Compare commits
149 Commits
version-0.
...
6fcecaab00
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fcecaab00 | |||
| 97a82f4e1d | |||
| e0ef485e04 | |||
| 21ea77eadc | |||
| ea5936717a | |||
| bdaee820ad | |||
| 718c0d2aed | |||
| ff699d220f | |||
| 77d0d73ed7 | |||
| 34260e7cbd | |||
| d81e7a9413 | |||
| 6352a43e27 | |||
| 54ea7b5813 | |||
| 5a9e156566 | |||
| 169e536011 | |||
| 5e3fdd1841 | |||
| bf03c56649 | |||
| 5d081654ef | |||
| 57995e0bdf | |||
| 3a3dc1727f | |||
| 0cb3cbe470 | |||
| 037ddc99ee | |||
| 226297510a | |||
| 2fd4c84e1a | |||
| 494cc3b8e1 | |||
| c0d1e1a3d5 | |||
| 843f474a98 | |||
| ec852c0002 | |||
| 8ca47953d2 | |||
| 0f20a11666 | |||
| 4fc42393d8 | |||
| 832068a61f | |||
| 5299ab9148 | |||
| e47c1f1a86 | |||
| 898db9a354 | |||
| 866bbc3270 | |||
| 36f0ac5122 | |||
| 1969a4080a | |||
| 069e9ee496 | |||
| bded788431 | |||
| 8005968ec5 | |||
| 04793da52c | |||
| 5095dd6964 | |||
| 09cc2dcf94 | |||
| dc062f8a3e | |||
| 0137a4675f | |||
| 402c3399e4 | |||
| f0dd028051 | |||
| 4083799c13 | |||
| 9598bbac4a | |||
| 95e833e7cf | |||
| b08112699e | |||
| 76b7c126c2 | |||
| 1164a11410 | |||
| b690420550 | |||
| 83f814c729 | |||
| 0f0a8d1c91 | |||
| 0cca9229ad | |||
| d1be71ff47 | |||
| 2b0def806a | |||
| f57754b5f8 | |||
| 3cb584646c | |||
| e33484b802 | |||
| 5b8642a70b | |||
| f9cd3063fb | |||
| de74c7b431 | |||
| ca8702fd7f | |||
| f9056f92e0 | |||
| eedb0b6a0e | |||
| 9017b70e90 | |||
| 18b9194530 | |||
| 6fe2f6be95 | |||
| 6f8a80a58a | |||
| 306e691c61 | |||
| 4b3e8a961c | |||
| 97879b4539 | |||
| ad7bdb7c5e | |||
| 4f3cc01e07 | |||
| a502e4fc3e | |||
| 32464c93fb | |||
| b3f1917441 | |||
| 86df772354 | |||
| fbe7291b43 | |||
| 03775f7574 | |||
| 58ec5f96f2 | |||
| cddd2afbd8 | |||
| 7276945b92 | |||
| 1e882ba6c3 | |||
| b987decb8d | |||
| c7fa37da16 | |||
| ecb8dc7e75 | |||
| acfdf01dab | |||
| 6bdd7ea905 | |||
| 3c5a91e0ff | |||
| 38bd063109 | |||
| 2b6462fdd9 | |||
| fa489f458d | |||
| bf876e4de1 | |||
| 5440e72afb | |||
| d98bc357b9 | |||
| 90c8a6e562 | |||
| 91c147eafd | |||
| c00b10a230 | |||
| ea5328450b | |||
| 2d13556b17 | |||
| 33fa672d32 | |||
| 3f0eadce34 | |||
| 4b20c966e0 | |||
| 3817fda38e | |||
| 7d55b5c3f0 | |||
| 21d9ee663c | |||
| 98aff899b3 | |||
| fa03b32c09 | |||
| 7d3c920e17 | |||
| e85f67c5d4 | |||
| d043126ac7 | |||
| dfc0c7f8e7 | |||
| 8ba627aee5 | |||
| 9bb4f00650 | |||
| b5e9bb116f | |||
| d002454031 | |||
| a0c1ab4228 | |||
| d3d2f24a6a | |||
| a13467bc2c | |||
| 5cee6539c6 | |||
| df8b062af6 | |||
| ab2f1af620 | |||
| 89f315052b | |||
| aad1cc772f | |||
| 418b0b7cbe | |||
| 5c5e1d534b | |||
| 8adccaa2df | |||
| 94f9790634 | |||
| e10f217385 | |||
| b6a10a1250 | |||
| c9f9eb4ee5 | |||
| 18fdfbe017 | |||
| c9dda75628 | |||
| f290878c5f | |||
| 39dbf56416 | |||
| 70f2389b17 | |||
| 01fb821dd3 | |||
| 4f8f902f3f | |||
| d7dcd164c8 | |||
| 90af7cf772 | |||
| 41a4f0eaea | |||
| 4cddbc8382 | |||
| 066e2fe008 | |||
| 5701fbf465 |
2
.cargo/config.toml
Normal file
2
.cargo/config.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[registries]
|
||||
pmo = { index = "sparse+https://gargoton.petite-maison-orange.fr/api/packages/pmo/cargo/" }
|
||||
@@ -1,31 +0,0 @@
|
||||
# PMOMusic Project Configuration
|
||||
|
||||
## Version Control
|
||||
Ce projet utilise **Jujutsu (jj)** pour le contrôle de version, PAS git.
|
||||
- Utiliser les commandes `jj` au lieu des commandes `git`
|
||||
- Bookmark principal : `main`
|
||||
- Ne jamais suggérer de commandes git
|
||||
|
||||
## Environnement
|
||||
Le PATH et les variables d'environnement sont configurés dans `.claude-env` à la racine du projet.
|
||||
|
||||
## Configuration de l'application
|
||||
- Fichier de configuration principal : `.pmomusic/config.yaml`
|
||||
- Configuration UPNP personnalisable pour différencier les instances en développement
|
||||
|
||||
## Développement
|
||||
Pendant le développement, plusieurs serveurs PMOMusic peuvent tourner en parallèle. Utiliser la configuration UPNP dans `.pmomusic/config.yaml` pour différencier les instances :
|
||||
|
||||
```yaml
|
||||
host:
|
||||
upnp:
|
||||
manufacturer: "PMOMusic-Dev1"
|
||||
udn_prefix: "pmomusic-dev1"
|
||||
model_name_prefix: "PMOMusic-Dev1"
|
||||
friendly_name_prefix: "PMOMusic-Dev1"
|
||||
```
|
||||
|
||||
## Architecture
|
||||
- Projet Rust multi-crates avec workspaces
|
||||
- Crates principales : pmoupnp, pmomediaserver, pmomediarenderer, pmoconfig
|
||||
- Pattern d'extension de configuration via traits (voir pmocache/src/config_ext.rs)
|
||||
19
.gitignore
vendored
19
.gitignore
vendored
@@ -8,7 +8,7 @@
|
||||
**/*.o
|
||||
**/*.o.d
|
||||
**/*.a
|
||||
**/*.flac
|
||||
**/*.flac
|
||||
**/*.aif
|
||||
**/*.aiff
|
||||
**/*.wav
|
||||
@@ -16,19 +16,22 @@
|
||||
**/*.mp4
|
||||
**/*.mp3
|
||||
**/*.ogg
|
||||
|
||||
xxx
|
||||
/dcai/
|
||||
**/.pmomusic.yml
|
||||
**/.pmomusic_covers/**
|
||||
**/.pmomusic_audio/**
|
||||
/.pmomusic
|
||||
.DS_Store
|
||||
target
|
||||
**/.DS_Store
|
||||
/target
|
||||
**/target/
|
||||
/.pmomusic_covers
|
||||
/.pmomusic_audio/**
|
||||
/.pmomusic_audio/**
|
||||
C/src/soxr-0.1.3/Release/tests
|
||||
**/Release/
|
||||
**/Debug/
|
||||
qobuz_debug
|
||||
OLD-GO-CODE/
|
||||
xxx
|
||||
xx
|
||||
@@ -37,9 +40,13 @@ pmo_src.txt
|
||||
upmpdcli/
|
||||
/*.xml
|
||||
test_upnp*.cargo/
|
||||
.cargo/
|
||||
setup-env.sh
|
||||
cache
|
||||
gupnp-tools
|
||||
pmo*_[0_9]*.txt
|
||||
webapp_[0_9]*.txt
|
||||
webapp_[0_9]*.txt
|
||||
RF.json
|
||||
RF_old.json
|
||||
.claude/
|
||||
.claude.old
|
||||
Kilo-session.md
|
||||
|
||||
405
.kilo/plans/1775285337131-neon-mountain.md
Normal file
405
.kilo/plans/1775285337131-neon-mountain.md
Normal file
@@ -0,0 +1,405 @@
|
||||
# Évaluation du plan : centraliser_base_url_axum_middleware
|
||||
|
||||
## Résumé de l'audit
|
||||
|
||||
Le plan est **bien pensé et cohérent**. Il identifie correctement le problème et la solution. Cependant, j'ai identifié plusieurs points nécessitant des amendements.
|
||||
|
||||
---
|
||||
|
||||
## Points validés (conformes au code actuel)
|
||||
|
||||
1. **Problème bien identifié** : URLs hardcodées avec IP locale (`PMO_SERVER_URL`) retournées au frontend via reverse proxy.
|
||||
|
||||
2. **`get_request_base_url` existe déjà** à `pmoserver/src/lib.rs:199` — pas besoin de la recréer.
|
||||
|
||||
3. **`covers_route_for` existe déjà** dans `pmocache/src/lib.rs:149`.
|
||||
|
||||
4. **`covers_absolute_url_for` utilisée dans les contextes UPnP** :
|
||||
- `pmoupnp/src/cache_registry.rs:57`
|
||||
- `pmoradiofrance/src/metadata_cache.rs:263`
|
||||
- `pmoparadise/src/source.rs:216`
|
||||
- `pmoaudio-ext/src/sinks/streaming_icyflac_sink.rs:91`
|
||||
|
||||
5. **Route audio correcte** : `/audio/tracks/{pk}` (pas `/audio/flac/{pk}`).
|
||||
|
||||
6. **Architecture du Server** : Les routes sont construites dynamiquement via `Arc<RwLock<Router>>`. Le layer devra être ajouté dans la construction du router, pas après.
|
||||
|
||||
---
|
||||
|
||||
## Points à amender
|
||||
|
||||
### 1. Ajout du layer dans le Server
|
||||
|
||||
Le plan suggère d'ajouter le layer "dans `server.rs`" mais la structure du router est complexe :
|
||||
- Les routes sont dynamiques (`RwLock<Router>`)
|
||||
- Le router final est un fallback qui délègue
|
||||
|
||||
**Correction** : Ajouter le layer directement lors de la création du `registry_route` initial (ligne 120-122) :
|
||||
|
||||
```rust
|
||||
let registry_route = Router::new()
|
||||
.route("/api/registry", get(get_api_registry))
|
||||
.with_state(api_registry.clone())
|
||||
.layer(base_url_layer()); // ← ici
|
||||
```
|
||||
|
||||
### 2. Comportement requis pour LAN vs WAN
|
||||
|
||||
Le middleware doit supporter les deux cas d'usage :
|
||||
|
||||
- **LAN (sans reverse proxy)** : Pas de headers `X-Forwarded-*` → utiliser l'adresse IP locale du serveur (`PMO_SERVER_URL`)
|
||||
- **WAN (via reverse proxy)** : Headers `X-Forwarded-*` présents → utiliser l'URL publique du reverse proxy
|
||||
|
||||
**Important** : `get_request_base_url` dans `pmoserver/src/lib.rs:199` lit déjà ces headers. Le fallback doit être `PMO_SERVER_URL` qui est configuré au démarrage avec l'IP locale.
|
||||
|
||||
### 3. Chemin du middleware dans la pile
|
||||
|
||||
Le plan dit d'appliquer le layer "avant" les autres. En réalité, Tower/Acorn applique les couches dans l'ordre où elles sont ajoutées — le premier layer ajouté est le plus extérieur (exécuté en premier). Le `base_url_layer` doit donc être ajouté en **premier** (le plus intérieur) pour voir les headers nettoyés.
|
||||
|
||||
### 3. Les handlers n'ont PAS besoin de BaseUrl
|
||||
|
||||
Après analyse, **aucun handler** dans le codebase actuel n'appelle `covers_absolute_url_for()` directement pour le frontend. Les `album_art_uri` sont :
|
||||
- Soit **propagés** depuis les réponses UPnP des media servers (pas des URLs pmomusic)
|
||||
- Soit **construits en tâche de fond** dans les caches (RadioFrance, RadioParadise)
|
||||
|
||||
**Correction** : Le plan surestime le nombre de handlers à modifier. La vraie question est : d'où viennent les URLs incorrectes ?
|
||||
|
||||
### 4. Source du problème à clarifier
|
||||
|
||||
Les URLs incorrectes ne viennent pas des handlers REST classiques. Elles viennent probablement de :
|
||||
|
||||
**a) Tâches de fond** (background tasks) qui stockent des URLs complètes :
|
||||
- `pmoradiofrance/src/metadata_cache.rs:263` — construit `covers_absolute_url_for()` dans le cache
|
||||
- `pmoparadise/src/source.rs:216` — même problème
|
||||
|
||||
**b) API Qobuz** (`pmoqobuz/src/api_rest.rs:302`) — utilise `covers_route_for` (route relative, OK)
|
||||
|
||||
**c) Playlist** (`pmoplaylist/src/handle/read.rs:204,271,355`) — utilise `covers_route_for` (OK)
|
||||
|
||||
### 5. Correction du fallback
|
||||
|
||||
Le plan suggère `localhost:8080` ou `0.0.0.0:8080` comme fallback. Le port doit provenir de la configuration du serveur (`get_server_base_url()` existe déjà dans `pmoserver/src/lib.rs`).
|
||||
|
||||
**Correction** : Le fallback utilise `get_server_base_url()` (disponible via `GLOBAL_SERVER`) :
|
||||
- En LAN : pas de `X-Forwarded-*` → `get_server_base_url()` → URLs en IP locale
|
||||
- En WAN : `X-Forwarded-*` présents → URLs en URL publique du reverse proxy
|
||||
|
||||
### 6. Fonction `audio_route_for` pas nécessaire maintenant
|
||||
|
||||
Le plan propose d'ajouter `audio_route_for` dans `pmoaudiocache`. Mais :
|
||||
- Les fichiers audio sont servis par `pmoaudiocache` lui-même (routes internes)
|
||||
-Aucune URL audio n'est retournée au frontend via JSON
|
||||
|
||||
**Supprimer** cette étape du plan.
|
||||
|
||||
---
|
||||
|
||||
## Plan amendé
|
||||
|
||||
### Étape 0 — Audit spécifique (à faire avant implémentation)
|
||||
|
||||
```bash
|
||||
# Trouver les constructions d'URLs dans les tâches de fond (caches, sources)
|
||||
grep -rn "covers_absolute_url_for\|PMO_SERVER_URL" --include="*.rs" | grep -v "pmocontrol\|pmoplaylist\|pmoqobuz"
|
||||
|
||||
# Vérifier les URLs dans les réponses JSON des handlers
|
||||
grep -rn "album_art_uri" --include="*.rs" | grep -E "fn |->"
|
||||
```
|
||||
|
||||
Identifier spécifiquement quels endpoints REST retournent des URLs au frontend.
|
||||
|
||||
### Étape 1 — `pmoserver/src/lib.rs` : Ajouter `BaseUrl` + middleware
|
||||
|
||||
```rust
|
||||
use axum::{extract::Request, middleware::Next, response::Response};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BaseUrl(pub String);
|
||||
|
||||
impl BaseUrl {
|
||||
pub fn url_for(&self, route: &str) -> String {
|
||||
debug_assert!(route.starts_with('/'), "route must start with '/'");
|
||||
format!("{}{}", self.0.trim_end_matches('/'), route)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn base_url_middleware(mut request: Request, next: Next) -> Response {
|
||||
// Priorité : 1) X-Forwarded-* (reverse proxy), 2) get_server_base_url() (adresse configurée)
|
||||
let base = get_request_base_url(request.headers())
|
||||
.or_else(|| get_server_base_url())
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"BaseUrl: impossible de déterminer l'URL de base.\n\
|
||||
Configurer PMO_SERVER_URL ou démarrer le serveur avant les handlers HTTP."
|
||||
);
|
||||
});
|
||||
tracing::debug!("BaseUrl calculée : {}", base);
|
||||
request.extensions_mut().insert(BaseUrl(base));
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
pub fn base_url_layer() -> axum::middleware::FromFnLayer {
|
||||
axum::middleware::from_fn(base_url_middleware)
|
||||
}
|
||||
```
|
||||
|
||||
**Comportement** :
|
||||
- Accès LAN (pas de proxy) : `get_server_base_url()` → URLs en IP locale configurée
|
||||
- Accès WAN (reverse proxy) : `X-Forwarded-*` → URLs en URL publique
|
||||
|
||||
**Note** : Si ni les headers ni le serveur ne sont disponibles, le middleware panic (fail-fast) car c'est une erreur de configuration.
|
||||
|
||||
### Étape 2 — `pmoserver/src/server.rs` : Appliquer le layer
|
||||
|
||||
Dans `Server::new()`, ligne ~120-122 :
|
||||
|
||||
```rust
|
||||
let registry_route = Router::new()
|
||||
.route("/api/registry", get(get_api_registry))
|
||||
.with_state(api_registry.clone())
|
||||
.layer(base_url_layer()); // ← Ajouter ici (couche la plus intérieure)
|
||||
```
|
||||
|
||||
### Étape 3 — `pmocache/src/lib.rs` : Renommer sans déprecation
|
||||
|
||||
```rust
|
||||
// Rename direct - pas de déprecation (soft en cours de dev, pas une library)
|
||||
pub fn covers_absolute_url_for_upnp(pk: &str, param: Option<&str>) -> String {
|
||||
// PMO_SERVER_URL contient l'IP locale (LAN) - utilisé uniquement pour UPnP
|
||||
// Fallback sur get_server_base_url() si dispo, sinon erreur
|
||||
let base = std::env::var("PMO_SERVER_URL")
|
||||
.or_else(|_| pmoserver::get_server_base_url().ok_or("PMO_SERVER_URL not set"))
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::error!("covers_absolute_url_for_upnp: {}", e);
|
||||
panic!("BaseUrl non disponible pour UPnP");
|
||||
});
|
||||
format!("{}{}", base.trim_end_matches('/'), covers_route_for(pk, param))
|
||||
}
|
||||
```
|
||||
|
||||
### Étape 4 — Mettre à jour les appels UPnP
|
||||
|
||||
```bash
|
||||
grep -rn "covers_absolute_url_for" --include="*.rs"
|
||||
```
|
||||
|
||||
Modifier `pmoupnp/src/cache_registry.rs:57` → `covers_absolute_url_for_upnp`
|
||||
|
||||
### Étape 5 — Tâches de fond : stocker la route, pas l'URL
|
||||
|
||||
**pmoradiofrance/src/metadata_cache.rs:263** :
|
||||
```rust
|
||||
// Avant :
|
||||
let public_url = pmocache::covers_absolute_url_for(&pk, None);
|
||||
|
||||
// Après : stocker la route relative
|
||||
let album_art_route = pmocache::covers_route_for(&pk, None);
|
||||
```
|
||||
|
||||
Le handler REST qui retourne ces métadonnées devra extraire `Extension<BaseUrl>` et appliquer `base_url.url_for()`.
|
||||
|
||||
**pmoparadise/src/source.rs:216** : Même traitement.
|
||||
|
||||
### Étape 6 — Vérification et tests
|
||||
|
||||
```bash
|
||||
# Plus d'appels à covers_absolute_url_for dans les contextes HTTP
|
||||
grep -rn "covers_absolute_url_for" --include="*.rs" | grep -v "pmocache\|pmoupnp"
|
||||
|
||||
# Tests du middleware
|
||||
cargo test base_url
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Questions en suspens
|
||||
|
||||
1. **Fallback avec panic** : Si ni les headers ni le serveur ne sont disponibles, le middleware panic au démarrage avec un message clair (ex: "BaseUrl: configurer PMO_SERVER_URL ou démarrer le serveur avant les handlers HTTP").
|
||||
→ **Décision utilisateur** : OK, panic avec message clair.
|
||||
|
||||
2. **Reverse proxy avec Authelia** : NPM ajoutera les headers `X-Forwarded-*`. Authelia gère l'authentification separately. Pas de vérification de header supplémentaire nécessaire pour le middleware BaseUrl.
|
||||
→ **Décision** : Pas de vérification supplémentaire.
|
||||
|
||||
---
|
||||
|
||||
## Problème complémentaire : URLs de covers des media servers externes
|
||||
|
||||
### Contexte
|
||||
|
||||
Quand le control point accède à un media server externe sur le LAN (autre que pmomusic), les URLs d'articles (`album_art_uri`) retournées par ce media server externe contiennent des IPs locales du LAN externe (ex: `http://192.168.1.100:8080/covers/...`).
|
||||
|
||||
Ces URLs ne passent pas par notre système de caching et ne peuvent pas être rewritées par le middleware `BaseUrl` car elles sont :
|
||||
1. Recues depuis le réseau UPnP (pas via HTTP)
|
||||
2. Propagées directement dans les réponses REST/SSE sans transformation
|
||||
|
||||
### Solution proposée : Proxy de covers avec cache
|
||||
|
||||
Créer un nouveau endpoint HTTP qui agit comme un proxy transparent :
|
||||
1. **Détection** : Si l'URL demandée est une URL LAN externe (pas une URL locale de pmomusic)
|
||||
2. **Caching** : Utiliser `cache.add_from_url()` qui gère déjà la déduplication (pas de double-cache)
|
||||
3. **Rewriting** : Retourner l'URL locale du cache (`/covers/image/{pk}`)
|
||||
|
||||
**Note importante** : `pmocache::add_from_url()` gère déjà :
|
||||
- La vérification si l'URL est déjà en cache (ligne 673-683)
|
||||
- Le calcul du pk basé sur le contenu (pas sur l'URL)
|
||||
- La déduplication automatique pour les mêmes contenus
|
||||
|
||||
### Implémentation
|
||||
|
||||
**Nouvel endpoint dans `pmocovers/src/lib.rs` ou nouveau fichier `pmocovers/src/proxy.rs`** :
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CoverProxyParams {
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CoverProxyResponse {
|
||||
cached_url: String,
|
||||
pk: String,
|
||||
}
|
||||
|
||||
/// GET /covers/proxy?url=<encoded_url>
|
||||
/// Proxy transparent qui :
|
||||
/// 1. Détecte si l'URL est une URL LAN externe (pas déjà locale)
|
||||
/// 2. Ajoute à cache via add_from_url (déduplication automatique)
|
||||
/// 3. Retourne l'URL locale du cache
|
||||
pub async fn cover_proxy_handler(
|
||||
Query(params): Query<CoverProxyParams>,
|
||||
State(cache): State<CoverCache>,
|
||||
Extension(base_url): Extension<pmoserver::BaseUrl>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||
let external_url = ¶ms.url;
|
||||
|
||||
// Ignorer si déjà une URL locale (ne pas se cacher soi-même)
|
||||
if is_local_cover_url(external_url, &base_url) {
|
||||
return Err((StatusCode::BAD_REQUEST, "URL is already a local cover"));
|
||||
}
|
||||
|
||||
// Vérifier si c'est une URL LAN à proxyfier
|
||||
if !should_proxy_url(external_url) {
|
||||
return Err((StatusCode::BAD_REQUEST, "URL is not a LAN URL requiring proxy"));
|
||||
}
|
||||
|
||||
// Ajouter au cache (add_from_url gère la déduplication)
|
||||
let pk = cache.add_from_url(external_url, Some("external-covers"))
|
||||
.await
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
|
||||
|
||||
// Retourner l'URL locale
|
||||
let local_url = base_url.url_for(&pmocache::covers_route_for(&pk, None));
|
||||
Ok(Json(CoverProxyResponse { cached_url: local_url, pk }))
|
||||
}
|
||||
|
||||
/// Vérifie si l'URL est déjà une cover locale de NOTRE instance pmomusic
|
||||
/// Note: Les covers d'autres instances pmomusic sur le LAN DEVRAIENT être proxyfiées
|
||||
/// et mises en cache localement - c'est le comportement desired!
|
||||
fn is_local_cover_url(url: &str, base_url: &pmoserver::BaseUrl) -> bool {
|
||||
// Only skip if it's OUR instance's base URL
|
||||
// Covers from other pmomusic instances on LAN should be proxied and cached
|
||||
url.starts_with(&base_url.0)
|
||||
}
|
||||
|
||||
/// Vérifie si l'URL doit être proxyfiée (URL LAN externe)
|
||||
fn should_proxy_url(url: &str) -> bool {
|
||||
if let Ok(parsed) = url::Url::parse(url) {
|
||||
if let Some(host) = parsed.host_str() {
|
||||
// Proxy uniquement les URLs LAN (pas les URLs publiques)
|
||||
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
|
||||
return ip.is_private() || ip.is_loopback();
|
||||
}
|
||||
// aussi les .local
|
||||
return host.ends_with(".local") || host == "localhost";
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
```
|
||||
|
||||
**Points importants** :
|
||||
- Utiliser `add_from_url()` pour bénéficier de la déduplication automatique
|
||||
- Vérifier `is_local_cover_url()` avec uniquement la comparaison de base_url pour éviter que notre instance ne se cache elle-même
|
||||
- Les covers d'autres instances pmomusic sur le LAN DEVRAIENT être proxyfiées (comportement souhaité!)
|
||||
- Le TTL sera celui par défaut du cache (configurable)
|
||||
|
||||
**Mise à jour des handlers REST** :
|
||||
|
||||
Dans `pmocontrol/src/pmoserver_ext.rs` et `pmocontrol/src/sse.rs`, transformer les `album_art_uri` LAN :
|
||||
|
||||
```rust
|
||||
fn transform_external_cover_url(url: &str) -> String {
|
||||
if is_lan_url(url) {
|
||||
// Remplacer par l'URL du proxy
|
||||
let encoded = urlencoding::encode(url);
|
||||
return format!("/covers/proxy?url={}", encoded);
|
||||
}
|
||||
url.to_string()
|
||||
}
|
||||
```
|
||||
|
||||
**Appels dans les handlers** :
|
||||
|
||||
- `pmocontrol/src/pmoserver_ext.rs:2173` : `browse_container` → transformer `album_art_uri`
|
||||
- `pmocontrol/src/pmoserver_ext.rs:2424` : autre endpoint → même transformation
|
||||
- `pmocontrol/src/sse.rs:213` : `MetadataChanged` events → même transformation
|
||||
|
||||
### TTL
|
||||
|
||||
- Le TTL sera celui par défaut du cache `pmocovers`
|
||||
- C'est configurable via `pmoconfig` si besoin
|
||||
|
||||
### Sécurité
|
||||
|
||||
- Limiter aux URLs LAN uniquement (`192.168.x.x`, `10.x.x.x`, `172.16-31.x.x`, `localhost`)
|
||||
- Vérifier que l'URL n'est pas déjà une cover locale de pmomusic (éviter le cacheception)
|
||||
- Ajouter un rate limiting pour éviter le flood de téléchargement
|
||||
- Timeout de téléchargement : 10 secondes max
|
||||
|
||||
### Résumé des fichiers à modifier
|
||||
|
||||
1. **Nouveau** : `pmocovers/src/proxy.rs` - Endpoint de proxy
|
||||
2. **Modifier** : `pmocontrol/src/pmoserver_ext.rs` - Transformer les album_art_uri
|
||||
3. **Modifier** : `pmocontrol/src/sse.rs` - Transformer les album_art_uri dans les événements
|
||||
|
||||
---
|
||||
|
||||
## Plan: Passer le SSE en mode Async
|
||||
|
||||
### Contexte
|
||||
|
||||
Le SSE de PMO Control est **déjà async** (fonctions `pub async fn`), mais le traitement des événements utilise des fonctions **synchrones** (`fn renderer_event_to_payload` → `transform_cover_url_sync`). Cela nécessite des workarounds (threads avec runtime tokio séparés).
|
||||
|
||||
### Problèmes actuels
|
||||
|
||||
1. **Nested runtime**: `std::thread::spawn` avec `tokio::runtime::Runtime::new()` dans chaque appel
|
||||
2. **Performance dégradée**: Création d'un thread par URL de cover
|
||||
3. **Code complexe**: Workarounds pour exécuter de l'async dans du sync
|
||||
|
||||
### Solution
|
||||
|
||||
Rendre le traitement des événements **entièrement async** :
|
||||
|
||||
1. **Modifier** `renderer_event_to_payload` → `async fn renderer_event_to_payload`
|
||||
2. **Modifier** `transform_cover_url_sync` → `transform_cover_url` (async) avec `.await` direct
|
||||
3. **Supprimer** le workaround `proxy_cover_url_sync` dans `pmocovers` (quand les .await fonctionnent)
|
||||
|
||||
### Avantages attendus
|
||||
|
||||
1. **Fluidité accrue**: Pas de thread par cover,真正的 async/await
|
||||
2. **Meilleure réactivité**: Pas de blocking sur les événements SSE
|
||||
3. **Code plus propre**: Plus de workarounds, plus de runtime imbriqué
|
||||
4. **Meilleure scalabilité**: Plus de création de thread
|
||||
|
||||
### Fichiers à modifier
|
||||
|
||||
1. `pmocontrol/src/sse.rs`:
|
||||
- `renderer_event_to_payload` → `async fn`
|
||||
- `media_server_event_to_payload` → `async fn`
|
||||
- `all_events_sse`: utiliser les versions async
|
||||
|
||||
2. `pmocontrol/src/pmoserver_ext.rs`:
|
||||
- Utiliser `transform_cover_url` (async) avec `.await` au lieu de `transform_cover_url_sync`
|
||||
|
||||
3. `pmocontrol/src/control_point.rs` (si nécessaire):
|
||||
- Adapter les appels aux fonctions async
|
||||
97
Blackboard/Architecture/webrenderer.md
Normal file
97
Blackboard/Architecture/webrenderer.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# WebRenderer UPnP privé par navigateur
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
Le crate `pmowebrenderer` transforme chaque navigateur connecté en un **MediaRenderer UPnP privé**. Quand un navigateur se connecte via WebSocket, le backend Rust crée dynamiquement un device UPnP dédié. Le ControlPoint envoie des commandes SOAP à ce device, et les action handlers les relaient au navigateur via WebSocket. Le navigateur joue l'audio via `<audio>` et renvoie l'état au backend.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser (Vue.js) Rust Backend ControlPoint
|
||||
| | |
|
||||
|-- WS connect --------------->| |
|
||||
|<-- SessionCreated (token) ---| |
|
||||
|-- Init (capabilities) ------>| |
|
||||
| |-- register_device() -------->| (Server)
|
||||
| | (Device + Services custom) |
|
||||
| |-- push_renderer() ---------->| (CP registry)
|
||||
| | |
|
||||
| |<-- SOAP Play (control_handler)
|
||||
|<-- Command(Play, uri) -------| (action handler -> WS) |
|
||||
|-- StateUpdate(Playing) ----->| |
|
||||
| |-- update StateVarInstance -->| (evented -> SSE)
|
||||
| | |
|
||||
|-- WS disconnect ------------>| |
|
||||
| |-- device_says_byebye() ----->| (CP registry)
|
||||
```
|
||||
|
||||
## Flux de connexion
|
||||
|
||||
1. Le navigateur ouvre une WebSocket vers `/api/webrenderer/ws`
|
||||
2. Il envoie un message `Init` avec ses capabilities (user_agent, formats supportes)
|
||||
3. Le backend construit un `Device` UPnP avec des `Service` models custom :
|
||||
- AVTransport (Play, Stop, Pause, Seek, SetURI, GetPositionInfo, etc.)
|
||||
- RenderingControl (SetVolume, GetVolume, SetMute, GetMute)
|
||||
- ConnectionManager (GetProtocolInfo)
|
||||
4. Chaque Action a un handler qui capture le `mpsc::UnboundedSender<ServerMessage>` du WS
|
||||
5. Le device est enregistre via `Server::register_device()` (routes SOAP + DEVICE_REGISTRY)
|
||||
6. Un `RendererInfo` est pousse dans le `DeviceRegistry` du ControlPoint via `push_renderer()`
|
||||
7. Le backend renvoie un `SessionCreated` avec le token et les infos du renderer
|
||||
|
||||
## Decision cle : Services dynamiques (zero changement pmoupnp)
|
||||
|
||||
Plutot que de modifier pmoupnp pour permettre l'override de handlers post-creation, on **construit des `Service` models dynamiques** pour chaque session WebSocket :
|
||||
|
||||
- Les `StateVariable` statics de pmomediarenderer sont reutilisees via `Arc::clone(&*VAR)`
|
||||
- De nouvelles `Action` sont creees avec `Action::new()`, configurees avec `set_handler()` puis wrappees en `Arc`
|
||||
- Les handlers capturent le sender WS et le `SharedState` (clone a chaque appel via `Fn` closure)
|
||||
- Le `Device` est construit avec ces services custom, puis enregistre normalement
|
||||
|
||||
Cela reutilise toute l'infrastructure existante sans modification de pmoupnp ni pmomediarenderer.
|
||||
|
||||
## Propagation d'etat bidirectionnelle
|
||||
|
||||
### SOAP -> Navigateur (commandes)
|
||||
Les action handlers des services AVTransport/RenderingControl :
|
||||
1. Lisent les arguments SOAP depuis `ActionData` via la macro `get!()`
|
||||
2. Envoient un `ServerMessage::Command` ou `SetVolume`/`SetMute` via le canal mpsc
|
||||
3. Mettent a jour le `SharedState` local
|
||||
4. Retournent les arguments OUT via `set!()` si necessaire
|
||||
|
||||
### Navigateur -> UPnP (etats)
|
||||
Quand le navigateur envoie `StateUpdate`, `PositionUpdate`, `MetadataUpdate` ou `VolumeUpdate` :
|
||||
1. Le `SharedState` est mis a jour
|
||||
2. Les `StateVarInstance` du `DeviceInstance` sont mises a jour via `set_value(StateValue::...)`
|
||||
3. Les variables evented declenchent les notifications UPnP captees par le watcher du ControlPoint
|
||||
|
||||
## Cycle de vie
|
||||
|
||||
- **Connexion** : creation du Device, enregistrement aupres du Server et du ControlPoint
|
||||
- **Session active** : le `SessionManager` gere un timeout de 30 minutes d'inactivite
|
||||
- **Deconnexion WS** : appel a `device_says_byebye()` sur le registry du ControlPoint pour marquer offline
|
||||
- **Cleanup automatique** : le `SessionManager` verifie toutes les 60 secondes les sessions expirees
|
||||
- **Pas de SSDP** : les WebRenderers sont injectes directement, max_age de 86400s
|
||||
|
||||
## Structure des fichiers
|
||||
|
||||
| Fichier | Role |
|
||||
|---------|------|
|
||||
| `handlers.rs` | Action handlers SOAP->WS (play, stop, pause, seek, set_uri, get_*_info, volume, mute) |
|
||||
| `renderer.rs` | `WebRendererFactory` : construction dynamique Device/Services avec handlers |
|
||||
| `websocket.rs` | Handler WS : connexion, reception messages, creation device, propagation etat |
|
||||
| `session.rs` | `SessionManager` : gestion des sessions avec timeout |
|
||||
| `config.rs` | `WebRendererExt` trait pour `pmoserver::Server` (enregistrement route WS) |
|
||||
| `messages.rs` | Types de messages WS (ServerMessage, ClientMessage, etc.) |
|
||||
| `state.rs` | `RendererState` et `SharedState` (etat partage entre handlers et WS) |
|
||||
| `error.rs` | Types d'erreur du crate |
|
||||
| `lib.rs` | Exports publics |
|
||||
|
||||
## Points d'attention
|
||||
|
||||
1. **parking_lot::RwLockWriteGuard non-Send** : les guards de `SharedState` doivent etre dropes avant tout `.await` dans les handlers async. Utiliser des blocs `{ ... }` pour limiter la portee.
|
||||
|
||||
2. **Fn vs FnOnce** : les `ActionHandler` sont `Fn` (appeles plusieurs fois). Les closures doivent cloner `ws` et `state` a chaque appel, avant le `async move`.
|
||||
|
||||
3. **Routes Axum persistantes** : Axum ne supporte pas la suppression de routes. Les routes SOAP d'un device deconnecte persistent mais les handlers retournent des erreurs naturellement.
|
||||
|
||||
4. **Acces au Server** : le WebSocket handler utilise `pmoserver::get_server()` (singleton global) pour enregistrer les devices dynamiquement.
|
||||
82
Blackboard/Done/Bug_lecture_open_home.md
Normal file
82
Blackboard/Done/Bug_lecture_open_home.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# Bug : Duplication de piste en position 0 lors de la lecture
|
||||
|
||||
**Statut** : Terminé
|
||||
**Crate** : pmocontrol
|
||||
|
||||
---
|
||||
|
||||
## Description initiale du bug
|
||||
|
||||
### Contexte
|
||||
Lecture d'une playlist liée (bindée) à une queue OpenHome ou interne.
|
||||
|
||||
### Comportement observé
|
||||
1. Sélection d'une playlist → la queue se charge correctement
|
||||
2. Lecture démarre à la piste 1 → OK
|
||||
3. Fin de la piste 1 → passage à la piste 2
|
||||
4. **BUG** : Après un certain délai (~60s), la piste 2 est **dupliquée en position 0**
|
||||
5. La lecture continue depuis cette nouvelle position 0
|
||||
|
||||
### Symptôme clé
|
||||
Toute piste en cours de lecture finit par être dupliquée en première position de la queue.
|
||||
|
||||
---
|
||||
|
||||
## Analyse et cause racine
|
||||
|
||||
### Mécanisme du bug
|
||||
La fonction `sync_queue` (appelée lors des refreshes périodiques de playlist toutes les 60 secondes) comparait les items **uniquement par leur URI**.
|
||||
|
||||
Si le MediaServer retournait une URI légèrement différente pour le même morceau (tokens de session, encodage différent, etc.), l'item courant n'était pas reconnu dans la nouvelle playlist et était préservé en position 0, créant une duplication.
|
||||
|
||||
### Flux problématique
|
||||
```
|
||||
1. Playlist attachée → lecture piste N
|
||||
2. Refresh périodique (60s) → sync_queue()
|
||||
3. Comparaison URI courante vs URIs playlist
|
||||
4. URI non trouvée → piste préservée en position 0
|
||||
5. Résultat : duplication
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Solution implémentée
|
||||
|
||||
### Principe
|
||||
Extension de la logique de comparaison pour utiliser l'URI **OU** le `didl_id` comme critère d'identification. Le `didl_id` est l'identifiant DIDL-Lite stable assigné par le MediaServer, indépendant de l'URI de streaming.
|
||||
|
||||
### Fichiers modifiés
|
||||
|
||||
| Fichier | Modifications |
|
||||
|---------|---------------|
|
||||
| `pmocontrol/src/queue/interne.rs` | Comparaison par `didl_id` en fallback + logs diagnostic |
|
||||
| `pmocontrol/src/queue/openhome.rs` | Fonction `items_match` + modification `sync_queue` et `lcs_flags` |
|
||||
|
||||
### Code clé
|
||||
|
||||
```rust
|
||||
// pmocontrol/src/queue/openhome.rs
|
||||
fn items_match(a: &PlaybackItem, b: &PlaybackItem) -> bool {
|
||||
a.uri == b.uri || a.didl_id == b.didl_id
|
||||
}
|
||||
```
|
||||
|
||||
```rust
|
||||
// pmocontrol/src/queue/interne.rs
|
||||
let new_idx = items.iter().position(|item| item.uri == current_uri)
|
||||
.or_else(|| items.iter().position(|item| item.didl_id == current_didl_id));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagnostic
|
||||
|
||||
Pour activer les logs :
|
||||
|
||||
```bash
|
||||
RUST_LOG=pmocontrol::queue=debug
|
||||
```
|
||||
|
||||
Messages de trace :
|
||||
- `sync_queue: current item found in new playlist` → Comportement normal
|
||||
- `sync_queue: current item NOT found in new playlist` → Cas problématique (ne devrait plus apparaître)
|
||||
35
Blackboard/Done/bug_images_cache_webapp.md
Normal file
35
Blackboard/Done/bug_images_cache_webapp.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Bug : Images non affichées dans la webapp (fallback SVG systématique)
|
||||
|
||||
## Description du bug
|
||||
|
||||
Dans l'application web Control Point, les images de couverture d'album ne s'affichent plus correctement. Au lieu d'afficher les images, seuls les petits logos SVG de fallback (icône Music) sont visibles, alors que :
|
||||
- Les URLs des images sont correctes
|
||||
- Les images sont bien présentes dans le cache
|
||||
- Les images sont accessibles via leur URL directe
|
||||
|
||||
Ce bug est apparu après une correction précédente visant à éliminer les images grises.
|
||||
|
||||
## Symptômes
|
||||
|
||||
- Les composants affichent l'icône SVG de fallback au lieu des vraies images
|
||||
- Le problème est plus fréquent qu'avant la correction précédente
|
||||
- Les images en cache du navigateur ne s'affichent pas
|
||||
|
||||
## Crates/Modules concernées
|
||||
|
||||
- **pmoapp/webapp** (application Vue.js)
|
||||
|
||||
## Composants à examiner
|
||||
|
||||
- `src/components/pmocontrol/CurrentTrack.vue`
|
||||
- `src/components/pmocontrol/QueueItem.vue`
|
||||
- `src/components/pmocontrol/MediaItem.vue`
|
||||
- `src/components/pmocontrol/RendererCard.vue`
|
||||
|
||||
## Cause suspectée
|
||||
|
||||
Le pattern d'affichage d'image avec `v-show="imageLoaded"` ne gère pas correctement le cas où l'image est déjà en cache du navigateur. Dans ce cas, l'événement `@load` peut se déclencher de manière synchrone avant que Vue n'ait attaché l'écouteur, laissant `imageLoaded` à `false`.
|
||||
|
||||
## Solution attendue
|
||||
|
||||
Ajouter une vérification de l'état `complete` de l'image après le montage du composant et après chaque changement d'URL, pour détecter les images déjà chargées depuis le cache.
|
||||
45
Blackboard/Done/bug_lecture_queue_interne.md
Normal file
45
Blackboard/Done/bug_lecture_queue_interne.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Bug lecture queue interne - RESOLU
|
||||
|
||||
## Tâche originale
|
||||
|
||||
**Crate concernée** : pmocontrol
|
||||
|
||||
**Problème rapporté** : Lors de la lecture sur un Renderer avec queue interne, si l'utilisateur clique sur un item de la queue pour déclencher sa lecture, tout semble se passer normalement pendant une seconde. Puis, avant que la lecture ne démarre réellement, le lecteur passe à la piste suivante.
|
||||
|
||||
---
|
||||
|
||||
## Synthèse de la résolution
|
||||
|
||||
### Cause racine
|
||||
|
||||
Race condition dans la logique d'auto-advance du watcher. Quand l'utilisateur sélectionne une piste :
|
||||
1. Les commandes UPnP `SetAVTransportURI` + `Play` sont envoyées
|
||||
2. Le renderer passe brièvement par un état `STOPPED` pendant l'initialisation
|
||||
3. Le watcher détecte ce `STOPPED` et déclenche l'auto-advance vers la piste suivante
|
||||
|
||||
Le système ne distinguait pas un état `STOPPED` transitoire (initialisation) d'un état `STOPPED` réel (fin de piste).
|
||||
|
||||
### Solution
|
||||
|
||||
Ajout d'un flag `has_played_since_track_start` dans `MusicRendererState` :
|
||||
|
||||
- **Remis à `false`** au démarrage d'une nouvelle piste (`play_from_index`, `play_from_queue`, etc.) et lors d'un `stop()`
|
||||
- **Passé à `true`** quand l'état `PLAYING` est détecté par le watcher
|
||||
- **L'auto-advance n'est autorisé** que si le flag est `true`
|
||||
|
||||
Ainsi, un état `STOPPED` transitoire (avant que `PLAYING` ne soit observé) n'entraîne plus d'auto-advance.
|
||||
|
||||
### Fichier modifié
|
||||
|
||||
- `pmocontrol/src/music_renderer/musicrenderer.rs`
|
||||
|
||||
### Méthodes ajoutées/modifiées
|
||||
|
||||
- `MusicRendererState.has_played_since_track_start` (nouveau champ)
|
||||
- `set_has_played_flag()`, `clear_has_played_flag()`, `check_and_clear_has_played_flag()` (nouvelles méthodes)
|
||||
- `handle_state_change()` (modifié pour utiliser le flag)
|
||||
- `play_current_from_queue()`, `play_next_from_queue()`, `play_from_index()`, `play_from_queue()`, `stop()` (modifiés pour réinitialiser le flag)
|
||||
|
||||
---
|
||||
|
||||
**Statut** : Corrigé et testé
|
||||
41
Blackboard/Done/bug_play_single_track.md
Normal file
41
Blackboard/Done/bug_play_single_track.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Synthèse : bug_play_single_track
|
||||
|
||||
## Tâche originale
|
||||
|
||||
**Crates concernées** : pmocontrol, pmoapp/webapp, pmoqobuz
|
||||
|
||||
**Problème** : Lorsque dans le ServerDrawer on clique sur le bouton de lecture d'un item simple Qobuz, rien ne se produit.
|
||||
|
||||
**Comportement attendu** :
|
||||
- Arrêt éventuel du renderer concerné
|
||||
- Effacement et détachement de sa queue de lecture
|
||||
- Ajout de la piste sélectionnée dans la queue de lecture
|
||||
- Lancement de la lecture
|
||||
|
||||
---
|
||||
|
||||
## Résolution
|
||||
|
||||
### Cause racine
|
||||
|
||||
`QobuzSource` n'implémentait pas `get_item()`. Quand le ContentDirectory recevait un `BrowseMetadata` sur un track individuel (`qobuz:track:123`), il ne pouvait pas retourner les métadonnées avec une URL HTTP valide.
|
||||
|
||||
- **Albums** : `get_or_create_album_playlist_items()` génère des URLs HTTP via le cache (`http://base_url/audio/flac/QOBUZ:123`)
|
||||
- **Tracks individuels** : URL symbolique `qobuz://track/123` non jouable par le renderer
|
||||
|
||||
### Solution
|
||||
|
||||
Implémentation de `get_item()` dans `QobuzSource` utilisant `add_track_lazy()` pour enregistrer le track dans le cache et retourner une URL HTTP absolue.
|
||||
|
||||
### Fichiers modifiés
|
||||
|
||||
| Fichier | Modification |
|
||||
|---------|--------------|
|
||||
| `pmoqobuz/src/source.rs` | Ajout de `get_item()` dans l'impl `MusicSource` |
|
||||
| `pmoqobuz/src/didl.rs` | `format_duration()` rendue publique |
|
||||
|
||||
---
|
||||
|
||||
## Statut
|
||||
|
||||
**Résolu** - Testé et validé.
|
||||
90
Blackboard/Done/bug_transfer_queue.md
Normal file
90
Blackboard/Done/bug_transfer_queue.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Bug transfert de queue entre renderers - RESOLU
|
||||
|
||||
## Tâche originale
|
||||
|
||||
**Crate concernée** : pmocontrol
|
||||
|
||||
**Problème rapporté** : Le transfert de la queue de lecture d'un renderer vers un autre ne semblait plus fonctionnel, avec des comportements erratiques. Le problème était potentiellement lié aux derniers changements sur le passage du renderer à un état stateless. L'interface utilisateur mettait également un certain temps à réagir.
|
||||
|
||||
---
|
||||
|
||||
## Synthèse de la résolution
|
||||
|
||||
### Cause racine
|
||||
|
||||
Dans la fonction `transfer_queue()` (`control_point.rs:1192-1291`), lorsqu'un binding de playlist existait sur le renderer source, le code appelait `attach_queue_to_playlist()` sur la destination **après** avoir rempli la queue avec les items sources.
|
||||
|
||||
Le problème : `attach_queue_to_playlist_internal()` effectue :
|
||||
1. `clear_for_playlist_attach()` - efface la queue du renderer
|
||||
2. `clear_queue()` - efface la queue locale
|
||||
3. `refresh_attached_queue_for()` - browse le serveur et **remplace** la queue
|
||||
|
||||
Cela **écrasait complètement** les items transférés avec `replace_queue()`, perdant le `current_index` et la position de lecture.
|
||||
|
||||
### Séquence problématique (avant correction)
|
||||
|
||||
```
|
||||
1. source_snapshot = get_renderer_queue_snapshot(source) // items + current_index
|
||||
2. clear_renderer_queue(dest)
|
||||
3. dest.replace_queue(source_snapshot.items, current_index) // Queue remplie OK
|
||||
4. attach_queue_to_playlist(dest, server, container) // ÉCRASE TOUT
|
||||
└─> clear_for_playlist_attach()
|
||||
└─> clear_queue()
|
||||
└─> refresh_attached_queue_for() → browse serveur → replace queue
|
||||
5. play() sur destination avec mauvaise queue
|
||||
```
|
||||
|
||||
### Solution
|
||||
|
||||
Remplacement de l'appel `attach_queue_to_playlist()` par un transfert direct du binding sans déclencher de refresh :
|
||||
|
||||
```rust
|
||||
// AVANT (problématique)
|
||||
if let Some((server_id, container_id, _)) = source_binding {
|
||||
self.attach_queue_to_playlist(dest_renderer_id, server_id, container_id)?;
|
||||
}
|
||||
|
||||
// APRÈS (corrigé)
|
||||
if let Some((server_id, container_id, has_seen_update)) = source_binding.clone() {
|
||||
let binding = PlaylistBinding {
|
||||
server_id,
|
||||
container_id,
|
||||
has_seen_update,
|
||||
pending_refresh: false, // Pas de refresh immédiat
|
||||
auto_play_on_refresh: false,
|
||||
};
|
||||
dest_renderer.set_playlist_binding(Some(binding));
|
||||
}
|
||||
```
|
||||
|
||||
### Séquence corrigée
|
||||
|
||||
```
|
||||
1. source_snapshot = get_renderer_queue_snapshot(source)
|
||||
2. clear_renderer_queue(dest)
|
||||
3. dest.replace_queue(source_snapshot.items, current_index) // Queue remplie OK
|
||||
4. dest.set_playlist_binding(binding avec pending_refresh=false) // Binding transféré OK
|
||||
5. play() sur destination avec bonne queue OK
|
||||
```
|
||||
|
||||
### Analyse des événements
|
||||
|
||||
L'analyse a confirmé que les émissions d'événements étaient correctes :
|
||||
|
||||
| Méthode | Événement émis |
|
||||
|---------|----------------|
|
||||
| `replace_queue()` | `QueueUpdated` |
|
||||
| `enqueue_items()` | `QueueUpdated` |
|
||||
| `clear_queue()` | `QueueUpdated` |
|
||||
| `set_playlist_binding()` | `BindingChanged` |
|
||||
| `clear_playlist_binding()` | `BindingChanged` |
|
||||
|
||||
Le problème de lenteur UI était lié au fait que la queue était écrasée puis re-remplie, causant plusieurs événements successifs et une confusion dans l'état affiché.
|
||||
|
||||
### Fichier modifié
|
||||
|
||||
- `pmocontrol/src/control_point.rs` : Modification de `transfer_queue()` lignes 1223-1244
|
||||
|
||||
---
|
||||
|
||||
**Statut** : Corrigé et testé
|
||||
57
Blackboard/Done/bug_url_cover.md
Normal file
57
Blackboard/Done/bug_url_cover.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Bug URL relative de la cover - TERMINÉ
|
||||
|
||||
## Description du bug
|
||||
|
||||
Le document DIDL généré par les PMO playlists possédait une URL absolue pour le flux audio, mais une URL relative pour la cover. Les deux entités (flux audio et cover) sont stockées dans des caches `pmoaudiocache` et `pmocovers` respectivement.
|
||||
|
||||
## Crates concernées
|
||||
|
||||
- **pmoqobuz** (crate corrigée)
|
||||
- pmoplaylist, pmocache, pmoaudiocache, pmocovers, pmodidl (explorées pour l'analyse)
|
||||
|
||||
## Analyse
|
||||
|
||||
### Architecture des URLs dans PMOMusic
|
||||
|
||||
1. **`pmocache::FileCache::route_for`** retourne une route **relative** (ex: `/audio/flac/abc123`, `/covers/images/def456`)
|
||||
|
||||
2. **Les sources (`pmoqobuz`, `pmoparadise`)** reçoivent un `base_url` à leur création et sont responsables de convertir les URLs relatives en absolues avant de retourner les résultats de Browse.
|
||||
|
||||
3. **`pmoplaylist::ReadHandle::to_items`** génère des items DIDL avec des URLs relatives pour l'audio ET la cover.
|
||||
|
||||
### Localisation du bug
|
||||
|
||||
Le bug se trouvait dans **`pmoqobuz/src/source.rs`** dans la fonction `adapt_playlist_items_to_qobuz`.
|
||||
|
||||
Cette fonction convertissait correctement l'URL audio en URL absolue mais **oubliait de faire la même conversion pour `item.album_art`** (la cover).
|
||||
|
||||
### Comparaison avec pmoparadise
|
||||
|
||||
`pmoparadise/src/source.rs` faisait déjà correctement les deux conversions (audio et cover).
|
||||
|
||||
## Correction appliquée
|
||||
|
||||
### Fichier modifié
|
||||
|
||||
- `pmoqobuz/src/source.rs`
|
||||
|
||||
### Modification
|
||||
|
||||
Ajout de la conversion de l'URL de la cover dans `adapt_playlist_items_to_qobuz` :
|
||||
|
||||
```rust
|
||||
// Convertir l'URL de la cover en URL absolue si elle est relative
|
||||
if let Some(art) = item.album_art.as_mut() {
|
||||
if art.starts_with('/') {
|
||||
*art = format!("{}{}", self.inner.base_url, art);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern architectural
|
||||
|
||||
Chaque source qui utilise `pmoplaylist::to_items()` doit post-traiter les items pour convertir les URLs relatives (`/audio/...`, `/covers/...`) en URLs absolues en utilisant son `base_url`.
|
||||
|
||||
## Statut
|
||||
|
||||
**TERMINÉ** - La compilation réussit après la correction.
|
||||
78
Blackboard/Done/fix-backend-mutex-poisoned.md
Normal file
78
Blackboard/Done/fix-backend-mutex-poisoned.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Fix: Backend Mutex Poisoned Panic sur OpenHome Stop
|
||||
|
||||
**Statut** : Terminé
|
||||
|
||||
## Problème initial
|
||||
|
||||
Lors de l'arrêt de la lecture sur un lecteur OpenHome, l'erreur suivante apparaissait :
|
||||
|
||||
```
|
||||
Impossible d'arrêter la lecture: Internal task error: task 66013 panicked with message "Backend mutex poisoned: PoisonError { .. }"
|
||||
```
|
||||
|
||||
## Crate concernée
|
||||
|
||||
- **pmocontrol** (`pmocontrol/src/`)
|
||||
|
||||
## Analyse de la cause racine
|
||||
|
||||
### Round 1 : Mutex empoisonné
|
||||
|
||||
Le mutex backend était empoisonné par des panics non gérés lors d'opérations sur les renderers. Les appels `.unwrap()` et `.expect()` sur le mutex propageaient les panics au lieu de les gérer gracieusement.
|
||||
|
||||
### Round 2 : Régression révélée par Round 1
|
||||
|
||||
Les correctifs du Round 1 ont révélé un problème plus profond. La chaîne d'échecs était :
|
||||
|
||||
1. **DeleteAll échoue avec erreur 501** : Le renderer OpenHome rejette l'action `DeleteAll` pendant la lecture active
|
||||
2. **Le code continue** (grâce aux correctifs Round 1 qui tolèrent les erreurs)
|
||||
3. **État incohérent du renderer** : OpenHome retourne 5 IDs via `IdArray` mais une `<TrackList>` vide via `ReadList`
|
||||
4. **Panic "index out of bounds"** : `sync_queue()` accède à `items[4]` alors que `items.len() == 0`
|
||||
5. **Mutex empoisonné** : Le panic dans le thread empoisonne le mutex
|
||||
|
||||
Preuve dans les logs :
|
||||
```
|
||||
OpenHome Playlist IdArray returned ... id_count=5
|
||||
OpenHome Playlist tracks read ... track_count=0 expected_count=5
|
||||
```
|
||||
|
||||
## Corrections apportées
|
||||
|
||||
### 1. Tolérance des erreurs clear_queue
|
||||
|
||||
**Fichier** : `pmocontrol/src/music_renderer/musicrenderer.rs`
|
||||
|
||||
La méthode `clear_for_playlist_attach()` tolère maintenant les erreurs de `clear_queue()` au lieu de propager l'erreur. Le `DeleteAll` n'est pas critique car `sync_queue()` remplacera de toute façon le contenu de la queue.
|
||||
|
||||
### 2. Suppression du clear_queue redondant
|
||||
|
||||
**Fichier** : `pmocontrol/src/control_point.rs`
|
||||
|
||||
Suppression de l'appel `renderer.clear_queue()?` dans `attach_queue_to_playlist_internal()`. Ce `clear_queue()` était redondant car `clear_for_playlist_attach()` le fait déjà, et causait un second échec `DeleteAll`.
|
||||
|
||||
### 3. Bounds-check pour current_index
|
||||
|
||||
**Fichier** : `pmocontrol/src/queue/openhome.rs`
|
||||
|
||||
Ajout d'une vérification de bornes dans `sync_queue()` pour gérer l'état incohérent du renderer OpenHome. Gère le cas où le renderer retourne un état incohérent (IDs sans données de track correspondantes).
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
| Fichier | Modification |
|
||||
|---------|-------------|
|
||||
| `pmocontrol/src/music_renderer/musicrenderer.rs` | Tolérance des erreurs `clear_queue()` dans `clear_for_playlist_attach()` |
|
||||
| `pmocontrol/src/control_point.rs` | Suppression du `clear_queue()` redondant |
|
||||
| `pmocontrol/src/queue/openhome.rs` | Bounds-check pour `current_index` + import `warn` |
|
||||
|
||||
## Comportement après correction
|
||||
|
||||
1. **DeleteAll échoue** : Warning loggé, le code continue
|
||||
2. **État incohérent détecté** : Warning loggé, traité comme "pas de track courante"
|
||||
3. **sync_queue réussit** : La playlist est correctement attachée au renderer
|
||||
4. **Pas de panic** : Le mutex reste sain
|
||||
|
||||
## Leçons apprises
|
||||
|
||||
La correction d'erreurs (Round 1) peut révéler des bugs latents. Le code supposait que l'état du renderer OpenHome était toujours cohérent. En réalité, certains renderers peuvent retourner des IDs de tracks sans les données correspondantes, notamment lorsqu'une opération `DeleteAll` est rejetée pendant la lecture.
|
||||
|
||||
**Approche défensive adoptée** : Plutôt que de supposer un état cohérent, le code vérifie les bornes et traite les incohérences comme des cas dégradés plutôt que de paniquer.
|
||||
330
Blackboard/Done/gestion_progressbar_on_stream.md
Normal file
330
Blackboard/Done/gestion_progressbar_on_stream.md
Normal file
@@ -0,0 +1,330 @@
|
||||
# Gestion de la barre de progression sur flux continu
|
||||
|
||||
## Spécification de la tâche
|
||||
|
||||
**Crate concernée** : `pmocontrol` et `pmoapp/webapp`
|
||||
|
||||
**Objectif** : Fournir une gestion correcte de la barre de progression de lecture sur des flux continus type radio artificiellement segmentés par l'intermédiaire des métadonnées.
|
||||
|
||||
### Contexte
|
||||
|
||||
Les radios web émettent un flux continu de données. Certaines d'entre elles émettent en parallèle des métadonnées permettant d'un point de vue logique de segmenter ce flux continu en chunks auxquels correspondent des métadonnées différentes. L'objectif est de faire en sorte que la Progress Bar reflète l'état d'avancement à l'intérieur de chacun de ces segments virtuels.
|
||||
|
||||
### Méthode proposée
|
||||
|
||||
Patcher la gestion des événements SSE vers l'application web de manière à envoyer des données de position de lecture en accord avec ces métadonnées dans le cas d'émissions en flux continu.
|
||||
|
||||
---
|
||||
|
||||
## Étape 1 : Implémentation du prédicat `is_playing_a_stream`
|
||||
|
||||
### Objectif
|
||||
Implémenter au niveau de la classe `MusicRenderer` une méthode prédicat `is_playing_a_stream()` qui retourne `true` si la lecture est en cours et que la musique est une radio en flux continu, `false` sinon.
|
||||
|
||||
### Implémentation réalisée
|
||||
|
||||
#### 1. Module de détection de stream
|
||||
**Fichier** : `pmocontrol/src/music_renderer/stream_detection.rs`
|
||||
|
||||
Création d'une fonction utilitaire centralisée `is_continuous_stream_url(url: &str) -> bool` qui :
|
||||
- Vérifie les patterns d'URL connus (`.m3u`, `.pls`, `/stream`, `/live`, etc.)
|
||||
- Effectue une requête HTTP HEAD pour analyser les headers :
|
||||
- Headers ICY (Icecast/Shoutcast) → stream
|
||||
- Absence de `Content-Length` + MIME type streaming → stream
|
||||
- `Transfer-Encoding: chunked` sans `Content-Length` → stream
|
||||
- **Optimisations** :
|
||||
- Cache global thread-safe (`STREAM_CACHE`) pour mémoriser les résultats par URL
|
||||
- Set de vérifications en cours (`PENDING_CHECKS`) pour éviter les doublons
|
||||
- Détection asynchrone dans un thread séparé pour ne pas bloquer
|
||||
- Utilise `std::sync::LazyLock` (stdlib Rust 1.80+)
|
||||
|
||||
#### 2. Implémentation par backend
|
||||
|
||||
##### Renderers simples (UPnP, Chromecast, LinkPlay)
|
||||
**Fichiers** : `upnp_renderer.rs`, `chromecast_renderer.rs`, `linkplay_renderer.rs`
|
||||
- Ajout d'un champ `continuous_stream: Arc<Mutex<bool>>`
|
||||
- Détection lors de `play_uri()` : appel à `is_continuous_stream_url(uri)` et stockage du résultat
|
||||
- Méthode publique `is_continuous_stream(&self) -> bool`
|
||||
- Logs de debug pour tracer la détection
|
||||
|
||||
##### Renderer OpenHome
|
||||
**Fichier** : `openhome_renderer.rs`
|
||||
- Ajout de `continuous_stream: Arc<Mutex<bool>>`
|
||||
- Ajout de `current_track_uri: Arc<Mutex<Option<String>>>`
|
||||
- Détection dans `playback_position()` uniquement lors d'un changement d'URI :
|
||||
```rust
|
||||
let uri_changed = cached_uri.as_ref() != Some(&track.uri);
|
||||
if uri_changed {
|
||||
let is_stream = is_continuous_stream_url(&track.uri);
|
||||
*self.continuous_stream.lock().unwrap() = is_stream;
|
||||
}
|
||||
```
|
||||
- Rationale : OpenHome gère sa playlist en interne, on doit détecter les changements d'URL
|
||||
|
||||
##### Renderer ArylicTcp
|
||||
**Fichier** : `arylic_tcp.rs`
|
||||
- Champ `continuous_stream` ajouté mais non utilisé (pas de support `play_uri()`)
|
||||
- Préparé pour extension future
|
||||
|
||||
#### 3. Méthode `MusicRenderer::is_playing_a_stream()`
|
||||
**Fichier** : `musicrenderer.rs`
|
||||
|
||||
```rust
|
||||
pub fn is_playing_a_stream(&self) -> bool {
|
||||
let backend = self.lock_backend_for("is_playing_a_stream");
|
||||
|
||||
// Vérifie que le renderer est en lecture
|
||||
let is_playing = matches!(
|
||||
backend.playback_state(),
|
||||
Ok(PlaybackState::Playing)
|
||||
);
|
||||
if !is_playing { return false; }
|
||||
|
||||
// Interroge le backend pour le statut stream
|
||||
match &*backend {
|
||||
MusicRendererBackend::Upnp(upnp) => upnp.is_continuous_stream(),
|
||||
MusicRendererBackend::OpenHome(oh) => oh.is_continuous_stream(),
|
||||
MusicRendererBackend::LinkPlay(lp) => lp.is_continuous_stream(),
|
||||
MusicRendererBackend::ArylicTcp(ary) => ary.is_continuous_stream(),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.is_continuous_stream(),
|
||||
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.is_continuous_stream(),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Émission d'événement SSE
|
||||
**Fichier** : `musicrenderer.rs` (méthode `poll_and_emit_changes()`)
|
||||
|
||||
```rust
|
||||
let is_stream = self.is_playing_a_stream();
|
||||
if watched.is_stream != Some(is_stream) {
|
||||
tracing::info!(
|
||||
"Stream state changed for renderer {}: is_stream={}",
|
||||
self.id().0,
|
||||
is_stream
|
||||
);
|
||||
self.emit_event(RendererEvent::StreamStateChanged {
|
||||
id: self.id(),
|
||||
is_stream,
|
||||
});
|
||||
watched.is_stream = Some(is_stream);
|
||||
}
|
||||
```
|
||||
|
||||
**Fichier** : `watcher.rs`
|
||||
- Ajout du champ `is_stream: Option<bool>` dans `WatchedState`
|
||||
|
||||
**Fichier** : `model.rs`
|
||||
- Ajout de l'événement `StreamStateChanged { id: DeviceId, is_stream: bool }` dans `RendererEvent`
|
||||
|
||||
---
|
||||
|
||||
## Étape 2 : Interface web et API
|
||||
|
||||
### Objectif
|
||||
- Pousser via SSE une information indiquant le changement d'état (flux continu vs morceau)
|
||||
- Ajouter un endpoint REST API pour interroger l'état stream
|
||||
- Afficher un indicateur visuel "Web Radio" dans l'interface web
|
||||
|
||||
### Implémentation réalisée
|
||||
|
||||
#### 1. Backend API
|
||||
|
||||
**Fichier** : `openapi.rs`
|
||||
```rust
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct StreamState {
|
||||
pub is_stream: bool,
|
||||
pub is_playing: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, ToSchema)]
|
||||
pub struct FullRendererSnapshot {
|
||||
pub state: RendererStateView,
|
||||
pub queue: QueueSnapshotView,
|
||||
pub binding: Option<RendererBindingView>,
|
||||
pub is_stream: bool, // ← Nouveau champ
|
||||
}
|
||||
```
|
||||
|
||||
**Fichier** : `pmoserver_ext.rs`
|
||||
- Endpoint REST : `GET /api/control/renderers/{renderer_id}/stream-state`
|
||||
```rust
|
||||
async fn get_stream_state(...) -> Result<Json<StreamState>, ...> {
|
||||
let renderer = state.control_point.music_renderer_by_id(&rid)?;
|
||||
let is_stream = renderer.is_playing_a_stream();
|
||||
let is_playing = matches!(
|
||||
renderer.playback_state()?,
|
||||
PlaybackState::Playing
|
||||
);
|
||||
Ok(Json(StreamState { is_stream, is_playing }))
|
||||
}
|
||||
```
|
||||
|
||||
**Fichier** : `control_point.rs`
|
||||
- Modification de `renderer_full_snapshot()` pour inclure `is_stream` :
|
||||
```rust
|
||||
let is_stream = renderer.is_playing_a_stream();
|
||||
Ok(FullRendererSnapshot {
|
||||
state: state_view,
|
||||
queue: queue_view,
|
||||
binding,
|
||||
is_stream,
|
||||
})
|
||||
```
|
||||
|
||||
**Fichier** : `sse.rs`
|
||||
- Ajout du payload SSE :
|
||||
```rust
|
||||
pub enum RendererEventPayload {
|
||||
StreamStateChanged {
|
||||
renderer_id: String,
|
||||
is_stream: bool,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
// ...
|
||||
}
|
||||
```
|
||||
- Conversion dans `renderer_event_to_payload()` :
|
||||
```rust
|
||||
RendererEvent::StreamStateChanged { id, is_stream } => {
|
||||
RendererEventPayload::StreamStateChanged {
|
||||
renderer_id: id.0,
|
||||
is_stream,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **Refactorisation bonus** : Création de `media_server_event_to_payload()` pour éliminer ~70 lignes de code dupliqué dans les conversions d'événements serveur
|
||||
|
||||
#### 2. Frontend TypeScript
|
||||
|
||||
**Fichier** : `pmoapp/webapp/src/services/pmocontrol/types.ts`
|
||||
```typescript
|
||||
export type RendererEventPayload =
|
||||
| { type: "stream_state_changed"; renderer_id: string; is_stream: boolean; timestamp: string }
|
||||
| ... // autres événements
|
||||
|
||||
export interface FullRendererSnapshot {
|
||||
state: RendererState;
|
||||
queue: QueueSnapshot;
|
||||
binding: AttachedPlaylistInfo | null;
|
||||
is_stream: boolean; // ← Nouveau champ
|
||||
}
|
||||
```
|
||||
|
||||
**Fichier** : `pmoapp/webapp/src/composables/useRenderers.ts`
|
||||
- Gestion de l'événement SSE :
|
||||
```typescript
|
||||
case "stream_state_changed":
|
||||
snapshot.is_stream = event.is_stream;
|
||||
break;
|
||||
```
|
||||
- Exposition dans le composable `useRenderer()` :
|
||||
```typescript
|
||||
const isStream = computed(() => snapshot.value?.is_stream ?? false);
|
||||
return { renderer, snapshot, state, queue, binding, isStream, refresh };
|
||||
```
|
||||
|
||||
**Fichier** : `pmoapp/webapp/src/components/pmocontrol/QueueViewer.vue`
|
||||
- Import de l'icône Radio depuis lucide-vue-next
|
||||
- Récupération de `isStream` :
|
||||
```vue
|
||||
const { queue, binding, isStream } = useRenderer(toRef(props, "rendererId"));
|
||||
```
|
||||
- Affichage de l'indicateur :
|
||||
```vue
|
||||
<div class="status-indicators">
|
||||
<!-- Indicateur playlist attachée -->
|
||||
<div v-if="isAttached" class="binding-indicator">
|
||||
<Link :size="16" />
|
||||
<span class="binding-text">Attachée à une playlist</span>
|
||||
</div>
|
||||
|
||||
<!-- Indicateur web radio -->
|
||||
<div v-if="isStream" class="stream-indicator">
|
||||
<Radio :size="16" />
|
||||
<span class="stream-text">Web Radio</span>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
- Styles CSS : badge violet (`color: #9333ea`) cohérent avec le design
|
||||
|
||||
---
|
||||
|
||||
## Résultats et validation
|
||||
|
||||
### Tests réalisés
|
||||
1. ✅ Détection correcte des flux continus (radio)
|
||||
2. ✅ Détection correcte des fichiers avec durée
|
||||
3. ✅ Événements SSE `stream_state_changed` émis et reçus
|
||||
4. ✅ Indicateur "Web Radio" s'affiche dans l'interface
|
||||
5. ✅ Logs serveur montrent les changements d'état :
|
||||
```
|
||||
INFO pmocontrol::music_renderer::musicrenderer: Stream state changed for renderer uuid:2899a4df-...: is_stream=true
|
||||
```
|
||||
6. ✅ API REST `/renderers/{id}/full` contient le champ `is_stream`
|
||||
7. ✅ Interface fluide grâce au cache et à la détection asynchrone
|
||||
|
||||
### Performance
|
||||
- **Avant** : Blocage de l'interface lors de la détection HTTP HEAD (jusqu'à 3 secondes)
|
||||
- **Après** :
|
||||
- Première détection d'une URL : ~200-500ms en arrière-plan (non-bloquant)
|
||||
- Détections suivantes : < 1ms (cache hit)
|
||||
- Pas de doublons de requêtes HTTP grâce au système anti-collision
|
||||
|
||||
---
|
||||
|
||||
## Fichiers modifiés (liste exhaustive)
|
||||
|
||||
### Backend (pmocontrol)
|
||||
1. `pmocontrol/src/music_renderer/stream_detection.rs` (créé)
|
||||
2. `pmocontrol/src/music_renderer/upnp_renderer.rs`
|
||||
3. `pmocontrol/src/music_renderer/openhome_renderer.rs`
|
||||
4. `pmocontrol/src/music_renderer/linkplay_renderer.rs`
|
||||
5. `pmocontrol/src/music_renderer/arylic_tcp.rs`
|
||||
6. `pmocontrol/src/music_renderer/chromecast_renderer.rs`
|
||||
7. `pmocontrol/src/music_renderer/musicrenderer.rs`
|
||||
8. `pmocontrol/src/music_renderer/watcher.rs`
|
||||
9. `pmocontrol/src/music_renderer/mod.rs`
|
||||
10. `pmocontrol/src/model.rs`
|
||||
11. `pmocontrol/src/sse.rs`
|
||||
12. `pmocontrol/src/openapi.rs`
|
||||
13. `pmocontrol/src/pmoserver_ext.rs`
|
||||
14. `pmocontrol/src/control_point.rs`
|
||||
|
||||
### Frontend (webapp)
|
||||
15. `pmoapp/webapp/src/services/pmocontrol/types.ts`
|
||||
16. `pmoapp/webapp/src/composables/useRenderers.ts`
|
||||
17. `pmoapp/webapp/src/components/pmocontrol/QueueViewer.vue`
|
||||
|
||||
---
|
||||
|
||||
## Améliorations supplémentaires
|
||||
|
||||
### Refactorisation du code SSE
|
||||
- Création de `renderer_event_to_payload()` pour centraliser la conversion `RendererEvent` → `RendererEventPayload`
|
||||
- Création de `media_server_event_to_payload()` pour centraliser la conversion `MediaServerEvent` → `MediaServerEventPayload`
|
||||
- Élimination de ~300 lignes de code dupliqué
|
||||
- Principe DRY appliqué : une seule source de vérité pour chaque conversion
|
||||
|
||||
### Observabilité
|
||||
- Logs structurés avec `tracing` à différents niveaux :
|
||||
- `info` : changements d'état stream
|
||||
- `debug` : détection de patterns d'URL, résultats HTTP
|
||||
- `trace` : cache hits/misses, détails des headers HTTP
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
Les deux étapes de la tâche ont été complétées avec succès :
|
||||
|
||||
**Étape 1** : Implémentation complète de la détection de flux continus avec support de tous les backends (UPnP, OpenHome, LinkPlay, Chromecast, ArylicTcp) et architecture optimisée (cache, async, anti-doublon).
|
||||
|
||||
**Étape 2** : Exposition de l'information stream via SSE et API REST, avec affichage d'un indicateur visuel "Web Radio" dans l'interface web, suivant le même pattern graphique que l'indicateur "Attachée à une playlist".
|
||||
|
||||
**Bonus** : Optimisations de performance majeures pour garantir une interface fluide et réactive, même lors de la détection initiale de streams.
|
||||
|
||||
La solution est robuste, performante, et prête pour la gestion future de la progress bar sur les segments de métadonnées des radios web.
|
||||
63
Blackboard/Done/large_cover.md
Normal file
63
Blackboard/Done/large_cover.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Tâche : Amélioration de l'overlay de couverture d'album
|
||||
|
||||
**Crate concernée** : pmoapp/webapp
|
||||
|
||||
**Statut** : Terminée
|
||||
|
||||
---
|
||||
|
||||
## Demande initiale
|
||||
|
||||
### Point 1 : Métadonnées et progress bar
|
||||
Quand on clique sur l'image de couverture de l'album, elle s'agrandit pour prendre tout l'espace. Par défaut les métadonnées sont cachées et si on clique sur l'écran, il y a un effet toggle. Il fallait que par défaut elles soient visibles, avec une progress bar draggable dans une petite boîte au fond gris transparent avec un effet glass metal à la macOS.
|
||||
|
||||
### Point 2 : Ergonomie mobile
|
||||
Le bouton de fermeture en haut à droite n'était pas ergonomique sur mobile où les doigts sont plutôt en bas de l'écran.
|
||||
|
||||
---
|
||||
|
||||
## Synthèse des modifications
|
||||
|
||||
### Fichier modifié
|
||||
- `pmoapp/webapp/src/components/pmocontrol/CurrentTrack.vue`
|
||||
|
||||
### Point 1 : Résolu
|
||||
- Métadonnées visibles par défaut à l'ouverture
|
||||
- Progress bar interactive ajoutée dans le panneau des métadonnées (souris + tactile)
|
||||
- Style glassmorphism : fond transparent 35%, blur 30px, bordure subtile, ombre
|
||||
- Panneau réduit à 70% de largeur pour un look plus élégant
|
||||
|
||||
### Point 2 : Résolu
|
||||
- **Swipe down** pour fermer l'overlay sur tactile (seuil 100px)
|
||||
- Feedback visuel pendant le geste (translation + fade)
|
||||
- Bouton X conservé pour desktop
|
||||
- Blocage du pull-to-refresh Android (`overscroll-behavior: contain`)
|
||||
|
||||
### Corrections additionnelles
|
||||
- Support tactile de la progress bar principale (hors overlay)
|
||||
- Utilisation de `100dvh` au lieu de `100vh` pour compatibilité avec les barres de navigation mobiles (Brave, Safari, Chrome)
|
||||
- Corrections TypeScript pour les événements tactiles
|
||||
|
||||
---
|
||||
|
||||
## Éléments techniques clés
|
||||
|
||||
### Swipe down
|
||||
```typescript
|
||||
const swipeStartY = ref(0);
|
||||
const swipeCurrentY = ref(0);
|
||||
const isSwiping = ref(false);
|
||||
const swipeThreshold = 100;
|
||||
```
|
||||
Listeners ajoutés sur `document` pour capturer le mouvement même sur les éléments enfants.
|
||||
|
||||
### CSS mobile
|
||||
```css
|
||||
.cover-overlay {
|
||||
overscroll-behavior: contain;
|
||||
touch-action: none;
|
||||
}
|
||||
.cover-overlay-content {
|
||||
height: calc(100dvh - 32px);
|
||||
}
|
||||
```
|
||||
22
Blackboard/Done/openhome_queue_hybrid.md
Normal file
22
Blackboard/Done/openhome_queue_hybrid.md
Normal file
@@ -0,0 +1,22 @@
|
||||
**Tu réaliseras ce travail en appliquant scrupuleusement les règles définies dans [@Rules.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Rules.md)**
|
||||
|
||||
|
||||
Nous allons travailler spécifiquement et sur rien d'autre que la queue Open Home des Média Renderer dans la CRAT PMO Control. [@openhome.rs](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmocontrol/src/queue/openhome.rs)
|
||||
|
||||
Tu ne peux modifier que ce fichier et a priori tu n'as besoin de lire que ce fichier.
|
||||
|
||||
Actuellement, cette queue est Stateless. C'est parfait, sauf sur un point, la gestion des métadonnées. En effet, les services OpenHome ne permettent pas de modifier les métadonnées d'une piste. Et cela m'ennuie. car mon control point ne peut pas mettre à jour les métadonnées d'une piste si elles sont changées par le média serveur
|
||||
|
||||
Les items de la queue openhome sont identifiés par un ID. L'idée est de maintenir en cache dans la structure de queue Open Home une map qui lit cette ID avec des métadonnées. Je parle bien de l'ID open home de la track et pas de l'index (position) dans la queue de lecture.
|
||||
|
||||
Tout le jeu consistera à enregistrer une copie des métadonnées dans cette map à partir de toutes les méthode du fichier [@openhome.rs](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmocontrol/src/queue/openhome.rs) Qui accepte des PlaybackItem :
|
||||
|
||||
- append_or_init_index
|
||||
- replace_item
|
||||
- sync_queue
|
||||
|
||||
Inversement, à chaque fois qu'on retournera un playback item, On n'oubliera pas de renvoyer les métadonnées du cache plutôt que celles renvoyées par OpenHome. Peut-être qu'il est juste nécessaire de modifier playback_item_from_entry
|
||||
|
||||
On profitera des appels réguliers à la fonction queue_snapshot Pour faire le ménage dans le cache en ne gardant que les entrées qui correspondent aux ID de la queue actuelle.
|
||||
|
||||
Cela nous permettra de rajouter, une fonction d'update des métadonnées d'un item de la queue. Au niveau du backend open home, puis dans un second temps des autres backend de queue, puis du Média Renderer.
|
||||
76
Blackboard/Report/Bug_lecture_open_home.md
Normal file
76
Blackboard/Report/Bug_lecture_open_home.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Rapport : Bug de duplication de piste en position 0
|
||||
|
||||
## Résumé
|
||||
|
||||
Correction d'un bug où la piste en cours de lecture était dupliquée en position 0 de la queue après un certain temps.
|
||||
|
||||
## Problème identifié
|
||||
|
||||
### Symptôme
|
||||
Lors de la lecture d'une playlist liée à une queue (OpenHome ou interne), après le passage à une nouvelle piste, celle-ci finissait par être dupliquée en première position de la queue.
|
||||
|
||||
### Cause racine
|
||||
La fonction `sync_queue` (utilisée lors des refreshes périodiques de playlist toutes les 60 secondes) comparait les items uniquement par leur URI. Si le MediaServer retournait une URI légèrement différente pour le même morceau (tokens de session, encodage différent, etc.), l'item courant n'était pas reconnu dans la nouvelle playlist et était préservé en position 0, créant ainsi une duplication.
|
||||
|
||||
### Mécanisme détaillé
|
||||
1. Une playlist est attachée à un renderer
|
||||
2. La lecture commence sur la piste N
|
||||
3. Après 60 secondes, un refresh périodique déclenche `sync_queue`
|
||||
4. `sync_queue` compare l'URI de la piste courante avec les URIs de la playlist rafraîchie
|
||||
5. Si les URIs ne correspondent pas exactement, la piste courante est considérée comme "absente" de la playlist
|
||||
6. La logique de préservation insère alors la piste courante en position 0
|
||||
7. Résultat : duplication de la piste
|
||||
|
||||
## Solution appliquée
|
||||
|
||||
### Modification de la logique de comparaison
|
||||
|
||||
La comparaison des items a été étendue pour utiliser l'URI **OU** le `didl_id` comme critère d'identification. Le `didl_id` est l'identifiant DIDL-Lite stable assigné par le MediaServer, indépendant de l'URI de streaming.
|
||||
|
||||
### Fichiers modifiés
|
||||
|
||||
#### 1. `pmocontrol/src/queue/interne.rs`
|
||||
|
||||
- Ajout de la comparaison par `didl_id` en fallback dans `sync_queue`
|
||||
- Ajout de logs de diagnostic pour tracer les cas de non-correspondance
|
||||
|
||||
```rust
|
||||
// Avant
|
||||
let new_idx = items.iter().position(|item| item.uri == current_uri);
|
||||
|
||||
// Après
|
||||
let new_idx = items.iter().position(|item| item.uri == current_uri)
|
||||
.or_else(|| items.iter().position(|item| item.didl_id == current_didl_id));
|
||||
```
|
||||
|
||||
#### 2. `pmocontrol/src/queue/openhome.rs`
|
||||
|
||||
- Ajout de la fonction `items_match` pour encapsuler la logique de comparaison
|
||||
- Modification de `sync_queue` pour utiliser URI ou `didl_id`
|
||||
- Modification de `lcs_flags` (algorithme LCS) pour utiliser la même logique de comparaison
|
||||
|
||||
```rust
|
||||
fn items_match(a: &PlaybackItem, b: &PlaybackItem) -> bool {
|
||||
a.uri == b.uri || a.didl_id == b.didl_id
|
||||
}
|
||||
```
|
||||
|
||||
## Tests recommandés
|
||||
|
||||
1. Attacher une playlist à un renderer OpenHome
|
||||
2. Lancer la lecture
|
||||
3. Attendre plusieurs cycles de refresh (> 60 secondes)
|
||||
4. Vérifier que la queue ne contient pas de duplications
|
||||
5. Cliquer sur différentes pistes et vérifier le même comportement
|
||||
|
||||
## Diagnostic
|
||||
|
||||
Pour activer les logs de diagnostic :
|
||||
|
||||
```bash
|
||||
RUST_LOG=pmocontrol::queue=debug
|
||||
```
|
||||
|
||||
Les messages suivants permettent de tracer le comportement :
|
||||
- `sync_queue: current item found in new playlist` - Comportement normal
|
||||
- `sync_queue: current item NOT found in new playlist, preserving as first item` - Cas problématique (ne devrait plus apparaître avec le fix)
|
||||
1000
Blackboard/Report/Construire_pmoradiofrance.md
Normal file
1000
Blackboard/Report/Construire_pmoradiofrance.md
Normal file
File diff suppressed because it is too large
Load Diff
64
Blackboard/Report/Oubli_Main_Radio.md
Normal file
64
Blackboard/Report/Oubli_Main_Radio.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Rapport : Correction de l'affichage des groupes de radios
|
||||
|
||||
## Résumé
|
||||
|
||||
Correction des deux bugs identifiés dans l'organisation des groupes de stations Radio France :
|
||||
1. La radio principale était absente des groupes multi-stations (écrasée par les webradios)
|
||||
2. Le groupe ICI (radios locales) était inaccessible à cause d'une station virtuelle "ici" mal gérée
|
||||
|
||||
## Solution implémentée
|
||||
|
||||
Refactoring complet pour supprimer la notion de "station virtuelle" et utiliser des champs optionnels `group_name` et `group_slug` dans `StationGroup`.
|
||||
|
||||
### Modifications
|
||||
|
||||
**Fichiers modifiés :**
|
||||
- `pmoradiofrance/src/playlist.rs`
|
||||
- `pmoradiofrance/src/source.rs`
|
||||
- `pmoradiofrance/src/client.rs`
|
||||
|
||||
### Changements structurels
|
||||
|
||||
1. **Ajout de champs optionnels à `StationGroup`** :
|
||||
- `group_name: Option<String>` : nom personnalisé du groupe (pour ICI : "Radios ICI")
|
||||
- `group_slug: Option<String>` : slug personnalisé du groupe (pour ICI : "ici")
|
||||
|
||||
2. **Suppression de la station virtuelle "ici"** :
|
||||
- Plus de création d'une `Station { slug: "ici", name: "Radios ICI" }`
|
||||
- Le groupe ICI contient maintenant uniquement les vraies stations `francebleu_*`
|
||||
|
||||
3. **Méthodes helper dans `StationGroup`** :
|
||||
- `name()` : retourne `group_name` ou le nom de `stations[0]`
|
||||
- `slug()` : retourne `group_slug` ou le slug de `stations[0]`
|
||||
|
||||
4. **Simplification de la logique** :
|
||||
- `to_stub()` et `to_didl()` de `StationGroup` utilisent `self.name()` et `self.slug()`
|
||||
- Plus de traitement spécial pour détecter ou sauter la station virtuelle "ici"
|
||||
- `compute_parent_id()` de `Station` utilise le slug fixe "ici" pour les stations `francebleu_*`
|
||||
|
||||
5. **Tri alphabétique des stations** :
|
||||
- Dans chaque groupe : station principale en position 0, puis webradios triées par nom
|
||||
- Groupe ICI : toutes les stations triées par nom (pas de station principale)
|
||||
- Les stations se retrouvent toujours à la même position dans la liste
|
||||
|
||||
6. **Chargement parallèle des métadonnées** :
|
||||
- Utilisation de `futures::stream::buffer_unordered(5)` pour charger jusqu'à 5 stations en parallèle
|
||||
- Décalage de 50ms entre chaque requête pour éviter de surcharger l'API Radio France
|
||||
- Amélioration significative du temps de chargement des groupes multi-stations
|
||||
|
||||
7. **Filtrage de francebleu générique** :
|
||||
- Application de la règle métier : `francebleu` (sans suffixe) n'est pas une vraie station
|
||||
- Filtrage dans `discover_all_stations()` pour éviter qu'elle soit ajoutée à la liste
|
||||
- Utilisation de `group.slug()` au lieu de `stations[0].slug` dans `source.rs` pour gérer correctement le groupe ICI
|
||||
|
||||
### Résultat
|
||||
|
||||
- **Groupes avec radio principale** (FIP, France Inter, etc.) : la radio principale apparaît à l'index 0, suivie des webradios triées alphabétiquement
|
||||
- **Groupe ICI** : accessible et affiche toutes les radios locales triées alphabétiquement
|
||||
- Code plus simple et sans logique spéciale dispersée
|
||||
- Interface prévisible : les stations sont toujours au même endroit
|
||||
- **Chargement plus rapide** : les métadonnées sont récupérées en parallèle au lieu de séquentiellement
|
||||
|
||||
## Statut
|
||||
|
||||
Modifications terminées. Compilation à vérifier par l'utilisateur.
|
||||
134
Blackboard/Report/bug_images_cache_webapp.md
Normal file
134
Blackboard/Report/bug_images_cache_webapp.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# Rapport : Correction du bug d'affichage des images en cache dans la webapp
|
||||
|
||||
## Tâche originale
|
||||
|
||||
Les images de couverture d'album ne s'affichaient plus dans l'application web Control Point. Seuls les logos SVG de fallback étaient visibles, bien que les URLs soient correctes et les images accessibles.
|
||||
|
||||
## Analyse
|
||||
|
||||
### Pattern d'affichage existant
|
||||
|
||||
Les composants utilisaient le pattern suivant :
|
||||
|
||||
```vue
|
||||
<img
|
||||
v-if="item.album_art_uri && !imageError"
|
||||
v-show="imageLoaded"
|
||||
:src="item.album_art_uri"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div v-if="!item.album_art_uri || imageError || !imageLoaded" class="placeholder">
|
||||
<Music :size="20" />
|
||||
</div>
|
||||
```
|
||||
|
||||
Avec :
|
||||
```typescript
|
||||
const imageLoaded = ref(false);
|
||||
|
||||
watch(() => props.item.album_art_uri, () => {
|
||||
imageLoaded.value = false;
|
||||
imageError.value = false;
|
||||
});
|
||||
|
||||
function handleImageLoad() {
|
||||
imageLoaded.value = true;
|
||||
}
|
||||
```
|
||||
|
||||
### Cause du bug
|
||||
|
||||
Lorsqu'une image est **déjà en cache du navigateur**, elle peut se charger de manière **synchrone** avant que Vue n'ait attaché l'écouteur d'événement `@load`. Dans ce cas :
|
||||
|
||||
1. L'image est créée dans le DOM (via `v-if`)
|
||||
2. Le navigateur charge l'image immédiatement depuis le cache
|
||||
3. L'événement `load` se déclenche **avant** que Vue n'ait attaché `@load`
|
||||
4. `imageLoaded` reste à `false`
|
||||
5. L'image reste cachée par `v-show="imageLoaded"`
|
||||
6. Le placeholder SVG s'affiche à la place
|
||||
|
||||
Ce comportement est particulièrement fréquent avec des images déjà visitées ou après un rechargement de page.
|
||||
|
||||
## Correction appliquée
|
||||
|
||||
### Solution
|
||||
|
||||
Ajout d'une fonction `checkImageComplete()` qui vérifie si l'image est déjà chargée via les propriétés natives de l'élément `<img>` :
|
||||
|
||||
```typescript
|
||||
const coverImageRef = ref<HTMLImageElement | null>(null);
|
||||
|
||||
function checkImageComplete() {
|
||||
nextTick(() => {
|
||||
if (coverImageRef.value?.complete && coverImageRef.value?.naturalWidth > 0) {
|
||||
imageLoaded.value = true;
|
||||
imageError.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkImageComplete();
|
||||
});
|
||||
|
||||
watch(() => metadata.value?.album_art_uri, (newUri) => {
|
||||
imageLoaded.value = false;
|
||||
imageError.value = false;
|
||||
if (newUri) {
|
||||
checkImageComplete();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Et ajout de la référence sur l'élément `<img>` :
|
||||
|
||||
```vue
|
||||
<img ref="coverImageRef" ... />
|
||||
```
|
||||
|
||||
### Fichiers modifiés
|
||||
|
||||
| Fichier | Modification |
|
||||
|---------|--------------|
|
||||
| `pmoapp/webapp/src/components/pmocontrol/CurrentTrack.vue` | Ajout `checkImageComplete()`, `coverImageRef`, `onMounted` |
|
||||
| `pmoapp/webapp/src/components/pmocontrol/QueueItem.vue` | Ajout `checkImageComplete()`, `coverImageRef`, `onMounted` |
|
||||
| `pmoapp/webapp/src/components/pmocontrol/MediaItem.vue` | Ajout `checkImageComplete()`, `coverImageRef`, `onMounted` |
|
||||
| `pmoapp/webapp/src/components/pmocontrol/RendererCard.vue` | Ajout complet de la gestion d'état image (était absent) |
|
||||
|
||||
### Détail des modifications par composant
|
||||
|
||||
#### CurrentTrack.vue
|
||||
- Import de `onMounted`, `nextTick`
|
||||
- Ajout de `coverImageRef`
|
||||
- Ajout de `checkImageComplete()`
|
||||
- Modification du `watch` pour appeler `checkImageComplete()` après changement d'URL
|
||||
- Ajout de `onMounted(() => checkImageComplete())`
|
||||
- Ajout de `ref="coverImageRef"` sur l'élément `<img>`
|
||||
|
||||
#### QueueItem.vue
|
||||
- Mêmes modifications que CurrentTrack.vue
|
||||
|
||||
#### MediaItem.vue
|
||||
- Mêmes modifications que CurrentTrack.vue
|
||||
|
||||
#### RendererCard.vue
|
||||
- Ce composant n'avait pas de gestion d'état de chargement d'image
|
||||
- Ajout complet : `imageLoaded`, `imageError`, `coverImageRef`, `checkImageComplete()`
|
||||
- Ajout des handlers `@load` et `@error`
|
||||
- Modification de `hasCover` pour inclure `!imageError`
|
||||
- Ajout de `v-show="imageLoaded"` sur l'image
|
||||
- Modification de la condition du placeholder
|
||||
|
||||
## Vérification
|
||||
|
||||
- Build webpack réussi sans erreur
|
||||
- Compilation TypeScript OK
|
||||
|
||||
## Remarque technique
|
||||
|
||||
La propriété `HTMLImageElement.complete` retourne `true` si :
|
||||
- L'image a fini de charger (succès ou erreur)
|
||||
- L'attribut `src` est vide ou absent
|
||||
|
||||
C'est pourquoi on vérifie également `naturalWidth > 0` pour s'assurer que l'image a bien été chargée avec succès (une image en erreur a `naturalWidth === 0`).
|
||||
79
Blackboard/Report/bug_lecture_queue_interne.md
Normal file
79
Blackboard/Report/bug_lecture_queue_interne.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Rapport : Correction du bug de lecture sur queue interne
|
||||
|
||||
## Problème
|
||||
|
||||
Lors de la lecture sur un Renderer avec queue interne, si l'utilisateur clique sur un item de la queue pour déclencher sa lecture, tout semble se passer normalement pendant une seconde. Puis, avant que la lecture ne démarre réellement, le lecteur passe à la piste suivante.
|
||||
|
||||
## Analyse
|
||||
|
||||
### Cause identifiée
|
||||
|
||||
Le problème était une **race condition** dans la logique d'auto-advance du watcher.
|
||||
|
||||
Quand l'utilisateur clique sur un item de la queue :
|
||||
1. `play_queue_index` est appelé dans `ControlPoint`
|
||||
2. Les commandes UPnP `SetAVTransportURI` + `Play` sont envoyées au renderer
|
||||
3. Le renderer peut passer brièvement par un état `STOPPED` pendant l'initialisation de la nouvelle piste
|
||||
4. Le watcher (polling toutes les 500ms) détecte cet état `STOPPED`
|
||||
5. Comme la lecture était lancée depuis la queue (`PlaybackSource::FromQueue`), l'auto-advance se déclenche et passe à la piste suivante
|
||||
|
||||
### Détail technique
|
||||
|
||||
La logique d'auto-advance dans `handle_state_change` vérifie si `is_playing_from_queue()` retourne `true` pour décider de passer à la piste suivante quand l'état `STOPPED` est détecté. Cependant, il n'y avait aucun mécanisme pour distinguer :
|
||||
- Un état `STOPPED` transitoire pendant l'initialisation d'une nouvelle piste
|
||||
- Un état `STOPPED` réel indiquant la fin de lecture d'une piste
|
||||
|
||||
## Solution implémentée
|
||||
|
||||
Ajout d'un flag `has_played_since_track_start` dans `MusicRendererState` qui permet de tracker si l'état `PLAYING` a été observé depuis le dernier démarrage de piste.
|
||||
|
||||
### Logique du flag
|
||||
|
||||
1. **Quand on démarre une nouvelle piste** (`play_from_index`, `play_from_queue`, `play_next_from_queue`, `play_current_from_queue`) : le flag est remis à `false`
|
||||
|
||||
2. **Quand le watcher détecte l'état `PLAYING`** : le flag passe à `true`
|
||||
|
||||
3. **Quand le watcher détecte l'état `STOPPED`** :
|
||||
- Si `has_played_since_track_start == true` : c'est une vraie fin de piste → auto-advance autorisé
|
||||
- Si `has_played_since_track_start == false` : c'est un état transitoire pendant l'initialisation → auto-advance bloqué
|
||||
|
||||
4. **Quand `stop()` est appelé** : le flag est remis à `false`
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
### `pmocontrol/src/music_renderer/musicrenderer.rs`
|
||||
|
||||
1. **Ajout du champ `has_played_since_track_start`** dans `MusicRendererState` :
|
||||
```rust
|
||||
struct MusicRendererState {
|
||||
// ...
|
||||
/// Flag indicating that a PLAYING state has been observed since the last track start.
|
||||
/// This prevents auto-advance on transient STOPPED states during track initialization.
|
||||
/// Auto-advance is only allowed when this flag is true.
|
||||
has_played_since_track_start: bool,
|
||||
}
|
||||
```
|
||||
|
||||
2. **Ajout des méthodes de gestion du flag** :
|
||||
- `set_has_played_flag()` : met le flag à `true`
|
||||
- `clear_has_played_flag()` : met le flag à `false` (publique)
|
||||
- `check_and_clear_has_played_flag()` : vérifie et remet à `false`
|
||||
|
||||
3. **Modification de `handle_state_change`** :
|
||||
- Sur `PLAYING` : appelle `set_has_played_flag()`
|
||||
- Sur `STOPPED` avec `is_playing_from_queue()` : vérifie `check_and_clear_has_played_flag()` avant d'auto-advance
|
||||
|
||||
4. **Modification des méthodes de démarrage de lecture** :
|
||||
- `play_current_from_queue()`
|
||||
- `play_next_from_queue()`
|
||||
- `play_from_index()`
|
||||
- `play_from_queue()`
|
||||
- `stop()`
|
||||
|
||||
Toutes appellent `clear_has_played_flag()` pour réinitialiser le flag.
|
||||
|
||||
## Tests effectués
|
||||
|
||||
- Clic sur différents items de la queue : la piste sélectionnée est bien jouée sans saut
|
||||
- Lecture normale jusqu'à la fin d'une piste : l'auto-advance vers la piste suivante fonctionne correctement
|
||||
- Arrêt manuel (stop) : pas d'auto-advance intempestif
|
||||
37
Blackboard/Report/bug_play_single_track.md
Normal file
37
Blackboard/Report/bug_play_single_track.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Rapport : bug_play_single_track
|
||||
|
||||
## Résumé
|
||||
|
||||
Correction du bug empêchant la lecture d'un track Qobuz individuel depuis le ServerDrawer. La cause était l'absence d'implémentation de `get_item()` dans `QobuzSource`, résultant en des URLs symboliques non jouables.
|
||||
|
||||
## Travail effectué
|
||||
|
||||
1. **Analyse du flux** : Tracé du chemin depuis le clic sur le bouton play (frontend) jusqu'au backend pmocontrol
|
||||
2. **Identification de la cause** : `QobuzSource` n'implémentait pas `get_item()`, donc les tracks individuels retournaient des URLs symboliques `qobuz://track/{id}` au lieu d'URLs HTTP
|
||||
3. **Implémentation de la solution** : Ajout de `get_item()` utilisant le même mécanisme de cache lazy que les albums
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
| Fichier | Modification |
|
||||
|---------|--------------|
|
||||
| `pmoqobuz/src/source.rs` | Ajout de `get_item()` dans l'impl `MusicSource`, import de `format_duration` |
|
||||
| `pmoqobuz/src/didl.rs` | `format_duration()` rendue publique |
|
||||
|
||||
## Détails techniques
|
||||
|
||||
### Cause racine
|
||||
|
||||
- **Albums Qobuz** : `get_or_create_album_playlist_items()` crée une playlist avec URLs HTTP absolues (`http://base_url/audio/flac/QOBUZ:123`)
|
||||
- **Tracks individuels** : `get_item()` non implémenté → fallback échoue → URL symbolique `qobuz://track/123` inutilisable par le renderer
|
||||
|
||||
### Solution
|
||||
|
||||
`get_item()` :
|
||||
1. Parse l'object_id pour extraire le track_id
|
||||
2. Récupère le track via l'API Qobuz
|
||||
3. Enregistre le track dans le cache avec `add_track_lazy()`
|
||||
4. Retourne un `Item` avec URL HTTP absolue : `http://base_url/audio/flac/QOBUZ:{track_id}`
|
||||
|
||||
## Statut
|
||||
|
||||
Bug résolu et testé.
|
||||
91
Blackboard/Report/bug_transfer_queue.md
Normal file
91
Blackboard/Report/bug_transfer_queue.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Bug: Transfert de Queue entre Renderers
|
||||
|
||||
**Date**: 2026-01-17
|
||||
**Fichier principal**: `pmocontrol/src/control_point.rs`
|
||||
**Fonction affectée**: `transfer_queue()`
|
||||
|
||||
## Symptôme
|
||||
|
||||
Le transfert de la queue de lecture d'un renderer vers un autre ne fonctionnait plus correctement, avec des comportements erratiques. La queue transférée était écrasée et le current_index perdu.
|
||||
|
||||
## Cause Racine
|
||||
|
||||
Dans `transfer_queue()` (lignes 1192-1291), lorsqu'un binding de playlist existait sur le renderer source, le code appelait `attach_queue_to_playlist()` sur la destination après avoir rempli la queue.
|
||||
|
||||
**Problème**: `attach_queue_to_playlist_internal()` effectue les opérations suivantes:
|
||||
1. `clear_for_playlist_attach()` - efface la queue du renderer
|
||||
2. `clear_queue()` - efface la queue locale
|
||||
3. `refresh_attached_queue_for()` - browse le serveur et remplace la queue
|
||||
|
||||
Cela **écrasait complètement** les items qu'on venait de transférer avec `replace_queue()`.
|
||||
|
||||
### Séquence problématique (avant correction)
|
||||
|
||||
```
|
||||
1. source_snapshot = get_renderer_queue_snapshot(source) // items + current_index
|
||||
2. clear_renderer_queue(dest)
|
||||
3. dest.replace_queue(source_snapshot.items, current_index) // Queue remplie ✓
|
||||
4. attach_queue_to_playlist(dest, server, container) // ÉCRASE TOUT ✗
|
||||
└─> clear_for_playlist_attach()
|
||||
└─> clear_queue()
|
||||
└─> refresh_attached_queue_for() → browse serveur → replace queue
|
||||
5. play() sur destination avec mauvaise queue
|
||||
```
|
||||
|
||||
## Correction Appliquée
|
||||
|
||||
Remplacement de l'appel `attach_queue_to_playlist()` par un transfert direct du binding sans déclencher de refresh:
|
||||
|
||||
```rust
|
||||
// AVANT (problématique)
|
||||
if let Some((server_id, container_id, _)) = source_binding {
|
||||
self.attach_queue_to_playlist(dest_renderer_id, server_id, container_id)?;
|
||||
}
|
||||
|
||||
// APRÈS (corrigé)
|
||||
if let Some((server_id, container_id, has_seen_update)) = source_binding.clone() {
|
||||
let binding = PlaylistBinding {
|
||||
server_id,
|
||||
container_id,
|
||||
has_seen_update,
|
||||
pending_refresh: false, // Pas de refresh immédiat
|
||||
auto_play_on_refresh: false,
|
||||
};
|
||||
dest_renderer.set_playlist_binding(Some(binding));
|
||||
}
|
||||
```
|
||||
|
||||
### Séquence corrigée
|
||||
|
||||
```
|
||||
1. source_snapshot = get_renderer_queue_snapshot(source)
|
||||
2. clear_renderer_queue(dest)
|
||||
3. dest.replace_queue(source_snapshot.items, current_index) // Queue remplie ✓
|
||||
4. dest.set_playlist_binding(binding avec pending_refresh=false) // Binding transféré ✓
|
||||
5. play() sur destination avec bonne queue ✓
|
||||
```
|
||||
|
||||
## Événements
|
||||
|
||||
L'analyse a également confirmé que les émissions d'événements sont correctes:
|
||||
|
||||
| Méthode | Événement émis |
|
||||
|---------|----------------|
|
||||
| `replace_queue()` | `QueueUpdated` ✓ |
|
||||
| `enqueue_items()` | `QueueUpdated` ✓ |
|
||||
| `clear_queue()` | `QueueUpdated` ✓ |
|
||||
| `set_playlist_binding()` | `BindingChanged` ✓ |
|
||||
| `clear_playlist_binding()` | `BindingChanged` ✓ |
|
||||
|
||||
Le problème de lenteur UI mentionné était probablement lié au fait que la queue était écrasée puis re-remplie, causant plusieurs événements successifs et une confusion dans l'état affiché.
|
||||
|
||||
## Impact
|
||||
|
||||
- Transfert de queue fonctionnel à nouveau
|
||||
- Préservation du current_index lors du transfert
|
||||
- Binding de playlist correctement transféré sans perte de synchronisation
|
||||
- UI réactive car un seul cycle d'événements cohérent
|
||||
|
||||
## Fichiers Modifiés
|
||||
|
||||
- `pmocontrol/src/control_point.rs`: Modification de `transfer_queue()` lignes 1223-1244
|
||||
139
Blackboard/Report/bug_update_cover_webui.md
Normal file
139
Blackboard/Report/bug_update_cover_webui.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# Rapport : Correction du bug d'update des covers dans l'interface web
|
||||
|
||||
## Résumé
|
||||
|
||||
Tentative de correction du problème de mise à jour des images de couverture dans l'application web PMOMusic. Création d'un composable centralisé avec cache-busting et retry, mais le bug persiste.
|
||||
|
||||
## Solution implémentée
|
||||
|
||||
### 1. Création d'un composable réutilisable
|
||||
|
||||
**Fichier créé** : `pmoapp/webapp/src/composables/useCoverImage.ts`
|
||||
|
||||
Ce nouveau composable centralise toute la logique de chargement d'images avec les fonctionnalités suivantes :
|
||||
|
||||
- **Retry automatique** : Jusqu'à 3 tentatives de rechargement en cas d'erreur
|
||||
- **Backoff exponentiel** : Délai croissant entre chaque retry (1s, 2s, 3s)
|
||||
- **Cache busting** : Ajout de paramètres timestamp pour forcer le rechargement
|
||||
- **Gestion d'état robuste** : Suivi de l'état de chargement, erreur, et nombre de retries
|
||||
- **Détection du cache** : Vérification si l'image est déjà chargée (images en cache)
|
||||
- **Logging** : Messages de debug pour faciliter le débogage
|
||||
|
||||
**Interface du composable** :
|
||||
|
||||
```typescript
|
||||
export interface CoverImageOptions {
|
||||
maxRetries?: number; // Défaut: 3
|
||||
retryDelay?: number; // Défaut: 1000ms
|
||||
forceReload?: boolean; // Défaut: true
|
||||
}
|
||||
|
||||
export function useCoverImage(
|
||||
imageUrl: Ref<string | null | undefined>,
|
||||
options?: CoverImageOptions
|
||||
)
|
||||
```
|
||||
|
||||
**Retour** :
|
||||
```typescript
|
||||
{
|
||||
imageLoaded: Ref<boolean>,
|
||||
imageError: Ref<boolean>,
|
||||
coverImageRef: Ref<HTMLImageElement | null>,
|
||||
handleImageLoad: Function,
|
||||
handleImageError: Function
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Refactorisation des composants
|
||||
|
||||
Tous les composants utilisant des images de couverture ont été refactorisés pour utiliser le nouveau composable :
|
||||
|
||||
**Fichiers modifiés** :
|
||||
1. `pmoapp/webapp/src/components/pmocontrol/CurrentTrack.vue`
|
||||
2. `pmoapp/webapp/src/components/pmocontrol/MediaItem.vue`
|
||||
3. `pmoapp/webapp/src/components/pmocontrol/QueueItem.vue`
|
||||
4. `pmoapp/webapp/src/components/pmocontrol/RendererCard.vue`
|
||||
5. `pmoapp/webapp/src/components/pmocontrol/ContainerItem.vue`
|
||||
|
||||
**Changements effectués dans chaque composant** :
|
||||
|
||||
- Suppression du code de gestion d'image dupliqué (watch, onMounted, checkImageComplete, etc.)
|
||||
- Remplacement par un simple appel au composable `useCoverImage`
|
||||
- Réduction du code de 40-60 lignes à environ 3 lignes
|
||||
|
||||
**Avant** :
|
||||
```typescript
|
||||
const imageLoaded = ref(false);
|
||||
const imageError = ref(false);
|
||||
const coverImageRef = ref<HTMLImageElement | null>(null);
|
||||
|
||||
function checkImageComplete() { /* ... */ }
|
||||
watch(() => metadata.value?.album_art_uri, /* ... */);
|
||||
onMounted(() => { /* ... */ });
|
||||
function handleImageLoad() { /* ... */ }
|
||||
function handleImageError() { /* ... */ }
|
||||
```
|
||||
|
||||
**Après** :
|
||||
```typescript
|
||||
const albumArtUri = computed(() => metadata.value?.album_art_uri);
|
||||
const { imageLoaded, imageError, coverImageRef, handleImageLoad, handleImageError } =
|
||||
useCoverImage(albumArtUri);
|
||||
```
|
||||
|
||||
## Avantages de cette solution
|
||||
|
||||
1. **Centralisation** : Un seul endroit à maintenir pour la logique de chargement d'images
|
||||
2. **Robustesse** : Retry automatique en cas d'erreur réseau ou de timing
|
||||
3. **Debugging** : Logs détaillés pour identifier les problèmes
|
||||
4. **Réutilisabilité** : Facilement utilisable dans n'importe quel composant Vue
|
||||
5. **Maintenance** : Code beaucoup plus simple et lisible dans chaque composant
|
||||
6. **Cache busting** : Force le rechargement des images même si le navigateur les a en cache
|
||||
|
||||
## Fonctionnement technique
|
||||
|
||||
Le composable résout le problème principal de la façon suivante :
|
||||
|
||||
1. **Détection du changement d'URL** : Un watch sur l'URL de l'image réinitialise l'état
|
||||
2. **Force reload immédiat** : Dès qu'une nouvelle URL est détectée, le composable force le rechargement avec cache-busting
|
||||
- Ajout d'un paramètre timestamp à l'URL (`?_cb=timestamp_r0`)
|
||||
- Mise à jour directe du `src` de l'élément `<img>`
|
||||
3. **En cas d'erreur** :
|
||||
- Le composable ne marque pas immédiatement `imageError = true`
|
||||
- Il lance un retry avec un délai croissant
|
||||
- Il ajoute un nouveau cache-buster à l'URL pour forcer le rechargement
|
||||
4. **Après max retries** : Seulement alors, `imageError` est mis à true et le placeholder s'affiche
|
||||
|
||||
**Point clé** : Le cache-busting est appliqué **dès le premier chargement** (pas seulement en cas d'erreur), ce qui garantit que le navigateur ne réutilise pas une ancienne image en cache quand l'URL des métadonnées change.
|
||||
|
||||
## Tests suggérés
|
||||
|
||||
Pour valider la correction :
|
||||
|
||||
1. Démarrer l'application web
|
||||
2. Jouer une track avec une cover
|
||||
3. Passer à une autre track avec une cover différente
|
||||
4. Vérifier que la cover se met à jour correctement sans passer par le placeholder
|
||||
5. Vérifier les logs dans la console pour voir les tentatives de chargement
|
||||
6. Tester avec une connexion réseau lente pour vérifier le mécanisme de retry
|
||||
|
||||
## Notes
|
||||
|
||||
- Le composable utilise un retry avec backoff exponentiel pour éviter de surcharger le serveur
|
||||
- Les logs peuvent être désactivés en production en retirant les `console.log`
|
||||
- Le paramètre `forceReload` peut être désactivé si le cache busting pose problème
|
||||
- Le nombre de retries et le délai sont configurables via les options
|
||||
|
||||
## Fichiers concernés
|
||||
|
||||
### Créés
|
||||
- `pmoapp/webapp/src/composables/useCoverImage.ts`
|
||||
|
||||
### Modifiés
|
||||
- `pmoapp/webapp/src/composables/useCoverImage.ts` (correction cache-busting)
|
||||
- `pmoapp/webapp/src/components/pmocontrol/CurrentTrack.vue`
|
||||
- `pmoapp/webapp/src/components/pmocontrol/MediaItem.vue`
|
||||
- `pmoapp/webapp/src/components/pmocontrol/QueueItem.vue`
|
||||
- `pmoapp/webapp/src/components/pmocontrol/RendererCard.vue`
|
||||
- `pmoapp/webapp/src/components/pmocontrol/ContainerItem.vue`
|
||||
76
Blackboard/Report/bug_url_cover.md
Normal file
76
Blackboard/Report/bug_url_cover.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Rapport : Correction du bug URL relative de la cover
|
||||
|
||||
## Tâche originale
|
||||
|
||||
Le document DIDL généré par les PMO playlists possédait une URL absolue pour le flux audio, mais une URL relative pour la cover. Les deux entités (flux audio et cover) sont stockées dans des caches `pmoaudiocache` et `pmocovers` respectivement.
|
||||
|
||||
## Analyse
|
||||
|
||||
### Architecture des URLs dans PMOMusic
|
||||
|
||||
1. **`pmocache::FileCache::route_for`** retourne une route **relative** (ex: `/audio/flac/abc123`, `/covers/images/def456`)
|
||||
|
||||
2. **Les sources (`pmoqobuz`, `pmoparadise`)** reçoivent un `base_url` à leur création et sont responsables de convertir les URLs relatives en absolues avant de retourner les résultats de Browse.
|
||||
|
||||
3. **`pmoplaylist::ReadHandle::to_items`** génère des items DIDL avec des URLs relatives pour l'audio ET la cover.
|
||||
|
||||
### Localisation du bug
|
||||
|
||||
Le bug se trouvait dans **`pmoqobuz/src/source.rs`** dans la fonction `adapt_playlist_items_to_qobuz` (ligne ~595).
|
||||
|
||||
Cette fonction convertissait correctement l'URL audio en URL absolue :
|
||||
```rust
|
||||
if let Some(resource) = item.resources.first_mut() {
|
||||
if resource.url.starts_with('/') {
|
||||
resource.url = format!("{}{}", self.inner.base_url, resource.url);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Mais ne faisait pas la même conversion pour `item.album_art`** (la cover).
|
||||
|
||||
### Comparaison avec pmoparadise
|
||||
|
||||
`pmoparadise/src/source.rs` fait correctement les deux conversions (lignes 505-526 et 585-595) :
|
||||
```rust
|
||||
// Pour l'audio
|
||||
if resource.url.starts_with('/') {
|
||||
resource.url = format!("{}{}", self.base_url, resource.url);
|
||||
}
|
||||
|
||||
// Pour la cover
|
||||
if let Some(art) = item.album_art.as_mut() {
|
||||
if art.starts_with('/') {
|
||||
*art = format!("{}{}", self.base_url, art);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Correction appliquée
|
||||
|
||||
### Fichier modifié
|
||||
|
||||
- `pmoqobuz/src/source.rs`
|
||||
|
||||
### Modification
|
||||
|
||||
Ajout de la conversion de l'URL de la cover dans `adapt_playlist_items_to_qobuz` :
|
||||
|
||||
```rust
|
||||
// Convertir l'URL de la cover en URL absolue si elle est relative
|
||||
if let Some(art) = item.album_art.as_mut() {
|
||||
if art.starts_with('/') {
|
||||
*art = format!("{}{}", self.inner.base_url, art);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Cette modification a été ajoutée après la conversion de l'URL audio et avant l'assignation du `parent_id`.
|
||||
|
||||
## Vérification
|
||||
|
||||
La compilation de `pmoqobuz` réussit après la correction.
|
||||
|
||||
## Remarques
|
||||
|
||||
Le pattern de conversion des URLs relatives en absolues est cohérent dans le projet : chaque source qui utilise `pmoplaylist::to_items()` doit post-traiter les items pour convertir les URLs relatives (`/audio/...`, `/covers/...`) en URLs absolues en utilisant son `base_url`.
|
||||
152
Blackboard/Report/fix-backend-mutex-poisoned.md
Normal file
152
Blackboard/Report/fix-backend-mutex-poisoned.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# Rapport: Fix Backend Mutex Poisoned
|
||||
|
||||
## Résumé
|
||||
|
||||
Ce rapport documente la correction du bug "Backend mutex poisoned" qui se manifestait lors de l'arrêt de la lecture sur un renderer OpenHome, ainsi que la régression Round 2 découverte après les premiers correctifs.
|
||||
|
||||
## Analyse de la cause racine
|
||||
|
||||
### Round 1 : Mutex empoisonné
|
||||
|
||||
Le mutex backend était empoisonné par des panics non gérés lors d'opérations sur les renderers. Les appels `.unwrap()` et `.expect()` sur le mutex propageaient les panics au lieu de les gérer gracieusement.
|
||||
|
||||
### Round 2 : Régression après Round 1
|
||||
|
||||
Les correctifs du Round 1 ont révélé un problème plus profond. La chaîne d'échecs était :
|
||||
|
||||
1. **DeleteAll échoue avec erreur 501** : Le renderer OpenHome rejette l'action `DeleteAll` pendant la lecture active
|
||||
2. **Le code continue** (grâce aux correctifs Round 1 qui tolèrent les erreurs)
|
||||
3. **État incohérent du renderer** : OpenHome retourne 5 IDs via `IdArray` mais une `<TrackList>` vide via `ReadList`
|
||||
4. **Panic "index out of bounds"** : `sync_queue()` accède à `items[4]` alors que `items.len() == 0`
|
||||
5. **Mutex empoisonné** : Le panic dans le thread empoisonne le mutex
|
||||
|
||||
Preuve dans les logs :
|
||||
```
|
||||
OpenHome Playlist IdArray returned ... id_count=5
|
||||
OpenHome Playlist tracks read ... track_count=0 expected_count=5
|
||||
```
|
||||
|
||||
## Corrections apportées
|
||||
|
||||
### 1. Tolérance des erreurs clear_queue (`musicrenderer.rs`)
|
||||
|
||||
**Fichier** : `pmocontrol/src/music_renderer/musicrenderer.rs`
|
||||
|
||||
**Modification** : La méthode `clear_for_playlist_attach()` tolère maintenant les erreurs de `clear_queue()` au lieu de propager l'erreur.
|
||||
|
||||
```rust
|
||||
pub fn clear_for_playlist_attach(&self) -> Result<(), ControlPointError> {
|
||||
let mut backend = self.lock_backend_for("clear_for_playlist_attach");
|
||||
|
||||
// Clear the queue first (ignore errors - queue will be replaced anyway by sync_queue)
|
||||
// Some backends (OpenHome) may reject DeleteAll if currently playing
|
||||
if let Err(err) = backend.clear_queue() {
|
||||
warn!(
|
||||
renderer = self.id().0.as_str(),
|
||||
error = %err,
|
||||
"Clear queue failed when preparing for playlist attach (continuing anyway)"
|
||||
);
|
||||
}
|
||||
|
||||
// Then stop playback (ignore errors if already stopped)
|
||||
backend.stop().or_else(|err| {
|
||||
warn!(
|
||||
renderer = self.id().0.as_str(),
|
||||
error = %err,
|
||||
"Stop failed when preparing for playlist attach (continuing anyway)"
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Justification** : Le `DeleteAll` n'est pas critique car `sync_queue()` remplacera de toute façon le contenu de la queue.
|
||||
|
||||
### 2. Suppression du clear_queue redondant (`control_point.rs`)
|
||||
|
||||
**Fichier** : `pmocontrol/src/control_point.rs`
|
||||
|
||||
**Modification** : Suppression de l'appel `renderer.clear_queue()?` dans `attach_queue_to_playlist_internal()`.
|
||||
|
||||
Avant :
|
||||
```rust
|
||||
// Clear the local queue (detach binding + clear runtime queue structure)
|
||||
self.detach_playlist_binding(renderer_id, "attach_new_playlist");
|
||||
renderer.clear_queue()?;
|
||||
```
|
||||
|
||||
Après :
|
||||
```rust
|
||||
// Detach any existing binding (local queue will be replaced by sync_queue later)
|
||||
self.detach_playlist_binding(renderer_id, "attach_new_playlist");
|
||||
```
|
||||
|
||||
**Justification** : Ce `clear_queue()` était redondant car `clear_for_playlist_attach()` le fait déjà, et causait un second échec `DeleteAll`.
|
||||
|
||||
### 3. Bounds-check pour current_index (`openhome.rs`)
|
||||
|
||||
**Fichier** : `pmocontrol/src/queue/openhome.rs`
|
||||
|
||||
**Modification** : Ajout d'une vérification de bornes dans `sync_queue()` pour gérer l'état incohérent du renderer OpenHome.
|
||||
|
||||
```rust
|
||||
let snapshot = self.queue_snapshot()?;
|
||||
// Note: current_index may point to an index that doesn't exist in items
|
||||
// if the OpenHome renderer is in an inconsistent state (e.g., IdArray returns
|
||||
// IDs but ReadList returns empty TrackList). We must bounds-check here.
|
||||
let playing_info = snapshot.current_index.and_then(|idx| {
|
||||
if idx < snapshot.items.len() {
|
||||
Some((
|
||||
idx,
|
||||
snapshot.items[idx].backend_id,
|
||||
snapshot.items[idx].uri.clone(),
|
||||
snapshot.items[idx].didl_id.clone(),
|
||||
))
|
||||
} else {
|
||||
warn!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
current_index = idx,
|
||||
items_len = snapshot.items.len(),
|
||||
"OpenHome renderer in inconsistent state: current_index out of bounds, treating as no current track"
|
||||
);
|
||||
None
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Justification** : Gère le cas où le renderer OpenHome retourne un état incohérent (IDs sans données de track correspondantes).
|
||||
|
||||
### 4. Ajout de l'import warn (`openhome.rs`)
|
||||
|
||||
**Fichier** : `pmocontrol/src/queue/openhome.rs`
|
||||
|
||||
**Modification** : Ajout de `warn` à l'import tracing.
|
||||
|
||||
```rust
|
||||
use tracing::{debug, warn};
|
||||
```
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
| Fichier | Modification |
|
||||
|---------|-------------|
|
||||
| `pmocontrol/src/music_renderer/musicrenderer.rs` | Tolérance des erreurs `clear_queue()` dans `clear_for_playlist_attach()` |
|
||||
| `pmocontrol/src/control_point.rs` | Suppression du `clear_queue()` redondant |
|
||||
| `pmocontrol/src/queue/openhome.rs` | Bounds-check + import `warn` |
|
||||
|
||||
## Comportement attendu après correction
|
||||
|
||||
1. **DeleteAll échoue** : Warning loggé, le code continue
|
||||
2. **État incohérent détecté** : Warning loggé, traité comme "pas de track courante"
|
||||
3. **sync_queue réussit** : La playlist est correctement attachée au renderer
|
||||
4. **Pas de panic** : Le mutex reste sain
|
||||
|
||||
## Tests effectués
|
||||
|
||||
- L'utilisateur a confirmé que la correction fonctionne ("Ok ça marche")
|
||||
|
||||
## Notes techniques
|
||||
|
||||
Les correctifs du Round 1 (gestion d'erreur sur mutex) ont révélé un bug préexistant : le code supposait que l'état du renderer OpenHome était toujours cohérent. En réalité, certains renderers peuvent retourner des IDs de tracks sans les données correspondantes, notamment lorsqu'une opération `DeleteAll` est rejetée pendant la lecture.
|
||||
|
||||
La solution adoptée est défensive : plutôt que de supposer un état cohérent, le code vérifie les bornes et traite les incohérences comme des cas dégradés (pas de track courante) plutôt que de paniquer.
|
||||
160
Blackboard/Report/fix-openhome-polling-regression.md
Normal file
160
Blackboard/Report/fix-openhome-polling-regression.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# Rapport : Correction de la régression du polling OpenHome
|
||||
|
||||
## Résumé
|
||||
|
||||
Suite au crash de Claude Code, investigation et correction d'une régression causant des sauts de 2-3 secondes dans la barre de progression de l'interface web pour les renderers OpenHome. Le problème provenait d'une combinaison de facteurs : timing incorrect de la boucle de polling et appels SOAP redondants.
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
1. `pmocontrol/src/music_renderer/musicrenderer.rs`
|
||||
- Correction du timing de la boucle watcher (intervalle fixe au lieu de pause fixe)
|
||||
- Suppression d'un appel double à `playback_position()`
|
||||
- Réorganisation de `poll_and_emit_changes()` pour minimiser le temps passé avec les locks
|
||||
|
||||
2. `pmocontrol/src/music_renderer/openhome_renderer.rs`
|
||||
- Ajout d'un cache intelligent pour `playback_position()` avec timestamp et détection d'abus
|
||||
- Évite les appels SOAP redondants (OpenHome a précision à la seconde)
|
||||
|
||||
3. `pmocontrol/src/music_renderer/watcher.rs`
|
||||
- Modifications temporaires annulées (cache déplacé dans OpenHomeRenderer)
|
||||
|
||||
## Analyse des appels SOAP OpenHome - Services de LECTURE
|
||||
|
||||
Analyse effectuée sur le renderer OpenHome `pizzicato-Music` (192.168.0.200) à partir des logs `pmomusic.log`.
|
||||
|
||||
### Services analysés et intervalles observés
|
||||
|
||||
#### ✅ Time:Time (après correction)
|
||||
- **Intervalle moyen** : ~1050ms
|
||||
- **Min/Max** : 1000-1200ms
|
||||
- **État** : CORRIGÉ - Cache actif, fonctionne parfaitement
|
||||
- **Appels** : Réguliers, espacés d'environ 1 seconde
|
||||
|
||||
#### ⚠️ Playlist:TransportState
|
||||
- **Intervalle moyen** : ~150ms
|
||||
- **Distribution** :
|
||||
- 100ms : 7 occurrences
|
||||
- 200ms : 1 occurrence
|
||||
- 700ms : 1 occurrence
|
||||
- **État** : PROBLÉMATIQUE - Sur-sollicitation
|
||||
- **Impact** : Appelé 6-7 fois par seconde au lieu de 2 fois
|
||||
|
||||
#### ⚠️ Playlist:IdArray
|
||||
- **Intervalle moyen** : ~320ms (très irrégulier)
|
||||
- **Distribution** :
|
||||
- 0ms : 2 occurrences (!)
|
||||
- 100ms : 3 occurrences
|
||||
- 200ms : 1 occurrence
|
||||
- 800-1000ms : 3 occurrences
|
||||
- **État** : TRÈS PROBLÉMATIQUE - Appels anarchiques
|
||||
- **Impact** : Certains appels consécutifs sans délai, surcharge réseau
|
||||
|
||||
#### ⚠️ Product:SourceXml
|
||||
- **Intervalle moyen** : ~130ms
|
||||
- **Distribution** :
|
||||
- 100ms : 8 occurrences
|
||||
- 200ms : 1 occurrence
|
||||
- 300ms : 1 occurrence
|
||||
- **État** : PROBLÉMATIQUE - Sur-sollicitation
|
||||
- **Impact** : Appelé 7-8 fois par seconde au lieu de 2 fois
|
||||
|
||||
#### ⚠️ Product:SourceIndex
|
||||
- **Données** : Observé dans les logs mais pas analysé en détail
|
||||
- **État** : Probablement similaire à SourceXml
|
||||
|
||||
#### 📊 Volume:Volume & Volume:Mute
|
||||
- **Données** : Insuffisantes dans les logs récents
|
||||
- **Polling prévu** : Toutes les 2 ticks (1 seconde) selon le code
|
||||
- **État** : À surveiller
|
||||
|
||||
### Services d'ÉCRITURE
|
||||
|
||||
Aucun appel récent observé dans les logs (comportement normal - ce sont des commandes utilisateur ponctuelles) :
|
||||
- Playlist:Play
|
||||
- Playlist:Pause
|
||||
- Playlist:Stop
|
||||
- Playlist:SeekId
|
||||
- Playlist:SeekSecondAbsolute
|
||||
- Volume:SetVolume
|
||||
- Volume:SetMute
|
||||
|
||||
## Problèmes identifiés
|
||||
|
||||
### 1. Timing de la boucle watcher (CORRIGÉ)
|
||||
**Avant** : `sleep(500ms)` APRÈS chaque poll
|
||||
- Poll prend 100-200ms → Intervalle réel = 600-700ms
|
||||
|
||||
**Après** : Intervalle fixe de 500ms entre le DÉBUT de chaque poll
|
||||
- Utilise `SystemTime` pour calculer le prochain poll
|
||||
- Ajuste le sleep en conséquence
|
||||
|
||||
### 2. Lock contention (CORRIGÉ)
|
||||
**Avant** : Lock `watched_state` tenu pendant les appels réseau
|
||||
- Bloque autres threads pendant 50-200ms
|
||||
- Cause des délais cumulatifs
|
||||
|
||||
**Après** : Locks acquis uniquement pour comparaison/mise à jour
|
||||
- Appels réseau faits SANS locks
|
||||
- Locks relâchés avant émission d'événements
|
||||
|
||||
### 3. Appels SOAP redondants OpenHome:Time (CORRIGÉ)
|
||||
**Avant** : Aucun cache, appel SOAP à chaque poll (500ms)
|
||||
- OpenHome retourne `elapsed_secs` (précision seconde)
|
||||
- Appels inutiles car valeur identique
|
||||
|
||||
**Après** : Cache avec expiration 900ms + détection d'abus
|
||||
- Retourne valeur cachée si < 900ms
|
||||
- Warning si > 3 appels/seconde
|
||||
- Réduit appels SOAP de moitié
|
||||
|
||||
### 4. Appel double à playback_position() (CORRIGÉ)
|
||||
**Avant** : Deux appels dans `poll_and_emit_changes()`
|
||||
```rust
|
||||
let raw_position = self.lock_backend_for("poll_position").playback_position().ok();
|
||||
let position = self.playback_position().ok();
|
||||
```
|
||||
|
||||
**Après** : Un seul appel
|
||||
```rust
|
||||
let position = self.playback_position().ok();
|
||||
```
|
||||
|
||||
## Problèmes restants (NON CORRIGÉS)
|
||||
|
||||
### Services OpenHome sur-sollicités
|
||||
|
||||
Les services suivants sont appelés trop fréquemment (100-300ms au lieu de 500ms+) :
|
||||
- **Playlist:TransportState** (~150ms) - utilisé par `playback_state()`
|
||||
- **Playlist:IdArray** (~320ms, irrégulier) - utilisé par les opérations de queue
|
||||
- **Product:SourceXml** (~130ms) - vérification de source active
|
||||
- **Product:SourceIndex** (non mesuré) - probablement similaire
|
||||
|
||||
**Impact** :
|
||||
- Surcharge réseau inutile
|
||||
- Potentiel de ralentissement avec latence réseau élevée
|
||||
- Gaspillage CPU (parsing SOAP)
|
||||
|
||||
**Solution recommandée** :
|
||||
Appliquer le même pattern de cache qu'on a fait pour `Time:Time` à ces méthodes :
|
||||
- `playback_state()` → cache TransportState
|
||||
- Méthodes de queue → cache IdArray
|
||||
- Vérification de source → cache SourceXml/SourceIndex
|
||||
|
||||
## Tests et validation
|
||||
|
||||
- Compilation : ✅ Succès (15:38 heure de Paris)
|
||||
- Logs analysés : `pmomusic.log` (14:54 UTC = 15:54 Paris)
|
||||
- Barre de progression : ✅ Fluide (confirmé par utilisateur)
|
||||
- Appels Time : ✅ Espacés de ~1s (au lieu de 0.6-1.8s avant)
|
||||
- Warnings abus : ✅ Aucun (< 3 appels/seconde)
|
||||
|
||||
## Conclusion
|
||||
|
||||
La régression de la barre de progression est corrigée. Le service `Time` bénéficie maintenant d'un cache intelligent qui évite les appels redondants. Cependant, l'analyse des logs révèle que d'autres services OpenHome souffrent du même problème de sur-sollicitation et mériteraient le même traitement.
|
||||
|
||||
## Métriques
|
||||
|
||||
- Temps d'investigation : ~2h (après crash)
|
||||
- Crates modifiés : `pmocontrol`
|
||||
- Lignes modifiées : ~150 (ajouts + suppressions)
|
||||
- Services corrigés : 1/5 identifiés
|
||||
64
Blackboard/Report/gestion_progressbar_on_stream.md
Normal file
64
Blackboard/Report/gestion_progressbar_on_stream.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Rapport : Gestion de la barre de progression sur flux continu
|
||||
|
||||
## Résumé
|
||||
|
||||
Implémentation complète de l'étape 2 de la tâche : ajout d'un indicateur visuel "Web Radio" dans l'interface web pour signaler la lecture d'un flux continu (webradio). L'indicateur s'affiche automatiquement à côté de l'indicateur "Attachée à une playlist" dans le composant QueueViewer. Optimisation de la détection de stream avec cache et traitement asynchrone pour éviter de ralentir l'interface.
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
### Backend (pmocontrol)
|
||||
|
||||
1. **pmocontrol/src/openapi.rs**
|
||||
- Ajout du champ `is_stream: bool` dans `struct FullRendererSnapshot`
|
||||
|
||||
2. **pmocontrol/src/control_point.rs**
|
||||
- Modification de la méthode de construction de `FullRendererSnapshot` pour inclure `is_stream` via appel à `renderer.is_playing_a_stream()`
|
||||
|
||||
3. **pmocontrol/src/sse.rs**
|
||||
- Refactorisation : création de la fonction helper `media_server_event_to_payload()` pour éliminer la duplication de code entre les conversions de `MediaServerEvent` vers `MediaServerEventPayload`
|
||||
- Remplacement de deux blocs match dupliqués par des appels à cette fonction helper
|
||||
|
||||
4. **pmocontrol/src/music_renderer/musicrenderer.rs**
|
||||
- Ajout d'un log `tracing::info!()` lors du changement d'état stream pour faciliter le débogage
|
||||
|
||||
5. **pmocontrol/src/music_renderer/stream_detection.rs**
|
||||
- **Optimisation majeure** : Ajout d'un cache global thread-safe (`STREAM_CACHE`) pour mémoriser les résultats de détection par URL
|
||||
- Ajout d'un set de vérifications en cours (`PENDING_CHECKS`) pour éviter les doublons de requêtes HTTP sur la même URL
|
||||
- Modification de `is_continuous_stream_url()` pour :
|
||||
- Vérifier le cache en premier (retour immédiat si trouvé)
|
||||
- Ne pas lancer de nouvelle détection si déjà en cours
|
||||
- Lancer la détection HTTP HEAD dans un thread séparé (non-bloquant)
|
||||
- Retourner `false` temporairement pendant la détection, le watcher mettra à jour à la prochaine itération
|
||||
- Utilisation de `std::sync::LazyLock` (stdlib Rust 1.80+) au lieu de lazy_static
|
||||
|
||||
### Frontend (webapp)
|
||||
|
||||
6. **pmoapp/webapp/src/services/pmocontrol/types.ts**
|
||||
- Ajout du type d'événement SSE `stream_state_changed` dans `RendererEventPayload`
|
||||
- Ajout du champ `is_stream: boolean` dans `FullRendererSnapshot`
|
||||
|
||||
7. **pmoapp/webapp/src/composables/useRenderers.ts**
|
||||
- Ajout de la gestion de l'événement `stream_state_changed` dans le switch statement
|
||||
- Ajout du computed `isStream` dans le composable `useRenderer()`
|
||||
- Export de `isStream` dans le retour du composable
|
||||
|
||||
8. **pmoapp/webapp/src/components/pmocontrol/QueueViewer.vue**
|
||||
- Import de l'icône `Radio` depuis lucide-vue-next
|
||||
- Récupération de `isStream` depuis le composable `useRenderer()`
|
||||
- Ajout d'un conteneur `status-indicators` pour wrapper les indicateurs
|
||||
- Ajout de l'indicateur visuel "Web Radio" avec icône Radio (badge violet)
|
||||
- Ajout des styles CSS pour `.stream-indicator` et `.status-indicators`
|
||||
|
||||
## Améliorations d'optimisation
|
||||
|
||||
### Problème identifié
|
||||
La détection de stream via requête HTTP HEAD synchrone bloquait l'interface et ralentissait la réactivité.
|
||||
|
||||
### Solution implémentée
|
||||
- **Cache en mémoire** : Les résultats sont mémorisés par URL (une URL ne change pas de nature)
|
||||
- **Détection asynchrone** : La requête HTTP est déportée dans un thread séparé
|
||||
- **Anti-doublon** : Un mécanisme empêche de relancer une détection déjà en cours pour la même URL
|
||||
- **Comportement graceful** : Retourne `false` temporairement pendant la première détection, le watcher met à jour l'état dès que le résultat est disponible
|
||||
|
||||
### Résultat
|
||||
Interface fluide sans blocage, les indicateurs "Web Radio" apparaissent après quelques centaines de millisecondes lors de la première lecture d'une URL, puis instantanément grâce au cache pour les lectures suivantes.
|
||||
89
Blackboard/Report/large_cover.md
Normal file
89
Blackboard/Report/large_cover.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Rapport : Amélioration de l'overlay de couverture d'album
|
||||
|
||||
## Résumé
|
||||
|
||||
Amélioration de l'interface de l'overlay affichant la couverture d'album en grand dans le Control Point PMOMusic, avec focus sur l'ergonomie mobile.
|
||||
|
||||
## Fichier modifié
|
||||
|
||||
- `pmoapp/webapp/src/components/pmocontrol/CurrentTrack.vue`
|
||||
|
||||
## Modifications effectuées
|
||||
|
||||
### Point 1 : Métadonnées visibles par défaut + Progress bar
|
||||
|
||||
**Problème initial** : Les métadonnées étaient cachées par défaut à l'ouverture de l'overlay.
|
||||
|
||||
**Solutions implémentées** :
|
||||
|
||||
1. **Métadonnées visibles par défaut** : `showMetadata.value = true` dans `openCoverOverlay()`
|
||||
|
||||
2. **Ajout de la progress bar** dans le panneau des métadonnées :
|
||||
- Barre de progression interactive avec thumb draggable
|
||||
- Affichage temps écoulé / durée totale
|
||||
- Support souris et tactile complet
|
||||
- Style glassmorphism (fond transparent 35%, blur, bordure subtile)
|
||||
|
||||
3. **Panneau plus discret** :
|
||||
- Transparence augmentée (35% au lieu de 60%)
|
||||
- Largeur réduite (70% de l'écran avec `left: 15%; right: 15%`)
|
||||
|
||||
### Point 2 : Fermeture ergonomique sur mobile
|
||||
|
||||
**Problème initial** : Bouton X en haut à droite difficile d'accès au pouce.
|
||||
|
||||
**Solutions implémentées** :
|
||||
|
||||
1. **Swipe down pour fermer** :
|
||||
- Geste naturel vers le bas pour fermer l'overlay
|
||||
- Feedback visuel (translation + diminution opacité)
|
||||
- Seuil de 100px pour déclencher la fermeture
|
||||
- Listeners sur `document` pour capturer le mouvement même sur les éléments enfants
|
||||
|
||||
2. **Bouton X conservé** pour desktop/souris
|
||||
|
||||
3. **Blocage du pull-to-refresh Android** :
|
||||
- `overscroll-behavior: contain`
|
||||
- `touch-action: none` sur l'overlay
|
||||
|
||||
### Corrections supplémentaires
|
||||
|
||||
1. **Support tactile progress bar principale** : Ajout de `@touchstart` sur la progress bar hors overlay
|
||||
|
||||
2. **Compatibilité viewport mobile** : Remplacement de `100vh` par `100dvh` (dynamic viewport height) pour éviter que la barre du navigateur masque le contenu
|
||||
|
||||
3. **Corrections TypeScript** : Vérifications `if (!touch) return` pour les événements tactiles
|
||||
|
||||
## Détails techniques
|
||||
|
||||
### Nouvelles variables réactives
|
||||
|
||||
```typescript
|
||||
const swipeStartY = ref(0);
|
||||
const swipeCurrentY = ref(0);
|
||||
const isSwiping = ref(false);
|
||||
const swipeThreshold = 100;
|
||||
```
|
||||
|
||||
### Nouvelles fonctions
|
||||
|
||||
- `handleOverlayTouchStart()` : Initialise le swipe et ajoute les listeners document
|
||||
- `handleSwipeTouchMove()` : Suit le mouvement du doigt
|
||||
- `handleSwipeTouchEnd()` : Ferme si seuil atteint, nettoie les listeners
|
||||
- `handleOverlayProgressBarTouchStart()` : Gestion tactile du seek (réutilisée par les deux progress bars)
|
||||
- `swipeOffset` / `swipeOpacity` : Computed pour le feedback visuel
|
||||
|
||||
### Nouveaux styles CSS
|
||||
|
||||
- `.overlay-metadata-text` : Conteneur du texte
|
||||
- `.overlay-progress-section` : Section progress bar
|
||||
- `.overlay-progress-bar` / `-fill` / `-thumb` : Style glassmorphism
|
||||
- `.overlay-time-display` : Affichage temps
|
||||
- `.cover-overlay-content.swiping` : État pendant le swipe
|
||||
- `overscroll-behavior: contain` + `touch-action: none` : Blocage pull-to-refresh
|
||||
- `100dvh` : Viewport dynamique pour mobile
|
||||
|
||||
### Responsive
|
||||
|
||||
- Mobile (< 768px) : Progress bar 10px, thumb 28px
|
||||
- Mode kiosque (800x600) : Progress bar 6px
|
||||
146
Blackboard/Report/metadata_RF_cache.md
Normal file
146
Blackboard/Report/metadata_RF_cache.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# Rapport: Simplification de pmoradiofrance
|
||||
|
||||
## Résumé
|
||||
|
||||
Refactoring complet de la crate `pmoradiofrance` pour simplifier l'architecture autour d'un cache de métadonnées centralisé avec système d'événements.
|
||||
|
||||
## Objectifs
|
||||
|
||||
1. Simplifier les structures de stations (supprimer StationType)
|
||||
2. Créer un cache de métadonnées in-memory avec TTL basé sur `end_time`
|
||||
3. Maintenir le cache de stations persistant (pmoconfig, TTL 1 semaine)
|
||||
4. Implémenter un système d'événements pour la synchronisation GENA
|
||||
5. Unifier les méthodes `to_didl()` pour retourner des Containers DIDL
|
||||
6. Gérer automatiquement le cache des covers via pmocovers
|
||||
|
||||
## Changements architecturaux majeurs
|
||||
|
||||
### 1. Nouveau fichier: metadata_cache.rs
|
||||
|
||||
**Créé**: `pmoradiofrance/src/metadata_cache.rs`
|
||||
|
||||
Contient deux structures principales:
|
||||
|
||||
- **CachedMetadata**: Stocke uniquement les données nécessaires au DIDL (titre, artiste, album, cover, stream URL, etc.)
|
||||
- **MetadataCache**: Gère le cache in-memory avec TTL + cache persistant des stations + système d'événements
|
||||
|
||||
**Fonctionnalités**:
|
||||
- TTL basé sur `end_time` de l'API Radio France
|
||||
- Gestion automatique du cache de covers via pmocovers
|
||||
- Système subscribe/notify pour les événements
|
||||
- Graceful degradation si API Radio France down
|
||||
- Méthode `to_didl()` retournant une playlist à un item avec métadonnées identiques
|
||||
|
||||
### 2. Suppression: stateful_client.rs
|
||||
|
||||
**Supprimé**: `pmoradiofrance/src/stateful_client.rs`
|
||||
|
||||
Raison: Complètement redondant avec `MetadataCache`. Toute la logique a été déplacée dans le nouveau module.
|
||||
|
||||
### 3. Simplification: models.rs
|
||||
|
||||
**Modifications**:
|
||||
- Supprimé `StationType` enum
|
||||
- Simplifié `Station` struct (juste `slug` + `name`)
|
||||
- Supprimé méthodes `is_main()`, `is_webradio()`, `is_local_radio()`, `base_station()`
|
||||
- Conservé structures d'API (`LiveResponse`, `ShowMetadata`, etc.)
|
||||
|
||||
### 4. Simplification: playlist.rs
|
||||
|
||||
**Modifications**:
|
||||
- Supprimé `StationPlaylist` complètement
|
||||
- Simplifié `StationGroup` et `StationGroups`
|
||||
- **Important**: `to_didl()` retourne `Container` (pas `Vec<Container>`)
|
||||
- Logique unifiée: ICI fonctionne comme FIP (plus de traitement spécial)
|
||||
- Préservé les règles de mapping RF → UPnP existantes
|
||||
|
||||
### 5. Refactoring: source.rs
|
||||
|
||||
**Modifications**:
|
||||
- Utilise uniquement `MetadataCache` (plus de `stateful_client`)
|
||||
- Simplifié `browse()` en 3 cas simples
|
||||
- Abonnement aux événements du cache pour GENA
|
||||
- Retourne des `Container` (cohérence avec to_didl)
|
||||
|
||||
### 6. Adaptation: config_ext.rs
|
||||
|
||||
**Modifications**:
|
||||
- Format simplifié: `Vec<Station>` au lieu de `CachedStationList`
|
||||
- TTL reste à 7 jours (1 semaine)
|
||||
|
||||
### 7. Mise à jour: lib.rs
|
||||
|
||||
**Modifications**:
|
||||
- Ajouté `pub mod metadata_cache;`
|
||||
- Supprimé export de `stateful_client`
|
||||
- Ajouté exports: `MetadataCache`, `CachedMetadata`
|
||||
|
||||
## Hiérarchie de browse
|
||||
|
||||
**Niveau 0**: `radiofrance`
|
||||
- Retourne UN Container contenant les containers de groupes
|
||||
- Exemple: Container "FIP", Container "France Culture", Container "ICI"
|
||||
|
||||
**Niveau 1**: `radiofrance:group:fip` ou `radiofrance:ici`
|
||||
- Si 1 station: retourne directement la playlist (Container playlistContainer)
|
||||
- Si plusieurs stations: retourne un container contenant les playlists
|
||||
|
||||
**Niveau 2**: `radiofrance:fip`
|
||||
- Retourne Container playlistContainer avec 1 item
|
||||
- Métadonnées identiques entre playlist et item
|
||||
|
||||
## Règles de mapping préservées
|
||||
|
||||
Les règles existantes de transformation RF → UPnP ont été préservées:
|
||||
- Radio musicale avec song → métadonnées du morceau
|
||||
- Radio parlée → agrégation émission/producteur
|
||||
- Éviter duplications du nom de station
|
||||
- Calcul de duration depuis end_time
|
||||
|
||||
## Système d'événements
|
||||
|
||||
**Flux**:
|
||||
1. `MetadataCache` rafraîchit les métadonnées d'un slug
|
||||
2. Notifie tous les abonnés via `notify(slug)`
|
||||
3. `RadioFranceSource` reçoit l'événement
|
||||
4. Émet un événement GENA UPnP pour la playlist `radiofrance:{slug}`
|
||||
5. Le Control Point reçoit la notification et peut se mettre à jour
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
### Créés
|
||||
- `pmoradiofrance/src/metadata_cache.rs`
|
||||
|
||||
### Supprimés
|
||||
- `pmoradiofrance/src/stateful_client.rs`
|
||||
|
||||
### Modifiés
|
||||
- `pmoradiofrance/src/models.rs`
|
||||
- `pmoradiofrance/src/playlist.rs`
|
||||
- `pmoradiofrance/src/source.rs`
|
||||
- `pmoradiofrance/src/config_ext.rs`
|
||||
- `pmoradiofrance/src/lib.rs`
|
||||
|
||||
### Inchangés
|
||||
- `pmoradiofrance/src/client.rs`
|
||||
- `pmoradiofrance/src/error.rs`
|
||||
|
||||
## Points de vigilance
|
||||
|
||||
1. **Migration**: Le cache pmoconfig existant sera invalidé (nouveau format)
|
||||
2. **Covers**: Nécessite que pmocovers soit initialisé via cache_registry
|
||||
3. **Thread safety**: Utilisation d'Arc<RwLock> pour la sécurité thread
|
||||
4. **Graceful degradation**: Retourne cache expiré si API Radio France down
|
||||
|
||||
## Prochaines étapes
|
||||
|
||||
1. Tester le cache de métadonnées (TTL, refresh, graceful degradation)
|
||||
2. Tester le système d'événements
|
||||
3. Tester le browse sur les 3 niveaux
|
||||
4. Vérifier les événements GENA
|
||||
5. Vérifier que les covers sont correctement cachées
|
||||
|
||||
## Plan d'implémentation détaillé
|
||||
|
||||
Le plan détaillé est disponible dans:
|
||||
`/Users/coissac/.claude/plans/glowing-scribbling-cook.md`
|
||||
86
Blackboard/Report/openhome_queue_hybrid.md
Normal file
86
Blackboard/Report/openhome_queue_hybrid.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# Rapport : Queue OpenHome hybride avec cache de métadonnées
|
||||
|
||||
## Objectif
|
||||
Transformer la queue OpenHome de stateless à hybride en ajoutant un cache de métadonnées. Cela permet au control point de mettre à jour les métadonnées des pistes même si le service OpenHome ne le permet pas nativement.
|
||||
|
||||
## Problématique
|
||||
Les services OpenHome ne permettent pas de modifier les métadonnées d'une piste une fois qu'elle est dans la queue. Cela empêchait le control point de refléter les mises à jour de métadonnées effectuées par le média serveur.
|
||||
|
||||
## Solution implémentée
|
||||
|
||||
### 1. Structure de données
|
||||
Ajout d'un champ `metadata_cache: HashMap<u32, Option<TrackMetadata>>` dans `OpenHomeQueue` :
|
||||
- Clé : ID OpenHome de la track (pas l'index/position)
|
||||
- Valeur : Métadonnées optionnelles de la piste
|
||||
|
||||
### 2. Enregistrement des métadonnées
|
||||
Les métadonnées sont enregistrées dans le cache dans toutes les méthodes qui manipulent des `PlaybackItem` :
|
||||
|
||||
- **`add_playback_item`** : Enregistre les métadonnées lors de l'insertion
|
||||
- **`replace_item`** : Supprime l'ancien ID et enregistre le nouveau
|
||||
- **`replace_queue`** : Enregistre pour tous les nouveaux items
|
||||
- **`sync_queue`** et helpers :
|
||||
- `replace_queue_preserve_current` : Enregistre pour les nouveaux items
|
||||
- `replace_queue_with_pivot` : Met à jour les métadonnées du pivot
|
||||
- `rebuild_playlist_section` : Met à jour pour items conservés et nouveaux
|
||||
- `replace_queue_standard_lcs` : Met à jour pour items conservés et nouveaux
|
||||
|
||||
### 3. Lecture depuis le cache
|
||||
Modification de `playback_item_from_entry` pour utiliser les métadonnées du cache en priorité :
|
||||
|
||||
```rust
|
||||
let metadata = self.metadata_cache
|
||||
.get(&entry.id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| entry.metadata());
|
||||
```
|
||||
|
||||
### 4. Nettoyage du cache
|
||||
Le cache est nettoyé automatiquement lors des suppressions :
|
||||
- `delete_all()` → `metadata_cache.clear()`
|
||||
- `delete_id()` / `delete_id_if_exists()` → `metadata_cache.remove()`
|
||||
- Pas de nettoyage dans `queue_snapshot` (pas nécessaire, quelques entrées orphelines n'ont pas d'impact)
|
||||
|
||||
### 5. API publique
|
||||
Ajout de la méthode publique `update_item_metadata` :
|
||||
|
||||
```rust
|
||||
pub fn update_item_metadata(
|
||||
&mut self,
|
||||
index: usize,
|
||||
metadata: Option<crate::model::TrackMetadata>,
|
||||
) -> Result<(), ControlPointError>
|
||||
```
|
||||
|
||||
Cette méthode permet de mettre à jour manuellement les métadonnées d'un item à un index donné.
|
||||
|
||||
## Points clés de l'implémentation
|
||||
|
||||
### Utilisation de l'ID OpenHome (pas l'index)
|
||||
Le cache utilise l'ID OpenHome comme clé, pas la position dans la queue. Cela permet de suivre une piste même si sa position change.
|
||||
|
||||
### Synchronisation intelligente
|
||||
Dans `sync_queue` :
|
||||
- **CASE 1** : Item courant PAS dans la nouvelle queue → métadonnées préservées en cache
|
||||
- **CASE 2** : Item courant DANS la nouvelle queue → métadonnées mises à jour avec celles de la nouvelle queue
|
||||
|
||||
### Gestion des fuites mémoire
|
||||
Quelques entrées orphelines peuvent subsister si un autre control point modifie la playlist, mais :
|
||||
- Elles ne causent pas de bug (jamais consultées)
|
||||
- Impact mémoire négligeable
|
||||
- Naturellement écrasées lors des synchronisations
|
||||
|
||||
## Fichiers modifiés
|
||||
- `pmocontrol/src/queue/openhome.rs` (unique fichier modifié)
|
||||
|
||||
## Impact
|
||||
- ✅ Le control point peut maintenant afficher des métadonnées à jour
|
||||
- ✅ Les mises à jour du média serveur se reflètent dans la queue
|
||||
- ✅ Pas de changement de l'API publique (sauf ajout de `update_item_metadata`)
|
||||
- ✅ Pas d'impact sur les autres backends de queue
|
||||
- ✅ Compatible avec le comportement existant
|
||||
|
||||
## Prochaines étapes suggérées
|
||||
1. Ajouter `update_item_metadata` aux autres backends de queue (InternalQueue)
|
||||
2. Exposer cette fonctionnalité au niveau du MediaRenderer
|
||||
3. Implémenter la synchronisation automatique des métadonnées depuis le MediaServer
|
||||
15
Blackboard/Report/patch_position_info_for_stream.md
Normal file
15
Blackboard/Report/patch_position_info_for_stream.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Rapport : Patch des informations de position pour les flux continus
|
||||
|
||||
## Résumé
|
||||
Modification de la méthode `poll_and_emit_changes()` dans la crate `pmocontrol` pour corriger les données de position et durée lorsqu'un renderer diffuse un flux continu (webradio). La méthode détecte maintenant si un flux est en cours via `is_playing_a_stream()` et applique un traitement spécifique : extraction de la durée depuis les métadonnées DIDL, et calcul de la position relative depuis `track_start_time` (qui est déjà maintenu à jour lors des changements de métadonnées). Si aucune durée n'est disponible, la position et la durée sont mises à zéro/none.
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
1. `pmocontrol/src/music_renderer/musicrenderer.rs`
|
||||
- Modification de la méthode `poll_and_emit_changes()` pour patcher les informations de position lors de la détection d'un flux continu
|
||||
- Ajout du logging au niveau info lors de la détection d'un flux continu
|
||||
- Extraction conditionnelle de la durée depuis les métadonnées DIDL pour les streams
|
||||
- Calcul de la position relative basé sur `track_start_time` (différence entre now et track_start_time)
|
||||
- Retour de valeurs par défaut (zéro pour position, none pour duration) si aucune durée n'est disponible dans les métadonnées
|
||||
- Préservation de la logique existante pour les médias réguliers (non-streams)
|
||||
- Note : `track_start_time` est déjà maintenu à jour par la logique existante lors des changements de métadonnées
|
||||
@@ -92,9 +92,16 @@ flowchart LR
|
||||
- **Action** : LLM implémente la tâche
|
||||
- **Output** : Fichier `Report/{nom}.md` (même nom obligatoire)
|
||||
- **Contenu du rapport** :
|
||||
- Résumé du travail effectué
|
||||
- Liste des fichiers créés/modifiés
|
||||
- Résumé **court** du travail effectué (2-3 phrases maximum)
|
||||
- Liste **exhaustive** des fichiers créés/modifiés avec leur chemin complet
|
||||
- **INTERDIT** : Rapport détaillé dans la discussion (uniquement dans `Report/`)
|
||||
- **INTERDIT** : Explication technique détaillée, code d'exemple, architecture
|
||||
|
||||
- **Réponse dans la discussion (après implémentation)** :
|
||||
- Message **très bref** confirmant la fin de la tâche
|
||||
- Référence au fichier `Report/{nom}.md` pour les détails
|
||||
- **Format attendu** : "Tâche terminée. Voir `Report/{nom}.md` pour la liste des modifications."
|
||||
- **PAS de** : résumé détaillé, explication du code, liste des avantages, etc.
|
||||
|
||||
#### 3. Décision humaine (Report → Done ou ToDiscuss)
|
||||
|
||||
|
||||
137
Blackboard/Rules_optimal.md
Normal file
137
Blackboard/Rules_optimal.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# PMOMusic - Règles LLM (IMPÉRATIF)
|
||||
|
||||
## 🎯 Projet
|
||||
Audio HiFi UPnP/DLNA. Backend Rust, Frontend Vue.js/TypeScript.
|
||||
|
||||
**Environnement** : `source .claude-env` (racine projet) AVANT toute commande.
|
||||
|
||||
---
|
||||
|
||||
## ⛔ INTERDICTIONS (0 EXCEPTION)
|
||||
|
||||
1. **JAMAIS déplacer/créer fichiers** dans `Blackboard/` (seul humain décide)
|
||||
2. **JAMAIS commencer** sans crates explicites dans `Todo/{nom}.md` → REFUSER
|
||||
3. **JAMAIS compiler/tester** (`cargo`, `npm`) → TOUJOURS demander à humain
|
||||
4. **JAMAIS détailler** implémentation dans discussion → UN message : "Tâche terminée. Voir `Report/{nom}.md`..."
|
||||
|
||||
---
|
||||
|
||||
## 📋 WORKFLOW (STRICT)
|
||||
|
||||
```
|
||||
1. LIRE Todo/{nom}.md
|
||||
Crates spécifiées ? NON → ARRÊTER, demander | OUI → Continuer
|
||||
|
||||
2. IMPLÉMENTER
|
||||
Patterns Architecture/ si référencés
|
||||
DEMANDER compilation : "Compilez `cargo build -p {crate}`, renvoyez erreurs"
|
||||
Erreurs ? OUI → Corriger, redemander | NON → Continuer
|
||||
|
||||
3. CRÉER Report/{nom}.md
|
||||
- Résumé (2-3 phrases, SANS code/détails techniques)
|
||||
- Fichiers modifiés (chemins complets)
|
||||
- Modifications SÉMANTIQUES (concepts, PAS lignes code)
|
||||
ÉCRIRE dans chat : "Tâche terminée. Voir `Report/{nom}.md`..."
|
||||
ARRÊTER (ne rien déplacer)
|
||||
|
||||
4. SI humain déplace Todo/{nom}.md → Done/{nom}.md
|
||||
ALORS écrire synthèse COMPLÈTE dans Done/{nom}.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 RÈGLES TECHNIQUES
|
||||
|
||||
**Dépendances** : TOUJOURS workspace (`Cargo.toml` racine) sauf exception justifiée
|
||||
```toml
|
||||
# ✅ workspace.dependencies puis { workspace = true }
|
||||
# ❌ version directe dans crate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📂 BLACKBOARD
|
||||
|
||||
| Dossier | LLM crée | LLM déplace | Humain déplace |
|
||||
|---------|----------|-------------|----------------|
|
||||
| `Todo/` | ❌ | ❌ | ✅ → Done/ToDiscuss |
|
||||
| `Report/` | ✅ | ❌ | ❌ |
|
||||
| `Done/` | ❌ (écrit après déplacement) | ❌ | ✅ |
|
||||
| `ToDiscuss/` | ❌ | ❌ | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## ✅ CHECKLIST
|
||||
|
||||
**Avant** :
|
||||
- [ ] `source .claude-env`
|
||||
- [ ] Crates dans `Todo/{nom}.md` ? NON → ARRÊTER
|
||||
|
||||
**Pendant** :
|
||||
- [ ] Patterns existants
|
||||
- [ ] Workspace dependencies
|
||||
- [ ] NE PAS compiler
|
||||
|
||||
**Après** :
|
||||
- [ ] `Report/{nom}.md` : résumé court + fichiers + modifs sémantiques (SANS code)
|
||||
- [ ] Chat : "Tâche terminée. Voir `Report/{nom}.md`..." (RIEN d'autre)
|
||||
- [ ] NE PAS déplacer `Todo/{nom}.md`
|
||||
|
||||
---
|
||||
|
||||
## 📝 TEMPLATES
|
||||
|
||||
### Report/{nom}.md
|
||||
```markdown
|
||||
# Rapport : {titre}
|
||||
|
||||
## Résumé
|
||||
{2-3 phrases SANS code}
|
||||
|
||||
## Fichiers modifiés
|
||||
1. `chemin/fichier.rs`
|
||||
- {Modification sémantique 1}
|
||||
- {Modification sémantique 2}
|
||||
```
|
||||
|
||||
**Modif sémantique** = concept (ex: "Ajout cache"), PAS ligne code (ex: ❌ "Ajout `let x = 5;`")
|
||||
|
||||
### Discussion
|
||||
```
|
||||
Tâche terminée. Voir `Report/{nom}.md` pour la liste des modifications.
|
||||
```
|
||||
|
||||
### Done/{nom}.md (après déplacement humain)
|
||||
```markdown
|
||||
# {Titre}
|
||||
## Spécification
|
||||
{Copie Todo/ complète}
|
||||
## Implémentation
|
||||
{Détails complets par fichier}
|
||||
## Tests/Validation
|
||||
## Conclusion
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 6 RÈGLES D'OR
|
||||
|
||||
1. JAMAIS déplacer fichiers Blackboard
|
||||
2. EXIGER crates dans Todo/ (sinon REFUSER)
|
||||
3. JAMAIS compiler (demander humain)
|
||||
4. Report court SANS code/détails
|
||||
5. Discussion : 1 ligne après implémentation
|
||||
6. Done/ : écrire APRÈS déplacement humain
|
||||
|
||||
---
|
||||
|
||||
## 🔍 AUTO-VÉRIF (chaque message)
|
||||
|
||||
- [ ] Déplacé fichier ? → ERREUR
|
||||
- [ ] >2 lignes chat après implémentation ? → ERREUR
|
||||
- [ ] Commencé sans vérif crates ? → ERREUR
|
||||
- [ ] Compilé moi-même ? → ERREUR
|
||||
- [ ] Créé Done/ ? → ERREUR
|
||||
- [ ] Supposé code compile ? → ERREUR
|
||||
|
||||
**ERREUR détectée** → ARRÊTER immédiatement
|
||||
1284
Blackboard/ToDiscuss/Construire_pmoradiofrance.md
Normal file
1284
Blackboard/ToDiscuss/Construire_pmoradiofrance.md
Normal file
File diff suppressed because it is too large
Load Diff
586
Blackboard/ToDiscuss/Support_AAC_streaming_pmoflac.md
Normal file
586
Blackboard/ToDiscuss/Support_AAC_streaming_pmoflac.md
Normal file
@@ -0,0 +1,586 @@
|
||||
** Tu dois suivre scrupuleusement les règles définies dans le fichier [@Rules.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Rules.md) **
|
||||
|
||||
** Cette tâche est une tâche de recherche et développement. Elle doit conduire à un prototype fonctionnel et/ou un rapport technique sur la faisabilité. **
|
||||
|
||||
# Support du streaming AAC dans pmoflac
|
||||
|
||||
## Contexte
|
||||
|
||||
Actuellement, `pmoflac` supporte le décodage streaming pour :
|
||||
- ✅ MP3 (via `minimp3`)
|
||||
- ✅ FLAC (via `claxon`)
|
||||
- ✅ Ogg Vorbis (via `lewton`)
|
||||
- ✅ Ogg Opus (via `opus`)
|
||||
- ✅ WAV (parsing manuel)
|
||||
- ✅ AIFF (parsing manuel)
|
||||
|
||||
**Manque critique** : Pas de support AAC, pourtant très utilisé pour :
|
||||
- Streams radio live (Radio France, etc.)
|
||||
- Podcasts
|
||||
- Services de streaming musicaux
|
||||
- Fichiers M4A/MP4
|
||||
|
||||
## Problématique
|
||||
|
||||
Le décodage AAC en **streaming infini** (radio live) est actuellement impossible dans `pmoflac`, ce qui force à :
|
||||
- Soit faire un proxy passthrough (pas de transcodage FLAC)
|
||||
- Soit utiliser une redirection 302 (pas de tracking)
|
||||
|
||||
Cela empêche d'avoir une expérience uniforme où toutes les sources servent du FLAC.
|
||||
|
||||
## Objectif
|
||||
|
||||
**Investiguer et prototyper** le support du décodage AAC streaming dans `pmoflac`, en s'inspirant de l'architecture existante (MP3, Ogg, etc.).
|
||||
|
||||
## Recherches préliminaires
|
||||
|
||||
### 1. Symphonia avec ReadOnlySource
|
||||
|
||||
[Symphonia](https://github.com/pdeljanov/Symphonia) est la bibliothèque Rust la plus complète pour le décodage audio. Elle fournit :
|
||||
|
||||
- **`ReadOnlySource`** : Wrapper pour sources non-seekable (streams infinis)
|
||||
- **`AdtsReader`** : Format reader spécifique pour ADTS (AAC streaming)
|
||||
- **`symphonia-codec-aac`** : Décodeur AAC-LC (Low Complexity)
|
||||
|
||||
**Points d'attention** :
|
||||
- [Issue connue](https://github.com/RustAudio/rodio/issues/580) : Certains formats peuvent quand même réclamer le seek
|
||||
- Nécessite de tester avec un vrai stream ADTS
|
||||
|
||||
### 2. Format ADTS
|
||||
|
||||
[ADTS](https://wiki.multimedia.cx/index.php/ADTS) (Audio Data Transport Stream) est le format AAC conçu pour le streaming :
|
||||
|
||||
- Auto-synchronisant : chaque frame a un header (12 bits `0xFFF`)
|
||||
- Pas de container nécessaire (MP4, M4A)
|
||||
- Utilisé par les radios en streaming
|
||||
- Chaque frame contient ses métadonnées (sample rate, channels, etc.)
|
||||
|
||||
**Structure** :
|
||||
```
|
||||
Frame 1: [ADTS Header 7-9 bytes][AAC Data]
|
||||
Frame 2: [ADTS Header 7-9 bytes][AAC Data]
|
||||
...
|
||||
```
|
||||
|
||||
### 3. Alternative : fdk-aac
|
||||
|
||||
[Bindings Rust pour fdk-aac](https://github.com/haileys/fdk-aac-rs) (bibliothèque Fraunhofer) :
|
||||
|
||||
**Avantages** :
|
||||
- ✅ Décodeur de référence (qualité maximale)
|
||||
- ✅ Support explicite du streaming chunk-by-chunk
|
||||
- ✅ Buffer interne géré automatiquement
|
||||
- ✅ Pas besoin de seek
|
||||
|
||||
**Inconvénients** :
|
||||
- ❌ Dépendance C (libfdk-aac)
|
||||
- ❌ Licence restrictive (non-commerciale pour certaines versions)
|
||||
- ❌ Compilation plus complexe
|
||||
|
||||
## Plan d'investigation
|
||||
|
||||
### Round 1 : Prototype Symphonia ADTS
|
||||
|
||||
**Objectif** : Tester si Symphonia peut décoder un stream AAC infini avec `ReadOnlySource` + `AdtsReader`.
|
||||
|
||||
#### Étapes
|
||||
|
||||
1. **Créer un module de test** : `pmoflac/tests/aac_streaming_test.rs`
|
||||
|
||||
2. **Implémenter un décodeur basique** :
|
||||
```rust
|
||||
use symphonia::core::io::{MediaSourceStream, ReadOnlySource};
|
||||
use symphonia::default::get_probe;
|
||||
use symphonia_codec_aac::AdtsReader;
|
||||
|
||||
async fn decode_aac_stream_test<R: AsyncRead + Unpin>(
|
||||
reader: R
|
||||
) -> Result<Vec<u8>> {
|
||||
// Wrapper AsyncRead → Read synchrone (pattern pmoflac)
|
||||
let sync_reader = blocking_reader_from_async(reader);
|
||||
|
||||
// ReadOnlySource pour stream infini
|
||||
let source = ReadOnlySource::new(sync_reader);
|
||||
let mss = MediaSourceStream::new(Box::new(source), Default::default());
|
||||
|
||||
// Probe avec hint AAC/ADTS
|
||||
let mut hint = Hint::new();
|
||||
hint.with_extension("aac");
|
||||
|
||||
let mut format = get_probe()
|
||||
.format(&hint, mss, &Default::default(), &Default::default())?;
|
||||
|
||||
// Récupérer le track audio
|
||||
let track = format.default_track().unwrap();
|
||||
let mut decoder = symphonia::default::get_codecs()
|
||||
.make(&track.codec_params, &Default::default())?;
|
||||
|
||||
let mut pcm_output = Vec::new();
|
||||
|
||||
// Décoder frame par frame (boucle infinie jusqu'à disconnect)
|
||||
loop {
|
||||
match format.next_packet() {
|
||||
Ok(packet) => {
|
||||
let decoded = decoder.decode(&packet)?;
|
||||
// Convertir en PCM et accumuler
|
||||
let samples = convert_to_pcm_bytes(decoded);
|
||||
pcm_output.extend_from_slice(&samples);
|
||||
}
|
||||
Err(symphonia::core::errors::Error::IoError(e))
|
||||
if e.kind() == std::io::ErrorKind::UnexpectedEof => {
|
||||
break; // Stream fermé
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(pcm_output)
|
||||
}
|
||||
```
|
||||
|
||||
3. **Tester avec un fichier AAC ADTS statique** :
|
||||
- Télécharger un échantillon AAC ADTS
|
||||
- Vérifier que le décodage fonctionne
|
||||
- Comparer PCM output avec ffmpeg
|
||||
|
||||
4. **Tester avec un stream Radio France live** :
|
||||
```rust
|
||||
#[tokio::test]
|
||||
#[ignore = "Requires network"]
|
||||
async fn test_decode_radiofrance_stream() {
|
||||
let stream_url = "https://icecast.radiofrance.fr/fip-hifi.aac";
|
||||
let response = reqwest::get(stream_url).await.unwrap();
|
||||
let reader = response.bytes_stream();
|
||||
|
||||
// Lire 10 secondes de stream
|
||||
let pcm = decode_aac_stream_test(reader).await.unwrap();
|
||||
|
||||
assert!(!pcm.is_empty());
|
||||
// Vérifier format PCM (44.1kHz ou 48kHz, stéréo, 16-bit)
|
||||
}
|
||||
```
|
||||
|
||||
#### Critères de succès Round 1
|
||||
|
||||
- ✅ Le décodeur accepte un `ReadOnlySource` sans erreur de seek
|
||||
- ✅ Les frames ADTS sont correctement parsées
|
||||
- ✅ Le décodage AAC → PCM fonctionne
|
||||
- ✅ Un stream live (infini) peut être décodé sans plantage
|
||||
- ✅ Le PCM output est valide (vérifiable avec `ffplay`)
|
||||
|
||||
#### Livrables Round 1
|
||||
|
||||
1. **Module de test** : `pmoflac/tests/aac_streaming_test.rs`
|
||||
2. **Rapport technique** : `Blackboard/Report/Support_AAC_streaming_pmoflac.md`
|
||||
- Résultats des tests
|
||||
- Problèmes rencontrés (seek, parsing, etc.)
|
||||
- Métriques de performance (CPU, latence)
|
||||
- Comparaison qualité avec ffmpeg
|
||||
|
||||
---
|
||||
|
||||
### Round 2 : Intégration dans pmoflac (si Round 1 réussit)
|
||||
|
||||
**Objectif** : Intégrer le décodeur AAC dans l'architecture streaming de `pmoflac`.
|
||||
|
||||
#### Fichiers à créer/modifier
|
||||
|
||||
**1. `pmoflac/src/aac.rs`** (nouveau)
|
||||
|
||||
```rust
|
||||
use symphonia::core::io::{MediaSourceStream, ReadOnlySource};
|
||||
use tokio::sync::mpsc;
|
||||
use crate::{
|
||||
common::ChannelReader,
|
||||
decoder_common::{spawn_ingest_task, spawn_writer_task, DecodedStream},
|
||||
pcm::StreamInfo,
|
||||
};
|
||||
|
||||
pub type AacDecodedStream = DecodedStream<AacError>;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum AacError {
|
||||
#[error("AAC decode error: {0}")]
|
||||
Decode(String),
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("Channel closed")]
|
||||
ChannelClosed,
|
||||
}
|
||||
|
||||
/// Décoder un stream AAC/ADTS en PCM
|
||||
pub async fn decode_aac_stream<R>(reader: R) -> Result<AacDecodedStream, AacError>
|
||||
where
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
{
|
||||
// Suivre le pattern existant (MP3, FLAC, etc.)
|
||||
let (ingest_tx, ingest_rx) = mpsc::channel(CHANNEL_CAPACITY);
|
||||
spawn_ingest_task(reader, ingest_tx);
|
||||
|
||||
let (pcm_tx, pcm_rx) = mpsc::channel(CHANNEL_CAPACITY);
|
||||
let (pcm_reader, pcm_writer) = tokio::io::duplex(DUPLEX_BUFFER_SIZE);
|
||||
let (info_tx, info_rx) = oneshot::channel::<Result<StreamInfo, AacError>>();
|
||||
|
||||
let blocking_handle = tokio::task::spawn_blocking(move || -> Result<(), AacError> {
|
||||
let mut channel_reader = ChannelReader::<AacError>::new(ingest_rx);
|
||||
|
||||
// ReadOnlySource pour stream infini
|
||||
let source = ReadOnlySource::new(&mut channel_reader);
|
||||
let mss = MediaSourceStream::new(Box::new(source), Default::default());
|
||||
|
||||
// Probe AAC/ADTS
|
||||
let mut hint = Hint::new();
|
||||
hint.with_extension("aac");
|
||||
|
||||
let mut format = get_probe()
|
||||
.format(&hint, mss, &Default::default(), &Default::default())
|
||||
.map_err(|e| AacError::Decode(e.to_string()))?;
|
||||
|
||||
let track = format.default_track()
|
||||
.ok_or_else(|| AacError::Decode("No audio track found".into()))?;
|
||||
|
||||
let mut decoder = get_codecs()
|
||||
.make(&track.codec_params, &Default::default())
|
||||
.map_err(|e| AacError::Decode(e.to_string()))?;
|
||||
|
||||
// Extraire StreamInfo
|
||||
let codec_params = &track.codec_params;
|
||||
let info = StreamInfo {
|
||||
sample_rate: codec_params.sample_rate.unwrap_or(48000),
|
||||
channels: codec_params.channels.unwrap().count() as u8,
|
||||
bits_per_sample: 16, // AAC decode to 16-bit PCM
|
||||
total_samples: None, // Stream infini
|
||||
max_block_size: 0,
|
||||
min_block_size: 0,
|
||||
};
|
||||
|
||||
if info_tx.send(Ok(info.clone())).is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Boucle de décodage
|
||||
loop {
|
||||
match format.next_packet() {
|
||||
Ok(packet) => {
|
||||
let decoded = decoder.decode(&packet)
|
||||
.map_err(|e| AacError::Decode(e.to_string()))?;
|
||||
|
||||
// Convertir AudioBufferRef → bytes PCM
|
||||
let pcm_bytes = convert_audio_buffer_to_bytes(decoded, &info);
|
||||
|
||||
if pcm_tx.blocking_send(Ok(pcm_bytes)).is_err() {
|
||||
break; // Reader fermé
|
||||
}
|
||||
}
|
||||
Err(symphonia::core::errors::Error::IoError(e))
|
||||
if e.kind() == std::io::ErrorKind::UnexpectedEof => {
|
||||
break; // Stream terminé normalement
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
let _ = pcm_tx.blocking_send(Err(AacError::Decode(msg.clone())));
|
||||
return Err(AacError::Decode(msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
let writer_handle = spawn_writer_task(pcm_rx, pcm_writer, blocking_handle, "aac-decode");
|
||||
let info = info_rx.await.map_err(|_| AacError::ChannelClosed)??;
|
||||
let reader = ManagedAsyncReader::new("aac-decode-writer", pcm_reader, writer_handle);
|
||||
|
||||
Ok(DecodedStream::new(info, reader))
|
||||
}
|
||||
|
||||
/// Convertir AudioBufferRef Symphonia → bytes PCM little-endian
|
||||
fn convert_audio_buffer_to_bytes(
|
||||
audio_buffer: AudioBufferRef,
|
||||
info: &StreamInfo,
|
||||
) -> Vec<u8> {
|
||||
// Implémenter conversion selon le type de buffer
|
||||
// (S16, S24, S32, F32, etc.) → i16 little-endian interleaved
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**2. `pmoflac/src/lib.rs`** (modifier)
|
||||
|
||||
```rust
|
||||
pub mod aac;
|
||||
|
||||
pub use aac::{decode_aac_stream, AacDecodedStream, AacError};
|
||||
```
|
||||
|
||||
**3. `pmoflac/src/autodetect.rs`** (modifier)
|
||||
|
||||
Ajouter la détection AAC/ADTS :
|
||||
|
||||
```rust
|
||||
fn detect_format(bytes: &[u8]) -> Option<DetectedFormat> {
|
||||
// ... détections existantes ...
|
||||
|
||||
// Détecter ADTS AAC (syncword 0xFFF)
|
||||
if is_adts(bytes) {
|
||||
return Some(DetectedFormat::Aac);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn is_adts(bytes: &[u8]) -> bool {
|
||||
if bytes.len() < 2 {
|
||||
return false;
|
||||
}
|
||||
// ADTS syncword: 12 bits à 1 (0xFFF)
|
||||
bytes[0] == 0xFF && (bytes[1] & 0xF0) == 0xF0
|
||||
}
|
||||
|
||||
pub enum DecodedAudioStream {
|
||||
// ... variants existants ...
|
||||
Aac(AacDecodedStream),
|
||||
}
|
||||
```
|
||||
|
||||
**4. `pmoflac/src/transcode.rs`** (modifier)
|
||||
|
||||
Ajouter AAC au transcodeur :
|
||||
|
||||
```rust
|
||||
pub enum AudioCodec {
|
||||
// ... codecs existants ...
|
||||
Aac,
|
||||
}
|
||||
|
||||
pub async fn transcode_to_flac_stream<R>(
|
||||
reader: R,
|
||||
options: TranscodeOptions,
|
||||
) -> Result<TranscodeToFlac, TranscodeError>
|
||||
where
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
{
|
||||
// ... détection auto ...
|
||||
|
||||
match decoded {
|
||||
// ... cas existants ...
|
||||
DecodedAudioStream::Aac(stream) => {
|
||||
transcode_from_decoded(AudioCodec::Aac, stream, options.encoder_options).await
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**5. `pmoflac/Cargo.toml`** (modifier)
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
# ... dépendances existantes ...
|
||||
|
||||
# AAC support
|
||||
symphonia = { version = "0.5", features = ["aac", "isomp4"], optional = true }
|
||||
symphonia-core = { version = "0.5", optional = true }
|
||||
symphonia-codec-aac = { version = "0.5", optional = true }
|
||||
|
||||
[features]
|
||||
default = ["mp3", "ogg", "opus", "wav", "aiff"]
|
||||
aac = ["dep:symphonia", "dep:symphonia-core", "dep:symphonia-codec-aac"]
|
||||
all = ["mp3", "ogg", "opus", "wav", "aiff", "aac"]
|
||||
```
|
||||
|
||||
#### Tests Round 2
|
||||
|
||||
**Tests unitaires** :
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn test_decode_aac_to_pcm() {
|
||||
let aac_data = include_bytes!("../test-data/sample.aac");
|
||||
let stream = decode_aac_stream(&aac_data[..]).await.unwrap();
|
||||
|
||||
let info = stream.info();
|
||||
assert_eq!(info.sample_rate, 48000);
|
||||
assert_eq!(info.channels, 2);
|
||||
|
||||
// Lire quelques samples
|
||||
let mut buffer = vec![0u8; 4096];
|
||||
let mut reader = stream;
|
||||
let n = reader.read(&mut buffer).await.unwrap();
|
||||
assert!(n > 0);
|
||||
}
|
||||
```
|
||||
|
||||
**Tests intégration** :
|
||||
```rust
|
||||
#[tokio::test]
|
||||
#[ignore = "Integration test - network required"]
|
||||
async fn test_transcode_radiofrance_to_flac() {
|
||||
let stream_url = "https://icecast.radiofrance.fr/fip-hifi.aac";
|
||||
let response = reqwest::get(stream_url).await.unwrap();
|
||||
let reader = response.bytes_stream();
|
||||
|
||||
let transcoded = transcode_to_flac_stream(
|
||||
reader,
|
||||
TranscodeOptions::default()
|
||||
).await.unwrap();
|
||||
|
||||
assert_eq!(transcoded.input_codec(), AudioCodec::Aac);
|
||||
assert_eq!(transcoded.input_stream_info().sample_rate, 48000);
|
||||
|
||||
// Lire 5 secondes de FLAC
|
||||
let mut output = Vec::new();
|
||||
let mut stream = transcoded.into_stream();
|
||||
|
||||
for _ in 0..50 {
|
||||
let mut chunk = vec![0u8; 8192];
|
||||
stream.read(&mut chunk).await.unwrap();
|
||||
output.extend_from_slice(&chunk);
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
assert!(output.len() > 100_000); // Au moins 100 KB de FLAC
|
||||
}
|
||||
```
|
||||
|
||||
#### Critères de succès Round 2
|
||||
|
||||
- ✅ `decode_aac_stream()` suit le pattern existant (MP3, Ogg, etc.)
|
||||
- ✅ Auto-détection AAC/ADTS fonctionne
|
||||
- ✅ Transcodage AAC → FLAC streaming opérationnel
|
||||
- ✅ Tests unitaires et intégration passent
|
||||
- ✅ Documentation complète (doctests, exemples)
|
||||
- ✅ Feature flag `aac` pour compilation optionnelle
|
||||
|
||||
---
|
||||
|
||||
### Round 3 : Intégration dans pmoradiofrance (si Round 2 réussit)
|
||||
|
||||
**Objectif** : Remplacer le proxy AAC passthrough par un transcodage FLAC.
|
||||
|
||||
#### Modifications
|
||||
|
||||
**1. `pmoradiofrance/src/server_ext.rs`**
|
||||
|
||||
Remplacer le proxy passthrough par un transcodage :
|
||||
|
||||
```rust
|
||||
async fn proxy_stream(
|
||||
Path(slug): Path<String>,
|
||||
State(state): State<Arc<RadioFranceServerState>>
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
let stream_url = state.client.get_stream_url(&slug).await
|
||||
.map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
|
||||
|
||||
let response = reqwest::get(&stream_url).await
|
||||
.map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?;
|
||||
|
||||
// Transcoder AAC → FLAC avec pmoflac
|
||||
let transcoded = pmoflac::transcode_to_flac_stream(
|
||||
response.bytes_stream(),
|
||||
pmoflac::TranscodeOptions::default()
|
||||
).await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Enregistrer connexion active et démarrer metadata refresh
|
||||
// ...
|
||||
|
||||
// Stream FLAC au lieu d'AAC
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("Content-Type", "audio/flac".parse().unwrap());
|
||||
headers.insert("Cache-Control", "no-cache".parse().unwrap());
|
||||
|
||||
Ok((headers, Body::from_stream(transcoded.into_stream())).into_response())
|
||||
}
|
||||
```
|
||||
|
||||
**2. `pmoradiofrance/src/playlist.rs`**
|
||||
|
||||
Changer le protocol_info pour FLAC :
|
||||
|
||||
```rust
|
||||
// Avant (AAC)
|
||||
protocol_info: "http-get:*:audio/aac:*"
|
||||
|
||||
// Après (FLAC)
|
||||
protocol_info: "http-get:*:audio/flac:*"
|
||||
sample_frequency: Some(info.sample_rate.to_string())
|
||||
bits_per_sample: Some("16".to_string())
|
||||
```
|
||||
|
||||
#### Critères de succès Round 3
|
||||
|
||||
- ✅ Radio France sert du FLAC au lieu d'AAC
|
||||
- ✅ Uniformité : toutes les sources PMOMusic servent du FLAC
|
||||
- ✅ Latence acceptable (<2s) pour le streaming live
|
||||
- ✅ CPU raisonnable pour 2-3 streams simultanés sur LAN
|
||||
- ✅ Métadonnées volatiles toujours mises à jour
|
||||
|
||||
---
|
||||
|
||||
## Alternative : fdk-aac (si Symphonia échoue)
|
||||
|
||||
Si Symphonia ne fonctionne pas en streaming infini, explorer `fdk-aac` :
|
||||
|
||||
### Avantages
|
||||
- ✅ Décodeur de référence (meilleure qualité)
|
||||
- ✅ Conçu pour le streaming
|
||||
- ✅ Utilisé en production (Android, etc.)
|
||||
|
||||
### Inconvénients
|
||||
- ❌ Dépendance C (compilation complexe)
|
||||
- ❌ Licence restrictive (vérifier compatibilité projet)
|
||||
|
||||
### Prototype minimal
|
||||
|
||||
```rust
|
||||
use fdk_aac::dec::{Decoder, DecoderParams};
|
||||
|
||||
pub async fn decode_aac_with_fdk<R>(reader: R) -> Result<AacDecodedStream>
|
||||
where
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
{
|
||||
// Similar pattern to pmoflac MP3 decoder
|
||||
// spawn_blocking pour le décodeur C
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Résultats attendus
|
||||
|
||||
### Minimum viable (Round 1)
|
||||
|
||||
- ✅ Rapport technique sur la faisabilité du streaming AAC avec Symphonia
|
||||
- ✅ Prototype fonctionnel (même basique)
|
||||
- ✅ Identification des limitations et solutions de contournement
|
||||
|
||||
### Objectif complet (Round 1-3)
|
||||
|
||||
- ✅ Support AAC/ADTS dans `pmoflac` (feature flag optionnelle)
|
||||
- ✅ Transcodage AAC → FLAC streaming opérationnel
|
||||
- ✅ Radio France servant du FLAC uniforme
|
||||
- ✅ Documentation et tests complets
|
||||
|
||||
### En cas d'échec
|
||||
|
||||
- ✅ Rapport détaillé des blocages techniques
|
||||
- ✅ Recommandations alternatives (fdk-aac, attendre évolution Symphonia, etc.)
|
||||
- ✅ Garder le proxy AAC passthrough actuel
|
||||
|
||||
---
|
||||
|
||||
## Références
|
||||
|
||||
### Documentation
|
||||
- [Symphonia Getting Started](https://github.com/pdeljanov/Symphonia/blob/master/GETTING_STARTED.md)
|
||||
- [AdtsReader API](https://docs.rs/symphonia-codec-aac/latest/symphonia_codec_aac/struct.AdtsReader.html)
|
||||
- [ADTS Format Specification](https://wiki.multimedia.cx/index.php/ADTS)
|
||||
- [fdk-aac Rust Bindings](https://github.com/haileys/fdk-aac-rs)
|
||||
|
||||
### Issues et discussions
|
||||
- [Symphonia ReadOnlySource Issue #580](https://github.com/RustAudio/rodio/issues/580)
|
||||
- [Symphonia MediaSource Trait](https://docs.rs/symphonia-core/latest/symphonia_core/io/index.html)
|
||||
|
||||
### Contexte PMOMusic
|
||||
- [Task Radio France](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/ToDiscuss/Construire_pmoradiofrance.md)
|
||||
- [Architecture pmoflac](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmoflac/src/lib.rs)
|
||||
173
Blackboard/ToDiscuss/metadata_RF_cache.md
Normal file
173
Blackboard/ToDiscuss/metadata_RF_cache.md
Normal file
@@ -0,0 +1,173 @@
|
||||
**Tu réaliseras ce travail en appliquant scrupuleusement les règles définies dans [@Rules.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Rules.md)**
|
||||
|
||||
**On ne travaille que dans la Crate PMORadioFrance**
|
||||
|
||||
À partir de maintenant, tu ne prends plus en compte ce que tu pensais avant et tu écoutes bien. Et tu construis un plan d'implémentation que je dois valider. Tu arrêtes de prendre des initiatives et de faire des bêtises.
|
||||
|
||||
- Tu as des fonctions d'interrogation de l'API Radio France qu'il faut utiliser au minimum. Mais Radio France nous donne des dates d'invalidation des métadonnées. Globalement, on doit gérer une grosse map où les valeurs ont des TTL.
|
||||
- Quand un client demande une donnée du cache, si le TTL est atteint, il commence par utiliser l'API Radio France, modifie le cache, puis la retourne. Dans le cas contraire, il retourne directement la donnée.
|
||||
|
||||
A chaque fois qu'il fait un appel de l'API Radio France pour modifier ses valeurs, Le cache émet un événement ou avertit ses abonnés, comme quoi les données d'un slug particulier ont été modifiées. Comme ça tout le monde peut se synchroniser.
|
||||
|
||||
Les clients, par exemple la fonction Browse, n'interrogent que le cache qui a forcément des données à jour. Les métadonnées ne sont jamais stockées hors du cache, on se réfère toujours à elles.
|
||||
|
||||
Nous devons maintenant considérer le fonctionnement du control point. Celui-ci est capable de s'abonner à une playlist pour suivre ses modifications. Il ne peut pas s'abonner à un item.
|
||||
|
||||
Dans le cas d'une radio, on peut considérer que chaque canal, chaque slug, est en réalité une playlist à un item qu'il faut suivre. Ainsi, le Control Point peut décider de jouer cette playlist en s'abonnant à elle et être tenu au courant des modifications par des événements GENA.
|
||||
|
||||
La source Radio France doit donc s'abonner aux événements du Cache. A chaque fois qu'un slug est modifié, elle avertit par un événement Jenna que la playlist à un item qui correspond à ce Slug est modifiée.
|
||||
|
||||
Maintenant, il y a le cache des stations. Le cache des stations finalement il ne stock qu'un emboîtement de listes de slug. Ça, normalement, ça ne bouge quasiment pas. On peut dire que une fois par jour, on met à jour ce cache. Les listes de slug ont donc un TTL mais très long.
|
||||
|
||||
A chaque browse, on reconstruit un document didl à partir des métadonnées à jour provenant du cache.
|
||||
|
||||
## Round 2
|
||||
|
||||
Je repasse sur ton code. Tout est beaucoup beaucoup trop compliqué, trop de structures allambiquées, de trucs qui s'emboîtent dans des trucs. Il faut faire simple. Le mot d'ordre est simple. Nous ne construisons pas une usine à gaz, nous construisons simplement un truc capable de diffuser moins d'une centaine de radios.
|
||||
|
||||
### Simplification de la notion de station.
|
||||
|
||||
Alors, tu fais une distinction entre radio locale et web radio, c'est une distinction sémantique, mais d'un point de vue informatique y'a pas de différence.
|
||||
|
||||
L'unité de base, ça devrait être:
|
||||
|
||||
pub struct StationGroup {
|
||||
pub stations: Vec<Station>,
|
||||
}
|
||||
|
||||
La seule règle metier sémantique est: L'index 0 du vecteur est attribué à la station principale du groupe, par exemple FIP, pour le groupe FIP, si elle existe.
|
||||
|
||||
Et du coup, les StationGroups devrait juste être un vecteur de StationGroup
|
||||
|
||||
- StationGroups définie le niveau zéro du browse
|
||||
- StationGroup définit les différents niveaux 1
|
||||
|
||||
Chaque station étant représentée maintenant par une playlist à un item item, Il y a un niveau 2 de browsing qui correspond à l'item de la station.
|
||||
|
||||
Donc, Station, StationGroup et StationGroups devrait chacun fournir une méthode retournant un objet PMODidl qui se construit en demandant les métadonnées au cache. Genre:
|
||||
|
||||
async pub fn to_didl(caches et server_base_url)
|
||||
|
||||
## Simplification du cache
|
||||
|
||||
Il faut réfléchir, Finalement, qu'est-ce que l'on a besoin de stocker dans le cache pour être efficace? De quoi remplir les Didl. Donc, à partir des données parsées depuis l'API Radio France, il faut reconstruire une structure simplifiée. contenant juste les données telles qu'on va les utiliser dans le diddle. Idéalement, le cache devrait être capable de fournir le bien d'idoles d'un item. Avec une méthode to_didl(slug) -> Un item de la Crate pmodidl. Tout le reste est superflu. Donc ne doit pas être stocké. Pour calculer la durée correctement, Il nous faut la fin de validité de l'item. Il est donc important de stocker end_time. Normalement, end time est aussi le TTL. Car à la fin de la diffusion de cet item, ça veut dire qu'il faut remettre à jour les métadata, Pour avoir l'item suivant.
|
||||
|
||||
## Round 3
|
||||
|
||||
### Problèmes identifiés
|
||||
|
||||
Point 3 : Le cache des slugs doit être persistant et stocké dans la config comme actuellement. Avec un délai d'une semaine. Le cache des métadonnées reste en mémoire. Les métadonnées changent à chaque émission, il n'y a pas de raison de les stocker de manière persistante.
|
||||
|
||||
Du coup, le cache des métadonnées, Pour simplifier la vie des autres structures. devrait s'occuper de cacher les covers dans pmocovers et stocker le PK de l'image dans le cache pour pouvoir construire le didl de l'item.
|
||||
|
||||
En fait, le didl de l'item, Dans notre nouvelle strategie est déjà un didl d'une playlist à un item.
|
||||
|
||||
### Architecture cible simplifiée
|
||||
|
||||
#### 1. Structures de station (models.rs et playlist.rs)
|
||||
|
||||
Je ne comprends pas bien la distinction entre les deux:
|
||||
A-t-on vraiment besoin des deux fonctions?
|
||||
À quoi sert cette fonction to_container?
|
||||
|
||||
```
|
||||
// Browse niveau 1: retourne les playlists (containers) pour chaque station du groupe
|
||||
pub async fn to_didl(&self, metadata_cache: &MetadataCache, server_base_url: &str) -> Vec<Container>;
|
||||
|
||||
// Helper pour construire le container de groupe (sans items, juste la structure)
|
||||
pub fn to_container(&self, server_base_url: Option<&str>) -> Container;
|
||||
```
|
||||
|
||||
```
|
||||
impl CachedMetadata {
|
||||
// Parse depuis LiveResponse + Station + optionnel cover cache
|
||||
#[cfg(feature = "cache")]
|
||||
pub async fn from_live_response(
|
||||
station: &Station,
|
||||
live: &LiveResponse,
|
||||
cover_cache: Option<&Arc<CoverCache>>,
|
||||
server_base_url: Option<&str>,
|
||||
) -> Result<Self>;
|
||||
|
||||
pub fn from_live_response_sync(
|
||||
station: &Station,
|
||||
live: &LiveResponse,
|
||||
server_base_url: Option<&str>,
|
||||
) -> Result<Self>;
|
||||
```
|
||||
|
||||
C'est quoi exactement la fonction from_live_response_sync ?
|
||||
J'ai l'impression que tu surcompliques encore.
|
||||
|
||||
```
|
||||
pub fn to_didl_item(&self, parent_id: &str) -> Item
|
||||
```
|
||||
|
||||
Vu ma remarque précédente:
|
||||
En fait, le didl de l'item, Dans notre nouvelle strategie est déjà un didl d'une playlist à un item.
|
||||
|
||||
Cette fonction devrait juste être un toDiddle et retourner le diddle d'une playlist à un item avec exactement les mêmes métadonnées pour la playlist conteneur et l'item à l'intérieur.
|
||||
|
||||
### 3. Cache de stations (intégré dans stateful_client.rs)
|
||||
|
||||
Comme je le disais plus haut, ce cache doit être permanent via l'usage de la configuration. Comme c'est le cas actuellement.
|
||||
|
||||
## Hiérarchie de browse
|
||||
|
||||
**Niveau 1**: Browse d'un groupe
|
||||
En fait, si un station groupe ne contient qu'un seul item, C'est à dire qu'il n'y a pas plusieurs sous-radios sous ce groupe. on peut directement retourner la playlist simple qui contient simplement cet item.
|
||||
|
||||
Questions pour validation
|
||||
|
||||
1. **Organisation des stations sans webradios**: Faut-il créer un groupe pour chaque station standalone (France Culture, France Inter, etc.) ou les mettre toutes dans un seul groupe "Stations principales"?
|
||||
- En fait, si un station groupe ne contient qu'un seul item, C'est à dire qu'il n'y a pas plusieurs sous-radios sous ce groupe. on peut directement retourner la playlist simple qui contient simplement cet item. Sinon, on retourne un container qui contient les playlists de chacun des items. Cela peut directement être implémenté dans le code de la fonction to Didl du groupe de station.
|
||||
|
||||
2. **Cache de métadonnées**: In-memory uniquement (données volatiles avec TTL court)?
|
||||
- Oui, in-memory seulement, TTL basé sur la fin de diffusion de cet item.
|
||||
|
||||
3. **Cache de stations**: Rester dans pmoconfig avec TTL 1 jour?
|
||||
- Oui, garder le système actuel, Il me semble que le TTL est d'une semaine actuellement, mais le garder tel qu'il est.
|
||||
|
||||
4. **Gestion d'erreur API Radio France down**: Retourner les données expirées avec warning?
|
||||
- **Proposition**: Oui, graceful degradation
|
||||
C'est parfait.
|
||||
|
||||
5. **Migration du code existant**: Faut-il maintenir une compatibilité temporaire ou refactoring complet immédiat?
|
||||
- **Proposition**: Refactoring complet, c'est une simplification profonde
|
||||
C'est parfait.
|
||||
|
||||
|
||||
## Round 4
|
||||
|
||||
### 2. Groupes de stations (playlist.rs)
|
||||
|
||||
```
|
||||
impl StationGroups {
|
||||
// Browse niveau 0: retourne les containers de groupes
|
||||
pub async fn to_didl(&self, metadata_cache: &MetadataCache, server_base_url: &str) -> Vec<Container>;
|
||||
}
|
||||
```
|
||||
|
||||
Pourquoi retourner un vecteur de conteneurs et pas un conteneur qui contient des conteneurs? Ça doit retourner une structure didl La fonction s'appelle to_didl.
|
||||
|
||||
Il faut être cohérent. Et **SIMPLE**.
|
||||
|
||||
### 3. Cache de métadonnées (NOUVEAU: metadata_cache.rs)
|
||||
|
||||
Il y a actuellement dans le code des règles pour passer des métadonnées Radio France vers des métadonnées UPNP, qui agrège les métadonnées selon certaines règles depuis Radio France pour en faire des métadonnées plus simples mais avec une sémantique correcte pour l'interface utilisateur du côté UPNP. Il ne faut pas abandonner ces règles.
|
||||
|
||||
### Hiérarchie de browse
|
||||
|
||||
**Niveau 0**: `radiofrance` → containers de groupes
|
||||
- "France Culture" (id: `radiofrance:franceculture`) - playlist directe si groupe à 1 station
|
||||
- "FIP" (id: `radiofrance:group:fip`) - container de groupe si plusieurs stations
|
||||
- "Radios ICI" (id: `radiofrance:ici`) - container de groupe pour les radios locales --> Je te rappelle qu'il n'y a plus de distinction entre radio locale et autres radios. Ça c'était avant. Donc ICI fonctionne exactement comme FIP.
|
||||
|
||||
### Étapes d'implémentation
|
||||
|
||||
#### Étape 1: Créer metadata_cache.rs
|
||||
1. Définir `CachedMetadata` struct avec tous les champs DIDL
|
||||
|
||||
On est d'accord que si tu définis ce type là, ça veut dire que tu supprimes le client Stateful. Sans ça, c'est complètement redondant.
|
||||
|
||||
A la fin de cette tâche, tu généreras le nouveau plan dans le fichier de rapport tel que c'est demandé par le fichier de règles [@Rules.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Rules.md) que tu devras suivre scrupuleusement.
|
||||
474
Blackboard/ToThinkAbout/analyse_metadonnees_franceculture.md
Normal file
474
Blackboard/ToThinkAbout/analyse_metadonnees_franceculture.md
Normal file
@@ -0,0 +1,474 @@
|
||||
# Analyse : Récupération des métadonnées France Culture
|
||||
|
||||
## Objectif
|
||||
Comprendre comment le site web de France Culture (https://www.radiofrance.fr/franceculture) obtient et affiche les informations sur l'émission en cours.
|
||||
|
||||
## Architecture du site
|
||||
|
||||
### Framework utilisé
|
||||
**SvelteKit** avec Server-Side Rendering (SSR)
|
||||
|
||||
Le site utilise SvelteKit, comme en témoignent :
|
||||
- L'attribut `data-sveltekit-preload-data="hover"` sur le `<body>`
|
||||
- Les classes CSS préfixées par `svelte-` (ex: `svelte-1thibul`, `svelte-qz676b`)
|
||||
- Les chemins vers les assets : `/client/immutable/assets/`
|
||||
|
||||
### Rendu des données
|
||||
**SSR (Server-Side Rendering)** - Les données sont déjà présentes dans le HTML initial
|
||||
|
||||
## Méthode de récupération des informations
|
||||
|
||||
### ✅ API publique JSON découverte !
|
||||
|
||||
**Après analyse du trafic réseau (fichier HAR), l'API officielle existe et est OUVERTE :**
|
||||
|
||||
#### API LiveMeta (métadonnées en temps réel)
|
||||
```
|
||||
https://api.radiofrance.fr/livemeta/live/5/transistor_culture_player
|
||||
```
|
||||
|
||||
**Caractéristiques :**
|
||||
- ✅ **Aucune authentification requise** (pas de token)
|
||||
- ✅ **Endpoint officiel** utilisé par le site web
|
||||
- ✅ **JSON structuré** avec émission en cours, précédente et suivante
|
||||
- ✅ **Timestamps précis** de début et fin d'émission
|
||||
- ✅ **UUIDs des émissions** pour récupérer plus de détails
|
||||
- ✅ **Indicateur de rafraîchissement** (`delayToRefresh` en millisecondes)
|
||||
|
||||
**Exemple de réponse :**
|
||||
```json
|
||||
{
|
||||
"prev": [{
|
||||
"firstLine": "Le direct",
|
||||
"firstLineUuid": null,
|
||||
"firstLinePath": null,
|
||||
"secondLine": "France Culture, l'esprit d'ouverture",
|
||||
"cover": "4e9fba8d-7675-409d-86a0-fce40f0cd4a6",
|
||||
"startTime": null,
|
||||
"endTime": null
|
||||
}],
|
||||
"now": {
|
||||
"firstLine": "La Série fiction",
|
||||
"firstLineUuid": "69cf4362-6bfb-48d1-89cf-9d11202f9938",
|
||||
"firstLineExpressionUuid": "69cf4362-6bfb-48d1-89cf-9d11202f9938",
|
||||
"firstLinePath": "franceculture/podcasts/fictions-le-feuilleton",
|
||||
"firstLinePathUuid": "3c1c2e55-41a0-11e5-9fe0-005056a87c89",
|
||||
"secondLine": "\"Ségou\" de Maryse Condé 9/10 : Deuil et pénitence",
|
||||
"secondLineExpressionUuid": "69cf4362-6bfb-48d1-89cf-9d11202f9938",
|
||||
"cover": "436430f7-5b2b-43f2-9f3c-28f2ad6cae39",
|
||||
"startTime": 1769108400,
|
||||
"endTime": 1769110122
|
||||
},
|
||||
"next": [{
|
||||
"firstLine": "L'Instant poésie",
|
||||
"firstLinePath": "franceculture/podcasts/l-instant-poesie",
|
||||
"firstLineUuid": "06fe22c7-144c-41b8-983d-ec956595b694",
|
||||
"secondLine": "L'Instant poésie d'Abd al Malik 14/20 : \"Roman inachevé\" de Louis Aragon, une main tendue",
|
||||
"cover": "a18a392b-f7d5-41bd-972a-e64451f35213",
|
||||
"startTime": 1769110200,
|
||||
"endTime": 1769110555
|
||||
}],
|
||||
"delayToRefresh": 742000
|
||||
}
|
||||
```
|
||||
|
||||
**Paramètres optionnels :**
|
||||
- `?date=<timestamp>` : Récupérer les métadonnées à un moment donné (historique)
|
||||
|
||||
#### API Pikapi (images de couverture)
|
||||
```
|
||||
https://www.radiofrance.fr/pikapi/images/{uuid}/{taille}
|
||||
```
|
||||
|
||||
**Exemples :**
|
||||
- `https://www.radiofrance.fr/pikapi/images/436430f7-5b2b-43f2-9f3c-28f2ad6cae39/200x200`
|
||||
- Autres tailles disponibles (à tester)
|
||||
|
||||
### Anciennes tentatives (pour référence historique)
|
||||
Les tentatives d'accès aux endpoints suivants ont échoué :
|
||||
- `https://www.radiofrance.fr/api/v2.1/stations/franceculture` → retourne du HTML
|
||||
- `https://www.radiofrance.fr/api/v2.1/stations/franceculture/live` → retourne du HTML
|
||||
- `https://openapi.radiofrance.fr/v1/graphql` → nécessite un header `x-token`
|
||||
|
||||
### Données embarquées dans le HTML (SSR)
|
||||
Les informations sont également directement rendues dans le HTML par le serveur SvelteKit (méthode de fallback).
|
||||
|
||||
## Structure HTML des métadonnées
|
||||
|
||||
### Zone principale : CoverRadio
|
||||
Les informations de l'émission en cours se trouvent dans la section `class="CoverRadio"` :
|
||||
|
||||
```html
|
||||
<div class="CoverRadio-infoContainer">
|
||||
|
||||
<!-- Titre de l'émission/segment -->
|
||||
<div class="CoverRadio-title qg-tt3 svelte-1thibul" role="heading" aria-level="1">
|
||||
<span class="truncate qg-focus-container svelte-1t7i9vq">
|
||||
<a href="/franceculture/podcasts/le-journal-de-l-eco/le-jouet-profite-de-la-morosite-ambiante-4949584"
|
||||
aria-label="Le Journal de l'éco • Le jouet profite de la morosité ambiante">
|
||||
Le Journal de l'éco • Le jouet profite de la morosité ambiante
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Nom de l'émission parente + producteur -->
|
||||
<p class="CoverRadio-subtitle qg-tt5 qg-focus-container svelte-1thibul">
|
||||
<a href="/franceculture/podcasts/les-matins">Les Matins</a>
|
||||
<span class="CoverRadio-producer qg-tx1 svelte-qz676b">par Guillaume Erner</span>
|
||||
</p>
|
||||
|
||||
<!-- Indicateur de direct -->
|
||||
<div class="CoverRadio-ctaTop">
|
||||
<p class="direct qg-st6 CoverRadio-labelDirect dark default svelte-12tsplm">
|
||||
En direct
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
```
|
||||
|
||||
### Classes CSS identifiées
|
||||
|
||||
| Classe CSS | Contenu | Utilité |
|
||||
|------------|---------|---------|
|
||||
| `CoverRadio-title` | Titre du segment/chronique en cours | Titre principal |
|
||||
| `CoverRadio-subtitle` | Nom de l'émission parente | Contexte de diffusion |
|
||||
| `CoverRadio-producer` | Nom du producteur/animateur | Crédit |
|
||||
| `CoverRadio-labelDirect` | Badge "En direct" | Statut de diffusion |
|
||||
|
||||
## Stratégies d'extraction
|
||||
|
||||
### Option 1 : Scraping HTML simple
|
||||
Récupérer la page HTML et extraire les données via :
|
||||
- Parsing HTML (BeautifulSoup en Python, scraper en Rust)
|
||||
- Regex ciblées sur les classes CSS
|
||||
|
||||
**Avantages :**
|
||||
- Pas de token nécessaire
|
||||
- Données toujours présentes dans le HTML
|
||||
- Méthode robuste
|
||||
|
||||
**Inconvénients :**
|
||||
- Dépendant de la structure HTML
|
||||
- Risque de cassure si le site change
|
||||
- Parsing HTML plus lourd
|
||||
|
||||
### Option 2 : API GraphQL avec token
|
||||
L'API GraphQL existe (`https://openapi.radiofrance.fr/v1/graphql`) mais nécessite un `x-token`.
|
||||
|
||||
**Étapes :**
|
||||
1. Analyser le code JavaScript du site pour trouver comment le token est généré
|
||||
2. Extraire ou reproduire la logique de génération de token
|
||||
3. Utiliser l'API GraphQL
|
||||
|
||||
**Avantages :**
|
||||
- API structurée et officielle
|
||||
- Données JSON propres
|
||||
- Moins de risque de changement
|
||||
|
||||
**Inconvénients :**
|
||||
- Nécessite un token (non documenté publiquement)
|
||||
- Potentiellement bloqué/limité en débit
|
||||
- Reverse engineering requis
|
||||
|
||||
### Option 3 : API interne SvelteKit
|
||||
SvelteKit utilise des endpoints `/__data.json` pour l'hydratation client.
|
||||
|
||||
**À explorer :**
|
||||
- `https://www.radiofrance.fr/franceculture/__data.json`
|
||||
- Endpoints de données internes
|
||||
|
||||
## Recommandation
|
||||
|
||||
### Pour un projet comme PMOMusic (pmoradiofrance)
|
||||
|
||||
**Approche hybride recommandée :**
|
||||
|
||||
1. **Court terme : Scraping HTML**
|
||||
- Implémenter un parser HTML en Rust
|
||||
- Cibler les classes CSS `CoverRadio-*`
|
||||
- Parser avec `scraper` ou `select` en Rust
|
||||
|
||||
2. **Moyen terme : Investigation API**
|
||||
- Analyser le code JavaScript pour trouver le token
|
||||
- Tenter d'utiliser l'API GraphQL si possible
|
||||
|
||||
3. **Mise en cache et rafraîchissement**
|
||||
- Rafraîchir les métadonnées toutes les 1-5 minutes
|
||||
- Mettre en cache pour éviter les requêtes excessives
|
||||
|
||||
## Exemple de code conceptuel (Rust)
|
||||
|
||||
```rust
|
||||
use scraper::{Html, Selector};
|
||||
|
||||
async fn fetch_current_show() -> Result<ShowInfo, Error> {
|
||||
let html = reqwest::get("https://www.radiofrance.fr/franceculture")
|
||||
.await?
|
||||
.text()
|
||||
.await?;
|
||||
|
||||
let document = Html::parse_document(&html);
|
||||
|
||||
// Sélecteurs CSS
|
||||
let title_selector = Selector::parse(".CoverRadio-title a").unwrap();
|
||||
let subtitle_selector = Selector::parse(".CoverRadio-subtitle a").unwrap();
|
||||
let producer_selector = Selector::parse(".CoverRadio-producer").unwrap();
|
||||
|
||||
let title = document
|
||||
.select(&title_selector)
|
||||
.next()
|
||||
.map(|e| e.inner_html())
|
||||
.unwrap_or_default();
|
||||
|
||||
let show_name = document
|
||||
.select(&subtitle_selector)
|
||||
.next()
|
||||
.map(|e| e.inner_html())
|
||||
.unwrap_or_default();
|
||||
|
||||
let producer = document
|
||||
.select(&producer_selector)
|
||||
.next()
|
||||
.map(|e| e.inner_html().replace("par ", ""))
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(ShowInfo {
|
||||
title,
|
||||
show_name,
|
||||
producer,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Points d'attention
|
||||
|
||||
1. **Rate limiting** : Ne pas surcharger le site avec des requêtes trop fréquentes
|
||||
2. **User-Agent** : Utiliser un User-Agent identifiable pour un projet open-source
|
||||
3. **Gestion d'erreurs** : Le site peut être temporairement indisponible
|
||||
4. **Structure HTML** : Peut changer sans préavis
|
||||
5. **Respect des CGU** : Vérifier les conditions d'utilisation de Radio France
|
||||
|
||||
## Mise à jour de la page côté client
|
||||
|
||||
### Comment la page se rafraîchit-elle ?
|
||||
|
||||
**Réponse : La page ne se met PAS à jour automatiquement côté client.**
|
||||
|
||||
Après analyse :
|
||||
1. **Pas de polling/WebSocket** : Aucun mécanisme de `setInterval`, `setTimeout`, WebSocket ou Server-Sent Events (SSE) détecté dans le HTML
|
||||
2. **Pas de JavaScript de mise à jour** : Le DOM n'est pas modifié dynamiquement pour les métadonnées `CoverRadio-*`
|
||||
3. **Navigation SvelteKit** : Les mises à jour se font via la navigation SPA de SvelteKit
|
||||
|
||||
### Mécanisme de navigation SvelteKit
|
||||
|
||||
SvelteKit utilise le **preloading** et les **endpoints `__data.json`** :
|
||||
|
||||
```
|
||||
https://www.radiofrance.fr/franceculture/__data.json
|
||||
```
|
||||
|
||||
Cet endpoint retourne un **JSON structuré** contenant toutes les données de la page, incluant :
|
||||
- Métadonnées de l'émission en cours
|
||||
- Configuration du site
|
||||
- Contenu de la page
|
||||
|
||||
**Format de données** :
|
||||
```json
|
||||
{
|
||||
"type": "data",
|
||||
"nodes": [
|
||||
{
|
||||
"metadata": { ... },
|
||||
"context": { ... },
|
||||
"mainStationLive": { ... }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Stratégie de rafraîchissement
|
||||
|
||||
Pour un utilisateur sur le site :
|
||||
1. **Chargement initial** : SSR complet avec HTML
|
||||
2. **Navigation ultérieure** : SvelteKit charge `__data.json` en AJAX
|
||||
3. **Rechargement manuel** : L'utilisateur doit recharger la page (F5) pour voir les nouvelles métadonnées
|
||||
|
||||
**Il n'y a pas de mise à jour automatique en temps réel.**
|
||||
|
||||
## Recommandation mise à jour
|
||||
|
||||
### 🏆 Option privilégiée : API LiveMeta officielle (DÉCOUVERTE !)
|
||||
|
||||
**URL :** `https://api.radiofrance.fr/livemeta/live/5/transistor_culture_player`
|
||||
|
||||
**Avantages :**
|
||||
- ✅ **API officielle Radio France** : Endpoint public et documenté
|
||||
- ✅ **Aucune authentification** : Pas de token, pas de restriction
|
||||
- ✅ **JSON léger et structuré** : Format simple et prévisible
|
||||
- ✅ **Données optimales** : Juste ce qu'il faut (prev/now/next)
|
||||
- ✅ **Polling intelligent** : `delayToRefresh` indique quand rafraîchir
|
||||
- ✅ **Stable** : API de production utilisée par le site officiel
|
||||
- ✅ **Support historique** : Paramètre `?date=` pour l'historique
|
||||
- ✅ **UUIDs** : Références pour récupérer plus de détails si besoin
|
||||
|
||||
**Inconvénients :**
|
||||
- Aucun majeur identifié
|
||||
|
||||
**Code Rust recommandé :**
|
||||
```rust
|
||||
use serde::{Deserialize, Serialize};
|
||||
use reqwest;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct LiveMetadata {
|
||||
prev: Vec<ShowInfo>,
|
||||
now: ShowInfo,
|
||||
next: Vec<ShowInfo>,
|
||||
#[serde(rename = "delayToRefresh")]
|
||||
delay_to_refresh: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct ShowInfo {
|
||||
#[serde(rename = "firstLine")]
|
||||
first_line: String,
|
||||
#[serde(rename = "firstLineUuid")]
|
||||
first_line_uuid: Option<String>,
|
||||
#[serde(rename = "firstLinePath")]
|
||||
first_line_path: Option<String>,
|
||||
#[serde(rename = "secondLine")]
|
||||
second_line: String,
|
||||
cover: String,
|
||||
#[serde(rename = "startTime")]
|
||||
start_time: Option<u64>,
|
||||
#[serde(rename = "endTime")]
|
||||
end_time: Option<u64>,
|
||||
}
|
||||
|
||||
async fn fetch_franceculture_live() -> Result<LiveMetadata, reqwest::Error> {
|
||||
let url = "https://api.radiofrance.fr/livemeta/live/5/transistor_culture_player";
|
||||
|
||||
reqwest::get(url)
|
||||
.await?
|
||||
.json::<LiveMetadata>()
|
||||
.await
|
||||
}
|
||||
|
||||
// Utilisation avec polling intelligent
|
||||
async fn monitor_live() {
|
||||
loop {
|
||||
match fetch_franceculture_live().await {
|
||||
Ok(metadata) => {
|
||||
println!("En cours : {} - {}",
|
||||
metadata.now.first_line,
|
||||
metadata.now.second_line
|
||||
);
|
||||
|
||||
// Attendre le temps recommandé avant de rafraîchir
|
||||
tokio::time::sleep(
|
||||
tokio::time::Duration::from_millis(metadata.delay_to_refresh)
|
||||
).await;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Erreur : {}", e);
|
||||
// Fallback : attendre 60 secondes
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(60)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Hiérarchie des options (mise à jour)
|
||||
|
||||
1. **🥇 Premier choix : API LiveMeta** - API officielle Radio France
|
||||
2. **🥈 Fallback niveau 1 : `__data.json`** - Endpoint SvelteKit si LiveMeta indisponible
|
||||
3. **🥉 Fallback niveau 2 : Scraping HTML** - Si les API JSON sont toutes indisponibles
|
||||
4. **💭 Exploration future : API GraphQL** - Si un token public devient disponible
|
||||
|
||||
## Conclusion
|
||||
|
||||
**Pour la mise à jour côté serveur (PMOMusic) :**
|
||||
- ✅ **Utiliser l'API LiveMeta officielle** : `https://api.radiofrance.fr/livemeta/live/5/transistor_culture_player`
|
||||
- ✅ **Polling intelligent** : Utiliser `delayToRefresh` pour optimiser les appels
|
||||
- ✅ **Récupération des images** : Via Pikapi avec l'UUID de `cover`
|
||||
- ✅ **Gestion d'erreur** : Fallback sur `__data.json` puis HTML si nécessaire
|
||||
|
||||
**Pour la page web elle-même :**
|
||||
- **Aucune mise à jour automatique** : L'utilisateur doit recharger la page manuellement
|
||||
- Navigation SPA via SvelteKit charge `__data.json` en AJAX
|
||||
- Le SSR initial contient déjà toutes les données dans le HTML
|
||||
|
||||
## URLs de flux audio découvertes
|
||||
|
||||
### Flux HLS (recommandé)
|
||||
|
||||
**Master playlist :**
|
||||
```
|
||||
https://stream.radiofrance.fr/franceculture/franceculture.m3u8?id=radiofrance
|
||||
```
|
||||
|
||||
**Qualités disponibles :**
|
||||
- **lofi** : 105 kbps (BANDWIDTH=107000) - `franceculture_lofi.m3u8?id=radiofrance`
|
||||
- **midfi** : 178 kbps (BANDWIDTH=185000) - `franceculture_midfi.m3u8?id=radiofrance`
|
||||
- **hifi** : 268 kbps (BANDWIDTH=280000) - `franceculture_hifi.m3u8?id=radiofrance`
|
||||
|
||||
Codec : `mp4a.40.2` (AAC-LC)
|
||||
|
||||
### Flux Icecast (à confirmer)
|
||||
|
||||
D'après RF_old.json, ces URLs devraient exister (non observées dans le HAR car le player web utilise HLS) :
|
||||
|
||||
**MP3 :**
|
||||
```
|
||||
https://icecast.radiofrance.fr/franceculture-lofi.mp3?id=radiofrance
|
||||
https://icecast.radiofrance.fr/franceculture-midfi.mp3?id=radiofrance
|
||||
https://icecast.radiofrance.fr/franceculture-hifi.mp3?id=radiofrance
|
||||
```
|
||||
|
||||
**AAC :**
|
||||
```
|
||||
https://icecast.radiofrance.fr/franceculture-lofi.aac?id=radiofrance
|
||||
https://icecast.radiofrance.fr/franceculture-midfi.aac?id=radiofrance
|
||||
https://icecast.radiofrance.fr/franceculture-hifi.aac?id=radiofrance
|
||||
```
|
||||
|
||||
## Mapping des stations Radio France
|
||||
|
||||
D'après l'analyse du fichier HAR et RF_old.json, voici le mapping des IDs de stations :
|
||||
|
||||
| Station | ID Station | Endpoint LiveMeta |
|
||||
|---------|-----------|-------------------|
|
||||
| France Culture | 5 | `/livemeta/live/5/transistor_culture_player` |
|
||||
| France Inter | ? | À découvrir |
|
||||
| France Musique | ? | À découvrir |
|
||||
| FIP | ? | À découvrir |
|
||||
| Mouv' | ? | À découvrir |
|
||||
| France Bleu (national) | ? | À découvrir |
|
||||
|
||||
**Note :** Les IDs des autres stations peuvent être découverts en analysant le HAR de leurs pages respectives ou en testant des valeurs séquentielles (1, 2, 3, 4, 6, 7...).
|
||||
|
||||
## Prochaines étapes recommandées
|
||||
|
||||
1. ✅ **Implémenter le client LiveMeta** en Rust avec les structures proposées
|
||||
2. 🔍 **Découvrir les IDs des autres stations** Radio France
|
||||
3. 🔍 **Tester les URLs Icecast** pour confirmer leur disponibilité
|
||||
4. 📋 **Documenter l'API complète** dans le code PMOMusic
|
||||
5. 🧪 **Tester le paramètre `?date=`** pour l'accès historique
|
||||
6. 🎨 **Tester les tailles d'images Pikapi** disponibles (200x200, 400x400, etc.)
|
||||
|
||||
## Annexe : Analyse du fichier HAR
|
||||
|
||||
**Source :** `www.radiofrance.fr.har`
|
||||
**Date de capture :** 2026-01-22
|
||||
**Page analysée :** https://www.radiofrance.fr/franceculture
|
||||
|
||||
**Découvertes principales :**
|
||||
- API LiveMeta accessible et ouverte
|
||||
- Aucune authentification requise
|
||||
- Polling intelligent via `delayToRefresh`
|
||||
- Support HLS multi-bitrate
|
||||
- API Pikapi pour les images
|
||||
|
||||
Cette analyse confirme que Radio France expose des APIs publiques utilisables pour des projets comme PMOMusic.
|
||||
1227
Blackboard/ToThinkAbout/api_radiofrance_complete.md
Normal file
1227
Blackboard/ToThinkAbout/api_radiofrance_complete.md
Normal file
File diff suppressed because it is too large
Load Diff
831
Blackboard/ToThinkAbout/client_radiofrance_architecture.md
Normal file
831
Blackboard/ToThinkAbout/client_radiofrance_architecture.md
Normal file
@@ -0,0 +1,831 @@
|
||||
# Architecture du client Radio France (client.rs)
|
||||
|
||||
**Date** : 2026-01-22
|
||||
**Objectif** : Conception d'une API Rust pour interroger les métadonnées live et flux audio de Radio France
|
||||
**Référence** : Architecture inspirée de `pmoparadise/src/client.rs`
|
||||
|
||||
---
|
||||
|
||||
## Table des matières
|
||||
|
||||
1. [Vue d'ensemble](#vue-densemble)
|
||||
2. [Découverte dynamique des stations](#découverte-dynamique-des-stations)
|
||||
3. [Architecture du client](#architecture-du-client)
|
||||
4. [Structures de données](#structures-de-données)
|
||||
5. [Méthodes principales](#méthodes-principales)
|
||||
6. [Exemple d'utilisation](#exemple-dutilisation)
|
||||
7. [Points d'attention](#points-dattention)
|
||||
|
||||
---
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
Le client Radio France doit permettre :
|
||||
- **Découverte dynamique** de ~73 stations/webradios (scraping HTML)
|
||||
- **Métadonnées live** via `/api/live?` avec polling intelligent
|
||||
- **Flux audio** en qualité maximale uniquement (AAC 192 kbps + HLS)
|
||||
- **Un seul client** pour toutes les stations (pas un client par station)
|
||||
|
||||
### Philosophie
|
||||
|
||||
- **Pas de hardcoding** : Toutes les stations sont découvertes dynamiquement
|
||||
- **Qualité maximale uniquement** : AAC 192 kbps (hifi) + HLS, pas de choix lofi/midfi
|
||||
- **Architecture simple** : Un client unique, les stations sont des paramètres
|
||||
|
||||
---
|
||||
|
||||
## Découverte dynamique des stations
|
||||
|
||||
### Stratégie complète
|
||||
|
||||
Radio France n'expose **pas d'API centralisée** listant toutes les stations. La découverte se fait par **scraping HTML** des pages principales :
|
||||
|
||||
#### 1. Stations principales (8)
|
||||
|
||||
**Source** : `https://www.radiofrance.fr/`
|
||||
|
||||
**Méthode** : Scraper le HTML et extraire tous les slugs via regex `(franceinter|franceinfo|franceculture|francemusique|fip|mouv|francebleu|monpetit)`
|
||||
|
||||
**Résultat attendu** :
|
||||
```
|
||||
franceinter
|
||||
franceinfo
|
||||
franceculture
|
||||
francemusique
|
||||
fip
|
||||
mouv
|
||||
francebleu
|
||||
monpetitfranceinter
|
||||
```
|
||||
|
||||
#### 2. Webradios de chaque station (nombre variable)
|
||||
|
||||
**Principe** : **TOUTES les stations** peuvent avoir des webradios, pas seulement FIP et France Musique.
|
||||
|
||||
**Méthode** : Pour chaque station principale découverte, scraper sa page `https://www.radiofrance.fr/{station}` et extraire les identifiants via regex `{station}_[a-z_]+`
|
||||
|
||||
**Exemples découverts** :
|
||||
|
||||
**FIP** (`https://www.radiofrance.fr/fip`) :
|
||||
```
|
||||
fip_cultes
|
||||
fip_electro
|
||||
fip_groove
|
||||
fip_hiphop
|
||||
fip_jazz
|
||||
fip_metal
|
||||
fip_nouveautes
|
||||
fip_pop
|
||||
fip_reggae
|
||||
fip_rock
|
||||
fip_sacre_francais
|
||||
fip_world
|
||||
```
|
||||
|
||||
**France Musique** (`https://www.radiofrance.fr/francemusique`) :
|
||||
```
|
||||
francemusique_baroque
|
||||
francemusique_classique_easy
|
||||
francemusique_classique_love
|
||||
francemusique_classique_plus
|
||||
francemusique_concert_rf
|
||||
francemusique_evenementielle
|
||||
francemusique_la_contemporaine
|
||||
francemusique_la_jazz
|
||||
francemusique_ocora_monde
|
||||
francemusique_opera
|
||||
francemusique_piano_zen
|
||||
```
|
||||
|
||||
**Autres stations** : À découvrir dynamiquement (France Inter, Mouv, etc. pourraient avoir des webradios futures)
|
||||
|
||||
#### 3. Radios locales France Bleu (~40)
|
||||
|
||||
**Source** : API `/francebleu/api/live?` → champ `localRadios[]`
|
||||
|
||||
**Méthode** : Appel API et extraction du tableau JSON
|
||||
|
||||
**Exemple de structure** :
|
||||
```json
|
||||
{
|
||||
"localRadios": [
|
||||
{"id": 12, "title": "ICI Alsace", "name": "francebleu_alsace", "isOnAir": true},
|
||||
{"id": 13, "title": "ICI Armorique", "name": "francebleu_armorique", "isOnAir": true},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Total découvert
|
||||
|
||||
- **8** stations principales
|
||||
- **~23** webradios (12 FIP + 11 France Musique + possibles autres)
|
||||
- **~40** radios locales France Bleu
|
||||
- **= ~71+ stations au total** (extensible automatiquement si nouvelles webradios)
|
||||
|
||||
---
|
||||
|
||||
## Architecture du client
|
||||
|
||||
### Client unique
|
||||
|
||||
Contrairement à une approche "un client par station", nous utilisons **un seul client** avec les stations comme **paramètres de méthode**.
|
||||
|
||||
```rust
|
||||
pub struct RadioFranceClient {
|
||||
client: reqwest::Client,
|
||||
timeout: Duration,
|
||||
}
|
||||
```
|
||||
|
||||
### Pas de cache interne
|
||||
|
||||
Le client est **stateless** et ne cache rien. La gestion du cache (métadonnées, images) sera faite par les couches supérieures (`SourceCacheManager`).
|
||||
|
||||
### Builder pattern
|
||||
|
||||
Pour permettre la configuration :
|
||||
|
||||
```rust
|
||||
pub struct ClientBuilder {
|
||||
client: Option<reqwest::Client>,
|
||||
timeout: Duration,
|
||||
user_agent: String,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Structures de données
|
||||
|
||||
### 1. Station découverte
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Station {
|
||||
pub slug: String, // "fip_rock", "franceinter"
|
||||
pub name: String, // "FIP Rock", "France Inter"
|
||||
pub station_type: StationType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum StationType {
|
||||
Main, // Station principale
|
||||
Webradio { // Webradio de n'importe quelle station
|
||||
parent_station: String, // "fip", "francemusique", "mouv", etc.
|
||||
},
|
||||
LocalRadio { region: String }, // Radio locale France Bleu
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Réponse API Live
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LiveResponse {
|
||||
pub station_name: String,
|
||||
pub delay_to_refresh: u64, // millisecondes
|
||||
pub migrated: bool,
|
||||
pub now: ShowMetadata,
|
||||
pub next: Option<ShowMetadata>,
|
||||
pub local_radios: Option<Vec<LocalRadio>>, // France Bleu uniquement
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Métadonnées d'émission
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ShowMetadata {
|
||||
pub start_time: Option<u64>,
|
||||
pub end_time: Option<u64>,
|
||||
pub producer: Option<String>,
|
||||
pub first_line: Line, // Titre émission
|
||||
pub second_line: Line, // Titre épisode/chronique
|
||||
pub third_line: Option<Line>, // Sous-titre
|
||||
pub intro: Option<String>, // Description
|
||||
pub song: Option<Song>, // Pour radios musicales (FIP, France Musique)
|
||||
pub media: Media, // Flux audio disponibles
|
||||
pub visual_background: Option<EmbedImage>,
|
||||
pub visuals: Option<Visuals>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Line {
|
||||
pub title: Option<String>,
|
||||
pub id: Option<String>,
|
||||
pub path: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Morceau musical (FIP, France Musique)
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Song {
|
||||
pub id: String,
|
||||
pub year: Option<u32>,
|
||||
pub interpreters: Vec<String>,
|
||||
pub release: Release,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Release {
|
||||
pub label: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub reference: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Flux audio
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Media {
|
||||
pub sources: Vec<StreamSource>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StreamSource {
|
||||
pub url: String,
|
||||
pub broadcast_type: BroadcastType,
|
||||
pub format: StreamFormat,
|
||||
pub bitrate: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum BroadcastType {
|
||||
Live,
|
||||
Timeshift,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum StreamFormat {
|
||||
Mp3,
|
||||
Aac,
|
||||
Hls,
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Images
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EmbedImage {
|
||||
pub model: String,
|
||||
pub src: String,
|
||||
pub width: Option<u32>,
|
||||
pub height: Option<u32>,
|
||||
pub dominant: Option<String>,
|
||||
pub copyright: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Visuals {
|
||||
pub card: Option<EmbedImage>,
|
||||
pub player: Option<EmbedImage>,
|
||||
}
|
||||
|
||||
pub enum ImageSize {
|
||||
Tiny, // 88x88
|
||||
Small, // 200x200
|
||||
Medium, // 420x720
|
||||
Large, // 560x960
|
||||
XLarge, // 1200x680
|
||||
Raw, // Taille originale
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Radios locales
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LocalRadio {
|
||||
pub id: u32,
|
||||
pub title: String,
|
||||
pub name: String,
|
||||
pub is_on_air: bool,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Méthodes principales
|
||||
|
||||
### 1. Création du client
|
||||
|
||||
```rust
|
||||
impl RadioFranceClient {
|
||||
/// Créer un nouveau client avec settings par défaut
|
||||
pub async fn new() -> Result<Self> {
|
||||
Self::builder().build().await
|
||||
}
|
||||
|
||||
/// Créer un builder pour configuration avancée
|
||||
pub fn builder() -> ClientBuilder {
|
||||
ClientBuilder::default()
|
||||
}
|
||||
|
||||
/// Créer avec un reqwest::Client existant
|
||||
pub fn with_client(client: reqwest::Client) -> Self {
|
||||
Self {
|
||||
client,
|
||||
timeout: Duration::from_secs(30),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Découverte des stations
|
||||
|
||||
```rust
|
||||
impl RadioFranceClient {
|
||||
/// Découvrir toutes les stations disponibles (scraping + API)
|
||||
pub async fn discover_all_stations(&self) -> Result<Vec<Station>> {
|
||||
let mut stations = Vec::new();
|
||||
|
||||
// 1. Découvrir les stations principales
|
||||
let main_stations = self.scrape_main_stations().await?;
|
||||
|
||||
// 2. Pour CHAQUE station principale, découvrir ses webradios éventuelles
|
||||
for main_station in main_stations {
|
||||
// Ajouter la station principale
|
||||
stations.push(main_station.clone());
|
||||
|
||||
// Découvrir ses webradios (peut retourner 0 si aucune)
|
||||
if let Ok(webradios) = self.scrape_station_webradios(&main_station.slug).await {
|
||||
stations.extend(webradios);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Cas spécial : radios locales France Bleu (via API)
|
||||
if let Ok(locals) = self.discover_local_radios().await {
|
||||
stations.extend(locals);
|
||||
}
|
||||
|
||||
Ok(stations)
|
||||
}
|
||||
|
||||
/// Scraper les stations principales depuis homepage
|
||||
async fn scrape_main_stations(&self) -> Result<Vec<Station>> {
|
||||
let html = self.client
|
||||
.get("https://www.radiofrance.fr/")
|
||||
.timeout(self.timeout)
|
||||
.send()
|
||||
.await?
|
||||
.text()
|
||||
.await?;
|
||||
|
||||
let re = regex::Regex::new(
|
||||
r"(franceinter|franceinfo|franceculture|francemusique|fip|mouv|francebleu|monpetit)"
|
||||
)?;
|
||||
|
||||
let mut slugs = std::collections::HashSet::new();
|
||||
for cap in re.captures_iter(&html) {
|
||||
slugs.insert(cap[0].to_string());
|
||||
}
|
||||
|
||||
Ok(slugs.into_iter().map(|slug| Station {
|
||||
slug: slug.clone(),
|
||||
name: Self::slug_to_name(&slug),
|
||||
station_type: StationType::Main,
|
||||
}).collect())
|
||||
}
|
||||
|
||||
/// Scraper les webradios d'une station donnée
|
||||
///
|
||||
/// Fonctionne pour n'importe quelle station (fip, francemusique, mouv, etc.)
|
||||
/// Retourne un Vec vide si aucune webradio n'est trouvée.
|
||||
async fn scrape_station_webradios(&self, station: &str) -> Result<Vec<Station>> {
|
||||
let url = format!("https://www.radiofrance.fr/{}", station);
|
||||
let html = self.client
|
||||
.get(&url)
|
||||
.timeout(self.timeout)
|
||||
.send()
|
||||
.await?
|
||||
.text()
|
||||
.await?;
|
||||
|
||||
// Pattern générique : {station}_[a-z_]+
|
||||
let pattern = format!(r"{}_[a-z_]+", station);
|
||||
let re = regex::Regex::new(&pattern)?;
|
||||
|
||||
let mut slugs = std::collections::HashSet::new();
|
||||
for cap in re.captures_iter(&html) {
|
||||
slugs.insert(cap[0].to_string());
|
||||
}
|
||||
|
||||
Ok(slugs.into_iter().map(|slug| Station {
|
||||
slug: slug.clone(),
|
||||
name: Self::slug_to_name(&slug),
|
||||
station_type: StationType::Webradio {
|
||||
parent_station: station.to_string(),
|
||||
},
|
||||
}).collect())
|
||||
}
|
||||
|
||||
/// Découvrir les radios locales France Bleu via API
|
||||
async fn discover_local_radios(&self) -> Result<Vec<Station>> {
|
||||
let response = self.live_metadata("francebleu").await?;
|
||||
|
||||
Ok(response.local_radios
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|local| Station {
|
||||
slug: local.name,
|
||||
name: local.title,
|
||||
station_type: StationType::LocalRadio {
|
||||
region: local.title.replace("ICI ", ""),
|
||||
},
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Convertir slug en nom lisible (heuristique simple)
|
||||
fn slug_to_name(slug: &str) -> String {
|
||||
// Transformations basiques, à améliorer
|
||||
slug.replace('_', " ")
|
||||
.split_whitespace()
|
||||
.map(|w| {
|
||||
let mut c = w.chars();
|
||||
match c.next() {
|
||||
None => String::new(),
|
||||
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Métadonnées live
|
||||
|
||||
```rust
|
||||
impl RadioFranceClient {
|
||||
/// Récupérer les métadonnées live d'une station
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `station` - Slug de la station (ex: "franceculture", "fip_rock")
|
||||
///
|
||||
/// # Webradios
|
||||
/// Pour les webradios FIP/France Musique, utiliser le format :
|
||||
/// - Principales : "fip", "francemusique"
|
||||
/// - Webradios : "fip_rock", "francemusique_jazz"
|
||||
///
|
||||
/// L'API utilise le paramètre `?webradio=` automatiquement si nécessaire.
|
||||
pub async fn live_metadata(&self, station: &str) -> Result<LiveResponse> {
|
||||
let (base_station, webradio) = Self::parse_station_slug(station);
|
||||
|
||||
let mut url = url::Url::parse(&format!(
|
||||
"https://www.radiofrance.fr/{}/api/live?",
|
||||
base_station
|
||||
))?;
|
||||
|
||||
// Ajouter le paramètre webradio si nécessaire
|
||||
if let Some(wr) = webradio {
|
||||
url.query_pairs_mut().append_pair("webradio", wr);
|
||||
}
|
||||
|
||||
let response = self.client
|
||||
.get(url)
|
||||
.timeout(self.timeout)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(Error::ApiError(format!(
|
||||
"API returned status: {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(response.json().await?)
|
||||
}
|
||||
|
||||
/// Parser le slug pour extraire station de base et webradio
|
||||
///
|
||||
/// Exemples :
|
||||
/// - "fip" → ("fip", None)
|
||||
/// - "fip_rock" → ("fip", Some("fip_rock"))
|
||||
/// - "francemusique_jazz" → ("francemusique", Some("francemusique_jazz"))
|
||||
/// - "franceinter" → ("franceinter", None)
|
||||
fn parse_station_slug(slug: &str) -> (&str, Option<&str>) {
|
||||
if slug.starts_with("fip_") {
|
||||
("fip", Some(slug))
|
||||
} else if slug.starts_with("francemusique_") {
|
||||
("francemusique", Some(slug))
|
||||
} else if slug.starts_with("francebleu_") {
|
||||
// Radios locales : pas de paramètre webradio, slug direct
|
||||
(slug, None)
|
||||
} else {
|
||||
// Stations principales
|
||||
(slug, None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupérer uniquement les métadonnées de l'émission actuelle
|
||||
pub async fn now_playing(&self, station: &str) -> Result<ShowMetadata> {
|
||||
let response = self.live_metadata(station).await?;
|
||||
Ok(response.now)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Flux audio (qualité maximale uniquement)
|
||||
|
||||
```rust
|
||||
impl RadioFranceClient {
|
||||
/// Récupérer l'URL du flux audio en qualité maximale
|
||||
///
|
||||
/// Priorité : AAC 192 kbps (hifi) > HLS
|
||||
pub async fn get_hifi_stream_url(&self, station: &str) -> Result<String> {
|
||||
let metadata = self.live_metadata(station).await?;
|
||||
|
||||
// Chercher AAC hifi (192 kbps)
|
||||
if let Some(source) = metadata.now.media.sources.iter().find(|s| {
|
||||
s.format == StreamFormat::Aac
|
||||
&& s.broadcast_type == BroadcastType::Live
|
||||
&& s.bitrate == 192
|
||||
}) {
|
||||
return Ok(source.url.clone());
|
||||
}
|
||||
|
||||
// Fallback HLS
|
||||
if let Some(source) = metadata.now.media.sources.iter().find(|s| {
|
||||
s.format == StreamFormat::Hls
|
||||
&& s.broadcast_type == BroadcastType::Live
|
||||
}) {
|
||||
return Ok(source.url.clone());
|
||||
}
|
||||
|
||||
Err(Error::NoHifiStream(format!(
|
||||
"No HiFi stream found for station: {}",
|
||||
station
|
||||
)))
|
||||
}
|
||||
|
||||
/// Lister tous les flux disponibles pour une station
|
||||
pub async fn get_available_streams(&self, station: &str) -> Result<Vec<StreamSource>> {
|
||||
let metadata = self.live_metadata(station).await?;
|
||||
Ok(metadata.now.media.sources)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Images (Pikapi)
|
||||
|
||||
```rust
|
||||
impl RadioFranceClient {
|
||||
/// Construire l'URL d'une image Pikapi
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `uuid` - UUID de l'image (extrait des métadonnées)
|
||||
/// * `size` - Taille souhaitée
|
||||
pub fn get_image_url(uuid: &str, size: ImageSize) -> String {
|
||||
let size_str = match size {
|
||||
ImageSize::Tiny => "88x88",
|
||||
ImageSize::Small => "200x200",
|
||||
ImageSize::Medium => "420x720",
|
||||
ImageSize::Large => "560x960",
|
||||
ImageSize::XLarge => "1200x680",
|
||||
ImageSize::Raw => "raw",
|
||||
};
|
||||
|
||||
format!("https://www.radiofrance.fr/pikapi/images/{}/{}", uuid, size_str)
|
||||
}
|
||||
|
||||
/// Extraire l'UUID d'une URL Pikapi existante
|
||||
pub fn extract_image_uuid(url: &str) -> Option<String> {
|
||||
let re = regex::Regex::new(r"/pikapi/images/([a-f0-9-]+)").ok()?;
|
||||
re.captures(url)
|
||||
.and_then(|cap| cap.get(1))
|
||||
.map(|m| m.as_str().to_string())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Polling intelligent
|
||||
|
||||
```rust
|
||||
impl RadioFranceClient {
|
||||
/// Calculer le délai avant le prochain refresh recommandé
|
||||
pub fn next_refresh_delay(metadata: &LiveResponse) -> Duration {
|
||||
Duration::from_millis(metadata.delay_to_refresh)
|
||||
}
|
||||
|
||||
/// Calculer le délai en tenant compte du temps écoulé
|
||||
pub fn adjusted_refresh_delay(
|
||||
metadata: &LiveResponse,
|
||||
fetched_at: std::time::SystemTime,
|
||||
) -> Duration {
|
||||
let base_delay = Duration::from_millis(metadata.delay_to_refresh);
|
||||
let elapsed = fetched_at.elapsed().unwrap_or(Duration::ZERO);
|
||||
|
||||
base_delay.saturating_sub(elapsed)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Exemple d'utilisation
|
||||
|
||||
### Découverte et affichage de toutes les stations
|
||||
|
||||
```rust
|
||||
use pmoradiofrance::RadioFranceClient;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let client = RadioFranceClient::new().await?;
|
||||
|
||||
println!("Découverte des stations...");
|
||||
let stations = client.discover_all_stations().await?;
|
||||
|
||||
println!("Trouvé {} stations :", stations.len());
|
||||
for station in &stations {
|
||||
println!(" - {} ({})", station.name, station.slug);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Récupération des métadonnées live
|
||||
|
||||
```rust
|
||||
use pmoradiofrance::RadioFranceClient;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let client = RadioFranceClient::new().await?;
|
||||
|
||||
// Station principale
|
||||
let fc_live = client.live_metadata("franceculture").await?;
|
||||
println!("France Culture : {} - {}",
|
||||
fc_live.now.first_line.title.unwrap_or_default(),
|
||||
fc_live.now.second_line.title.unwrap_or_default()
|
||||
);
|
||||
|
||||
// Webradio FIP
|
||||
let fip_rock_live = client.live_metadata("fip_rock").await?;
|
||||
if let Some(song) = &fip_rock_live.now.song {
|
||||
println!("FIP Rock : {} - {}",
|
||||
song.interpreters.join(", "),
|
||||
fip_rock_live.now.first_line.title.unwrap_or_default()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Polling avec délai intelligent
|
||||
|
||||
```rust
|
||||
use pmoradiofrance::RadioFranceClient;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let client = RadioFranceClient::new().await?;
|
||||
|
||||
loop {
|
||||
let fetched_at = SystemTime::now();
|
||||
let metadata = client.live_metadata("fip").await?;
|
||||
|
||||
println!("Now: {} - {}",
|
||||
metadata.now.second_line.title.unwrap_or_default(),
|
||||
metadata.now.first_line.title.unwrap_or_default()
|
||||
);
|
||||
|
||||
// Attendre le délai recommandé
|
||||
let delay = RadioFranceClient::adjusted_refresh_delay(&metadata, fetched_at);
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Récupération du flux HiFi
|
||||
|
||||
```rust
|
||||
use pmoradiofrance::RadioFranceClient;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let client = RadioFranceClient::new().await?;
|
||||
|
||||
let stream_url = client.get_hifi_stream_url("franceculture").await?;
|
||||
println!("Stream HiFi : {}", stream_url);
|
||||
// Exemple : https://icecast.radiofrance.fr/franceculture-hifi.aac?id=radiofrance
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Points d'attention
|
||||
|
||||
### 1. Rate limiting
|
||||
|
||||
- Pas de limite documentée observée
|
||||
- **Toujours** respecter `delayToRefresh` pour éviter les requêtes inutiles
|
||||
- Mettre en cache les résultats de `discover_all_stations()` (TTL : 24h recommandé)
|
||||
|
||||
### 2. User-Agent
|
||||
|
||||
Pour un projet open-source, utiliser un User-Agent identifiable :
|
||||
|
||||
```rust
|
||||
impl Default for ClientBuilder {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
user_agent: "PMOMusic/0.3.10 (https://github.com/votre-repo)".to_string(),
|
||||
// ...
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Gestion d'erreurs
|
||||
|
||||
Les APIs peuvent retourner :
|
||||
- **Données vides** (`null`) pour certains champs
|
||||
- **`song`** absent pour radios non-musicales (France Inter, France Info, France Culture)
|
||||
- **`localRadios`** uniquement pour France Bleu
|
||||
- **`visual_background`** parfois absent
|
||||
|
||||
Toujours utiliser `Option<>` et gérer les cas manquants.
|
||||
|
||||
### 4. Webradios et paramètre `?webradio=`
|
||||
|
||||
- **Stations principales** : `/franceinter/api/live?`
|
||||
- **Webradios FIP** : `/fip/api/live?webradio=fip_rock`
|
||||
- **Webradios France Musique** : `/francemusique/api/live?webradio=francemusique_jazz`
|
||||
- **Radios locales** : `/francebleu_alsace/api/live?` (slug direct, pas de paramètre)
|
||||
|
||||
### 5. Images Pikapi
|
||||
|
||||
Les URLs dans les réponses API utilisent parfois des chemins complets, parfois juste l'UUID :
|
||||
|
||||
```json
|
||||
"src": "https://www.radiofrance.fr/pikapi/images/436430f7-5b2b-43f2-9f3c-28f2ad6cae39"
|
||||
```
|
||||
|
||||
Toujours normaliser en extrayant l'UUID et en reconstruisant l'URL avec la taille souhaitée.
|
||||
|
||||
### 6. Scraping HTML
|
||||
|
||||
Le scraping HTML est **fragile** par nature. Recommandations :
|
||||
|
||||
- **Cache agressif** : Stocker les résultats de découverte (TTL 24h minimum)
|
||||
- **Fallback** : Avoir une liste de base hardcodée si le scraping échoue
|
||||
- **Validation optionnelle** : Tester chaque station découverte avec `/api/live?` avant de l'ajouter (peut être lent)
|
||||
- **Monitoring** : Logger les échecs de découverte
|
||||
|
||||
### 7. Performance
|
||||
|
||||
Pour découvrir ~70 stations :
|
||||
- **Scraping** : 1 homepage + 8 pages stations (une par station principale)
|
||||
- **Validation France Bleu** : 1 requête API
|
||||
- **Total** : ~10 requêtes HTTP
|
||||
|
||||
Temps estimé : 3-5 secondes avec timeout 30s (parallélisable pour réduire à ~1-2s).
|
||||
|
||||
### 8. Respect des CGU
|
||||
|
||||
- APIs publiques utilisées par le site officiel
|
||||
- Usage acceptable pour un projet open-source personnel/non-commercial
|
||||
- **Ne pas redistribuer** les flux audio commercialement
|
||||
- **Ne pas surcharger** les serveurs (respecter `delayToRefresh`)
|
||||
|
||||
---
|
||||
|
||||
## Prochaines étapes
|
||||
|
||||
1. **Implémenter `client.rs`** avec l'architecture décrite
|
||||
2. **Ajouter les tests** :
|
||||
- Tests unitaires pour parsing de slugs
|
||||
- Tests d'intégration pour découverte
|
||||
- Tests d'API live (avec captures VCR)
|
||||
3. **Intégrer avec `pmosource`** :
|
||||
- Implémenter le trait `MusicSource`
|
||||
- Gérer le cache via `SourceCacheManager`
|
||||
- Support FIFO pour radios musicales (FIP)
|
||||
4. **Documenter les limitations** :
|
||||
- Stations non accessibles
|
||||
- Cas d'erreur connus
|
||||
- Métriques de fiabilité
|
||||
|
||||
---
|
||||
|
||||
**Fin du rapport d'architecture client.rs**
|
||||
1193
Blackboard/ToThinkAbout/plant_webrenderer_server_side_streaming.md
Normal file
1193
Blackboard/ToThinkAbout/plant_webrenderer_server_side_streaming.md
Normal file
File diff suppressed because it is too large
Load Diff
138
Blackboard/ToThinkAbout/webrenderer.md
Normal file
138
Blackboard/ToThinkAbout/webrenderer.md
Normal file
@@ -0,0 +1,138 @@
|
||||
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.
|
||||
|
||||
- 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
|
||||
|
||||
Tu mettras une version du plan en Markdown dans le répertoire [@Architecture](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Architecture) .
|
||||
|
||||
---
|
||||
|
||||
## 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 │
|
||||
└───────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Étapes détaillées
|
||||
|
||||
### a) Création du renderer
|
||||
|
||||
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()),
|
||||
_ => (),
|
||||
}
|
||||
```
|
||||
|
||||
* Rust met à jour l’état du renderer (AVTransport/RenderingControl) pour le Control Point.
|
||||
|
||||
---
|
||||
|
||||
### d) Lecture côté navigateur
|
||||
|
||||
* Le navigateur reçoit la commande via WebSocket et pilote `<audio>` :
|
||||
|
||||
```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;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### e) Fermeture / cleanup
|
||||
|
||||
* Quand le navigateur se déconnecte :
|
||||
|
||||
* Rust supprime le renderer associé au token
|
||||
* Émet un **byebye virtuel** pour le Control Point (si nécessaire)
|
||||
* Libère toutes les ressources
|
||||
|
||||
---
|
||||
|
||||
## 3. Points clés
|
||||
|
||||
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>`
|
||||
|
||||
---
|
||||
|
||||
💡 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.
|
||||
202
Blackboard/ToThinkAbout/webrenderer_server_side_streaming.md
Normal file
202
Blackboard/ToThinkAbout/webrenderer_server_side_streaming.md
Normal file
@@ -0,0 +1,202 @@
|
||||
# WebRenderer : Streaming Audio Côté Serveur
|
||||
|
||||
## Problème actuel
|
||||
|
||||
Le webrenderer actuel délègue la lecture audio au navigateur : le serveur envoie une URL
|
||||
de fichier via WebSocket (`SetUri`), et le navigateur charge cette URL dans un élément
|
||||
`<audio>`. Cette approche a plusieurs limitations :
|
||||
|
||||
- Les URLs sont internes (IP locale + port) → inaccessibles depuis l'extérieur
|
||||
- Les fichiers sur partage Samba ont des chemins locaux → jamais accessibles au navigateur
|
||||
- La `base_url` doit être configurée statiquement → pas de solution propre local/externe
|
||||
|
||||
## Solution proposée : flux HTTP serveur
|
||||
|
||||
Le serveur génère un flux audio continu par instance de webrenderer, servi sur un endpoint
|
||||
HTTP dédié. Le navigateur n'écoute que ce flux — une URL fixe, toujours accessible.
|
||||
|
||||
```
|
||||
Avant : ControlPoint → SetAVTransportURI(url_interne) → WebSocket → Browser(<audio src=url_interne>)
|
||||
Après : ControlPoint → SetAVTransportURI(url_interne) → Serveur(ouvre+stream) → Browser(<audio src=/api/webrenderer/{id}/stream>)
|
||||
```
|
||||
|
||||
## Architecture cible
|
||||
|
||||
### Cycle de vie d'une instance
|
||||
|
||||
```
|
||||
1. Navigateur ouvre la page
|
||||
↓
|
||||
2. POST /api/webrenderer/register {instance_id, user_agent}
|
||||
↓
|
||||
3. Serveur crée le device UPnP + pipeline audio
|
||||
Annonce SSDP → ControlPoints découvrent le renderer
|
||||
Répond : { stream_url }
|
||||
↓
|
||||
4. Navigateur ouvre GET /api/webrenderer/{id}/stream (flux FLAC)
|
||||
↓
|
||||
5. ControlPoint → SetAVTransportURI + Play → pipeline démarre
|
||||
Navigateur écoute le flux FLAC en continu
|
||||
SSE global existant → métadonnées et état vers l'interface
|
||||
↓
|
||||
6. Navigateur ferme la page → flux FLAC se coupe
|
||||
Serveur détecte → SSDP byebye → pipeline stoppé
|
||||
Device UPnP retiré
|
||||
```
|
||||
|
||||
Le lecteur web est complètement invisible — l'interface est pilotée par le SSE global
|
||||
existant du ControlPoint. Le WebSocket est supprimé. Pas de SSE dédié au webrenderer.
|
||||
|
||||
### Endpoints HTTP
|
||||
|
||||
```
|
||||
POST /api/webrenderer/register
|
||||
Body: { instance_id, user_agent }
|
||||
Réponse: { stream_url }
|
||||
|
||||
GET /api/webrenderer/{id}/stream
|
||||
Content-Type: audio/flac
|
||||
Cache-Control: no-store, no-transform
|
||||
[Flux FLAC continu — déconnexion = fin de session]
|
||||
|
||||
DELETE /api/webrenderer/{id}
|
||||
Désenregistrement explicite (optionnel, fallback sur coupure du flux)
|
||||
```
|
||||
|
||||
La `stream_url` est une URL relative (`/api/webrenderer/{id}/stream`) — le navigateur
|
||||
la résout lui-même, toujours correcte en local et via proxy externe, sans reconstruction
|
||||
depuis les headers `X-Forwarded-*`.
|
||||
|
||||
### Composants nécessaires
|
||||
|
||||
#### 1. Pipeline audio par instance
|
||||
|
||||
Chaque instance possède :
|
||||
|
||||
- Un **`StreamingFlacSink`** — infrastructure existante dans `pmoaudio-ext`
|
||||
- Un **`StreamHandle`** — exposé via l'endpoint `/stream`
|
||||
- Un canal de contrôle **`PipelineControl`** — alimenté par les actions UPnP
|
||||
|
||||
Le pipeline est créé au `POST /register` et détruit à la coupure du flux FLAC.
|
||||
|
||||
#### 2. Enregistrement et création du device UPnP
|
||||
|
||||
```
|
||||
POST /api/webrenderer/register
|
||||
→ créer DeviceInstance UPnP (même factory qu'aujourd'hui)
|
||||
→ annoncer via SSDP (nouveau : aujourd'hui pas de SSDP pour le webrenderer)
|
||||
→ créer StreamingFlacSink + pipeline
|
||||
→ enregistrer dans le RendererRegistry
|
||||
→ retourner stream_url
|
||||
```
|
||||
|
||||
L'`instance_id` vient du `localStorage` du navigateur — stable entre les reloads,
|
||||
garantit que le même renderer UPnP est retrouvé à la reconnexion.
|
||||
|
||||
#### 3. Modification de `SetAVTransportURI`
|
||||
|
||||
Au lieu d'envoyer l'URL au navigateur, le handler UPnP :
|
||||
|
||||
1. Reçoit l'URI source (fichier cache, Samba, URL externe...)
|
||||
2. Envoie `PipelineControl::LoadUri(uri)` au pipeline de l'instance
|
||||
3. Le pipeline ouvre la source côté serveur et alimente le `StreamingFlacSink`
|
||||
4. Le navigateur reçoit un event SSE `state_changed: Transitioning` puis `Playing`
|
||||
|
||||
#### 4. Gestion des transitions (gapless)
|
||||
|
||||
Le `StreamingFlacSink` diffuse un flux FLAC continu. À la frontière de piste, le pipeline
|
||||
enchaîne les sources sans interruption du flux HTTP.
|
||||
|
||||
`SetNextAVTransportURI` → `PipelineControl::LoadNextUri(uri)` → pré-chargé dans le pipeline
|
||||
→ transition seamless, le navigateur ne recharge pas l'URL.
|
||||
|
||||
#### 5. Métadonnées et état
|
||||
|
||||
Tout passe par le SSE existant — titre, artiste, artwork, position, état de lecture.
|
||||
Pas de nouveau mécanisme nécessaire.
|
||||
|
||||
#### 6. Seek
|
||||
|
||||
Flux HTTP live → pas de Range requests.
|
||||
|
||||
Pour les fichiers (non-live) :
|
||||
- `PipelineControl::Seek(position_sec)` → pipeline repart depuis la nouvelle position
|
||||
- Légère interruption du flux FLAC (rebuffering navigateur ~1s) — acceptable
|
||||
|
||||
#### 7. Sources supportées
|
||||
|
||||
Le pipeline réutilise `pmoaudio-ext` et `pmoflac`. La seule source actuellement
|
||||
déclarée dans `pmoaudio-ext` fonctionne à partir d'une `pmoplaylist` — c'est le
|
||||
modèle à suivre pour construire dans `source_loader.rs` une source ad-hoc capable
|
||||
d'ouvrir des URIs arbitraires (URL HTTP externe, fichier local/Samba) qui ne passent
|
||||
pas par le cache.
|
||||
|
||||
### État partagé par instance
|
||||
|
||||
```rust
|
||||
pub struct WebRendererServerState {
|
||||
pub playback_state: PlaybackState,
|
||||
pub current_uri: Option<String>,
|
||||
pub volume: u16,
|
||||
pub mute: bool,
|
||||
pub stream_handle: SharedStreamHandle, // Handle vers le flux FLAC
|
||||
pub pipeline_tx: mpsc::Sender<PipelineControl>, // Contrôle du pipeline
|
||||
}
|
||||
|
||||
pub enum PipelineControl {
|
||||
LoadUri(String),
|
||||
LoadNextUri(String),
|
||||
Play,
|
||||
Pause,
|
||||
Stop,
|
||||
Seek(f64),
|
||||
SetVolume(u16),
|
||||
}
|
||||
```
|
||||
|
||||
## Fichiers à créer / modifier
|
||||
|
||||
### Nouveaux fichiers
|
||||
|
||||
| Fichier | Rôle |
|
||||
|---------|------|
|
||||
| `pmowebrenderer/src/stream.rs` | Handler HTTP du flux FLAC |
|
||||
| `pmowebrenderer/src/pipeline.rs` | Pipeline audio serveur par instance |
|
||||
| `pmowebrenderer/src/source_loader.rs` | Ouverture des sources (cache, HTTP, fichier local) |
|
||||
| `pmowebrenderer/src/register.rs` | Handler `POST /register` + `DELETE /{id}` |
|
||||
|
||||
### Fichiers à modifier
|
||||
|
||||
| Fichier | Modification |
|
||||
|---------|--------------|
|
||||
| `pmowebrenderer/src/state.rs` | Ajouter `stream_handle` et `pipeline_tx` |
|
||||
| `pmowebrenderer/src/handlers.rs` | `set_uri_handler` → `PipelineControl::LoadUri` |
|
||||
| `pmowebrenderer/src/websocket.rs` | **Supprimer** — remplacé par `register.rs` |
|
||||
| `pmowebrenderer/src/config.rs` | Enregistrer les nouvelles routes, supprimer WS |
|
||||
| `pmowebrenderer/src/messages.rs` | **Supprimer** — plus de WebSocket |
|
||||
| `pmowebrenderer/src/session.rs` | Adapter : session liée au flux FLAC, pas au WS |
|
||||
| `pmoapp/webapp/src/` | Remplacer WS par `POST /register` + `<audio src=stream_url>` |
|
||||
|
||||
## Infrastructure existante réutilisable
|
||||
|
||||
Tout le travail difficile est déjà fait :
|
||||
|
||||
- **`StreamingFlacSink`** (`pmoaudio-ext/src/sinks/streaming_flac_sink.rs`) — broadcast multi-clients, gestion backpressure, ICY metadata
|
||||
- **`timed_broadcast`** (`pmoaudio-ext/src/sinks/timed_broadcast.rs`) — diffusion multi-clients avec pacing
|
||||
- **`pmoflac`** — décodage/encodage FLAC temps réel
|
||||
- Pattern HTTP streaming (`pmomediaserver/src/paradise_streaming.rs`) — exemple complet à suivre
|
||||
|
||||
## Questions ouvertes
|
||||
|
||||
1. **Volume côté serveur ou navigateur** ? Actuellement le navigateur gère le volume.
|
||||
Côté serveur, on pourrait appliquer un gain DSP dans le pipeline.
|
||||
|
||||
3. **Plusieurs onglets simultanés** : chaque onglet a son propre pipeline et flux ?
|
||||
Ou partage d'un flux broadcast si même contenu ? (Complexe, probablement un pipeline par instance.)
|
||||
|
||||
4. **Reconnexion** : si le navigateur se reconnecte (reload), le pipeline continue de
|
||||
tourner ? Ou on le stoppe et recrée ? La `StreamHandle` permet plusieurs souscripteurs,
|
||||
donc la reconnexion peut se faire sans interruption.
|
||||
|
||||
5. **Latence** : le pipeline serveur introduit une latence (buffering + encodage).
|
||||
À mesurer et potentiellement configurer via `max_lead_seconds`.
|
||||
459
Blackboard/Todo/centraliser_base_url_axum_middleware.md
Normal file
459
Blackboard/Todo/centraliser_base_url_axum_middleware.md
Normal file
@@ -0,0 +1,459 @@
|
||||
** Ce travail devra être réalisé en suivant scrupuleusement les consignes listées dans le fichier [@Rules_optimal.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Rules_optimal.md) **
|
||||
|
||||
## Problème
|
||||
|
||||
Quand le site est accédé via une adresse externe (reverse proxy, ex: `orbis.petite-maison-orange.fr`),
|
||||
les URLs d'images et de ressources générées par le backend contiennent l'IP locale hardcodée
|
||||
(ex: `http://192.168.0.32:80/covers/image/...`). Ces URLs sont inaccessibles depuis l'extérieur.
|
||||
|
||||
## Cause racine
|
||||
|
||||
Il existe deux contextes distincts de construction d'URL dans pmomusic :
|
||||
|
||||
**A. Contexte UPnP / réseau local** : les URLs doivent être absolues avec l'IP locale, car les
|
||||
renderers UPnP accèdent directement aux ressources sur le réseau local.
|
||||
→ Elles utilisent correctement `PMO_SERVER_URL` / `covers_absolute_url_for()`.
|
||||
|
||||
**B. Contexte HTTP / frontend webapp** : les URLs retournées au navigateur doivent refléter l'hôte
|
||||
vu par le client (local ou via proxy). Elles utilisent actuellement aussi `PMO_SERVER_URL` —
|
||||
c'est le bug.
|
||||
|
||||
`pmoserver` dispose déjà de `request_base_url(headers)` (`pmoserver/src/server.rs:633`) qui lit
|
||||
`X-Forwarded-Proto` / `X-Forwarded-Host` / `Host` et retourne la base URL correcte par requête.
|
||||
Mais **aucun handler HTTP ne l'utilise** pour construire les URLs retournées au frontend.
|
||||
|
||||
## Solution : Middleware Axum BaseUrl
|
||||
|
||||
Ajouter un middleware Axum au niveau de la racine du routeur qui enrichit chaque requête d'une
|
||||
`Extension<BaseUrl>` calculée depuis les headers. Tous les handlers HTTP qui retournent des URLs
|
||||
au frontend extraient cette extension — **un seul point de calcul, zéro gestion ad hoc**.
|
||||
|
||||
### 1. Nouveau type `BaseUrl` dans `pmoserver/src/lib.rs`
|
||||
|
||||
```rust
|
||||
/// URL de base effective pour la requête courante.
|
||||
/// Calculée depuis X-Forwarded-Proto/Host ou Host header.
|
||||
/// Injectée par `base_url_layer` dans toutes les requêtes Axum.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BaseUrl(pub String);
|
||||
```
|
||||
|
||||
### 2. Middleware `base_url_layer` dans `pmoserver/src/lib.rs`
|
||||
|
||||
```rust
|
||||
/// Middleware Axum : injecte BaseUrl dans chaque requête.
|
||||
/// À appliquer sur le routeur racine via `.layer(base_url_layer())`.
|
||||
pub fn base_url_layer() -> axum::middleware::FromFnLayer<...> {
|
||||
axum::middleware::from_fn(|request: Request, next: Next| async move {
|
||||
// Fallback sur PMO_SERVER_URL (valeur de démarrage avec la vraie IP/port),
|
||||
// pas sur localhost:8080 hardcodé.
|
||||
let base = get_request_base_url(request.headers())
|
||||
.or_else(|| std::env::var("PMO_SERVER_URL").ok())
|
||||
.unwrap_or_else(|| {
|
||||
tracing::warn!("BaseUrl: aucun header Host/X-Forwarded-Host ni PMO_SERVER_URL — fallback localhost:8080");
|
||||
"http://localhost:8080".to_string()
|
||||
});
|
||||
let mut request = request;
|
||||
tracing::debug!("BaseUrl calculée : {}", base);
|
||||
request.extensions_mut().insert(BaseUrl(base));
|
||||
next.run(request).await
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Application du layer dans `pmoserver/src/server.rs`
|
||||
|
||||
Dans Axum, le dernier `.layer()` appliqué est le plus extérieur (exécuté en premier sur la
|
||||
requête entrante). Pour que `base_url_layer` voie les headers **après** tout layer de nettoyage,
|
||||
il doit être **intérieur** — donc appliqué **avant** dans le code :
|
||||
|
||||
```rust
|
||||
router
|
||||
.layer(header_clean_layer()) // extérieur → exécuté en premier, nettoie les headers
|
||||
.layer(base_url_layer()) // intérieur → voit les headers nettoyés
|
||||
```
|
||||
|
||||
Les endpoints UPnP (SSDP, description XML, control, event) ne doivent pas appeler
|
||||
`covers_absolute_url_for_upnp()` via `BaseUrl` — l'injection du middleware ne les affecte pas
|
||||
puisqu'ils n'extraient pas `Extension<BaseUrl>`.
|
||||
|
||||
Si des routes non-HTTP sont ajoutées ultérieurement (métriques internes, health checks sans
|
||||
contexte client), les isoler dans un sous-routeur dédié sans `base_url_layer()`.
|
||||
|
||||
### 4. Utilisation dans les handlers
|
||||
|
||||
Tous les handlers qui retournent des URLs au frontend ajoutent :
|
||||
|
||||
```rust
|
||||
Extension(base_url): Extension<BaseUrl>,
|
||||
```
|
||||
|
||||
Et utilisent `base_url.url_for(&pmocovers::covers_route_for(pk, None))` à la place de
|
||||
`covers_absolute_url_for()` (voir section 5 pour le pattern complet).
|
||||
|
||||
Handlers REST concernés (liste non exhaustive) :
|
||||
- `pmocontrol/src/pmoserver_ext.rs` : `get_renderer_full_snapshot` (album_art_uri dans snapshot)
|
||||
- `pmocontrol/src/pmoserver_ext.rs` : handler browse (ContainerEntry.album_art_uri)
|
||||
- `pmoradiofrance/src/api_rest.rs` : endpoints playlist/metadata
|
||||
- `pmoplaylist/src/handle/read.rs` : album art dans les réponses playlist
|
||||
|
||||
**Handlers SSE** (`pmocontrol/src/sse.rs`) : cas particulier. Le stream SSE est long-lived —
|
||||
après le `stream!` block, on n'est plus dans le contexte du handler Axum. `BaseUrl` doit être
|
||||
clonée dans une variable locale **avant** le `stream!`, puis `move`-ée dans la closure :
|
||||
|
||||
```rust
|
||||
pub async fn renderer_events_sse(
|
||||
State(control_point): State<Arc<ControlPoint>>,
|
||||
Extension(base_url): Extension<BaseUrl>, // ← extraite à la connexion
|
||||
) -> impl IntoResponse {
|
||||
let base_url = base_url.clone(); // clone avant le stream! pour le move
|
||||
// ...
|
||||
let stream = stream! {
|
||||
while let Some(event) = rx_tokio.recv().await {
|
||||
// base_url est disponible ici par move
|
||||
let payload = renderer_event_to_payload(event, &base_url);
|
||||
yield Ok(Event::default()...);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
### 5. Méthode `url_for` sur `BaseUrl` + fonctions `route_for` dans chaque crate
|
||||
|
||||
La combinaison `base_url + route` est identique pour tous les types de ressources. Elle est
|
||||
factorisée en une méthode sur `BaseUrl` dans `pmoserver/src/lib.rs` :
|
||||
|
||||
```rust
|
||||
impl BaseUrl {
|
||||
/// Construit une URL absolue en combinant la base URL de la requête avec une route relative.
|
||||
/// Usage : base_url.url_for(&pmocovers::covers_route_for(pk, None))
|
||||
pub fn url_for(&self, route: &str) -> String {
|
||||
debug_assert!(route.starts_with('/'), "route must start with '/'");
|
||||
format!("{}{}", self.0.trim_end_matches('/'), route)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Chaque crate spécialisée expose uniquement sa **route** (chemin relatif), pas l'URL complète :
|
||||
|
||||
**`pmocovers/src/lib.rs`** — déplacer depuis `pmocache` :
|
||||
```rust
|
||||
/// Route relative d'une cover : `/covers/image/{pk}[/{param}]`
|
||||
pub fn covers_route_for(pk: &str, param: Option<&str>) -> String { ... }
|
||||
```
|
||||
|
||||
**`pmoaudiocache/src/lib.rs`** :
|
||||
```rust
|
||||
/// Route relative d'un fichier audio : `/audio/flac/{pk}`
|
||||
pub fn audio_route_for(pk: &str) -> String {
|
||||
format!("/audio/flac/{}", pk)
|
||||
}
|
||||
```
|
||||
|
||||
Usage dans les handlers :
|
||||
```rust
|
||||
base_url.url_for(&pmocovers::covers_route_for(pk, None))
|
||||
base_url.url_for(&pmoaudiocache::audio_route_for(pk))
|
||||
```
|
||||
|
||||
### 6. Renommage de `covers_absolute_url_for` → `covers_absolute_url_for_upnp`
|
||||
|
||||
Pour rendre le contexte d'usage explicite et décourager l'appel depuis les handlers HTTP,
|
||||
renommer dans `pmocache/src/lib.rs` :
|
||||
|
||||
```rust
|
||||
// Ancien nom — marqué deprecated pour faciliter la migration (warnings à la compilation)
|
||||
#[deprecated(note = "Utiliser covers_absolute_url_for_upnp() dans les contextes UPnP uniquement")]
|
||||
pub fn covers_absolute_url_for(pk: &str, param: Option<&str>) -> String { ... }
|
||||
|
||||
// Nouveau nom — usage UPnP uniquement
|
||||
pub fn covers_absolute_url_for_upnp(pk: &str, param: Option<&str>) -> String { ... }
|
||||
```
|
||||
|
||||
Mettre à jour tous les appels existants (contextes UPnP/DIDL uniquement) via un grep :
|
||||
`grep -rn "covers_absolute_url_for" src/ --include="*.rs"`
|
||||
|
||||
À terme, `covers_route_for` et `covers_absolute_url_for_upnp` devraient migrer de `pmocache`
|
||||
vers `pmocovers`, mais ce n'est pas le périmètre de ce ticket.
|
||||
|
||||
## URLs dans les documents DIDL et SSE
|
||||
|
||||
Les documents DIDL bruts (`<res>`, `<upnp:albumArtURI>`) ne transitent jamais vers le frontend —
|
||||
pmocontrol les parse côté serveur et n'envoie que des champs extraits (JSON) via REST et SSE.
|
||||
Il n'y a donc pas de "rebasage XML" : les champs extraits (`album_art_uri`, etc.) passent tous
|
||||
par des handlers qui ont accès à `BaseUrl`.
|
||||
|
||||
**SSE est per-client** : chaque connexion SSE crée son propre receiver (`subscribe_events()`).
|
||||
La `BaseUrl` est figée à l'établissement de la connexion et ne sera pas mise à jour si le client
|
||||
change de réseau en cours de stream — comportement attendu et documenté.
|
||||
Il n'y a ni canal partagé, ni duplication LAN/WAN. Le handler SSE capture `Extension<BaseUrl>`
|
||||
à l'établissement de la connexion et applique `base_url.url_for()` à toutes les URLs des événements
|
||||
émis vers ce client.
|
||||
|
||||
Les DIDL servis directement aux renderers UPnP (hors HTTP webapp) gardent l'IP locale — c'est
|
||||
correct, les renderers sont sur le réseau local.
|
||||
|
||||
## Audit préalable à l'implémentation
|
||||
|
||||
Avant de modifier les handlers, faire un audit exhaustif de tous les endroits qui construisent
|
||||
des URLs absolues dans des réponses JSON au frontend :
|
||||
|
||||
```bash
|
||||
# Appels directs aux fonctions URL connues
|
||||
grep -rn "covers_absolute_url_for\|audio/flac\|cache/audio" src/ --include="*.rs"
|
||||
|
||||
# Constructions format! utilisant PMO_SERVER_URL ou des littéraux http://
|
||||
grep -rn "PMO_SERVER_URL\|format!.*base_url\|format!.*server_url" src/ --include="*.rs"
|
||||
grep -rn 'format!.*"http' src/ --include="*.rs"
|
||||
```
|
||||
|
||||
Note : les PKs de covers et audio sont des hashes hex (`[0-9a-f]+`) — ils ne peuvent pas
|
||||
contenir de caractères spéciaux nécessitant un encodage URL. La concaténation `format!` est
|
||||
donc sûre ; pas besoin de `url::Url::join`.
|
||||
|
||||
## Tests à écrire
|
||||
|
||||
- **Middleware** : `BaseUrl` correctement extraite depuis `X-Forwarded-Host`, `Host`, et en
|
||||
leur absence (fallback sur `PMO_SERVER_URL`)
|
||||
- **`url_for`** : assertion que toutes les routes commencent par `/` ; pas de double slash ;
|
||||
trailing slash sur la base géré par `trim_end_matches`
|
||||
- **Handlers REST** : `album_art_uri` rebased dans `FullRendererSnapshot` et `BrowseResponse`
|
||||
- **SSE** : URLs rebased dans les événements `TrackChanged`
|
||||
- **UPnP** : vérifier que les URLs servies aux renderers UPnP restent en IP locale (non affectées
|
||||
par `BaseUrl`)
|
||||
- **Intégration** : appeler **chaque endpoint frontend** avec un client HTTP de test pour
|
||||
vérifier (a) qu'aucune panique ne se produit (middleware bien appliqué) et (b) que les URLs
|
||||
produites utilisent l'hôte du header `X-Forwarded-Host` simulé et non l'IP locale.
|
||||
La panique sur `Extension<BaseUrl>` manquante est un comportement voulu — elle doit être
|
||||
détectée par ces tests et non silencieusement masquée par un `Option`.
|
||||
|
||||
## Sécurité : headers X-Forwarded-*
|
||||
|
||||
Les headers `X-Forwarded-Proto` / `X-Forwarded-Host` peuvent être forgés par n'importe quel client
|
||||
si le reverse proxy ne les filtre pas. Dans le contexte de déploiement de pmomusic (usage domestique,
|
||||
proxy Nginx/Caddy unique), le risque est faible et hors périmètre de ce ticket.
|
||||
|
||||
À surveiller si le déploiement évolue : restreindre la lecture de ces headers aux requêtes venant
|
||||
de l'IP du proxy (liste blanche de proxies de confiance côté Axum ou côté proxy).
|
||||
|
||||
## Périmètre : ce qui ne change PAS
|
||||
|
||||
- `covers_absolute_url_for()` dans `pmocache` : conservée pour les contextes UPnP
|
||||
- `PMO_SERVER_URL` env var : conservée pour UPnP et les processus non-HTTP
|
||||
- URLs dans les DIDL servis aux renderers UPnP : inchangées (doivent rester en IP locale)
|
||||
- `server_base_url` passé aux sources (RadioFrance, RadioParadise, Qobuz) : inchangé
|
||||
(ces sources construisent des URLs pour les renderers réseau)
|
||||
|
||||
## Plan d'exécution
|
||||
|
||||
### Corrections d'audit préalables
|
||||
|
||||
Divergences entre le document et le code réel :
|
||||
|
||||
- **Route audio** : `/audio/tracks/{pk}` (PAS `/audio/flac/{pk}`)
|
||||
- **`covers_route_for`** existe déjà dans `pmocache/src/lib.rs:149` — à copier vers `pmocovers`
|
||||
- **`album_art_uri`** dans les handlers est propagé depuis des caches amont ; le point de
|
||||
construction réel est `pmoradiofrance/src/metadata_cache.rs:263` (tâche de fond, pas un handler)
|
||||
- **`pmoqobuz/src/source.rs:1943`** construit des URLs audio avec `self.base_url` → contexte
|
||||
UPnP/renderer, hors périmètre de ce ticket
|
||||
|
||||
### Étape 0 — Audit exhaustif (avant tout changement)
|
||||
|
||||
```bash
|
||||
grep -rn "covers_absolute_url_for\|audio/tracks\|cache/audio" --include="*.rs"
|
||||
grep -rn "PMO_SERVER_URL\|format!.*base_url\|format!.*server_url" --include="*.rs"
|
||||
grep -rn 'format!.*"http' --include="*.rs"
|
||||
```
|
||||
|
||||
Identifier tous les call sites dans les contextes HTTP (handlers, caches de métadonnées servant
|
||||
le frontend). Distinguer des contextes UPnP/renderer (hors périmètre).
|
||||
|
||||
### Étape 1 — `pmoserver/src/lib.rs` : ajouter `BaseUrl` + `base_url_layer`
|
||||
|
||||
`get_request_base_url(headers)` existe déjà à la ligne 199. Ajouter :
|
||||
|
||||
```rust
|
||||
use axum::{extract::Request, middleware::Next, response::Response};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BaseUrl(pub String);
|
||||
|
||||
impl BaseUrl {
|
||||
pub fn url_for(&self, route: &str) -> String {
|
||||
debug_assert!(route.starts_with('/'), "route must start with '/'");
|
||||
format!("{}{}", self.0.trim_end_matches('/'), route)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn base_url_middleware(mut request: Request, next: Next) -> Response {
|
||||
let base = get_request_base_url(request.headers())
|
||||
.unwrap_or_else(|| {
|
||||
std::env::var("PMO_SERVER_URL").unwrap_or_else(|_| {
|
||||
tracing::warn!(
|
||||
"BaseUrl: aucun header Host/X-Forwarded-Host ni PMO_SERVER_URL \
|
||||
— fallback localhost:8080"
|
||||
);
|
||||
"http://localhost:8080".to_string()
|
||||
})
|
||||
});
|
||||
tracing::debug!("BaseUrl calculée : {}", base);
|
||||
request.extensions_mut().insert(BaseUrl(base));
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
pub fn base_url_layer() -> axum::middleware::FromFnLayer<...> {
|
||||
axum::middleware::from_fn(base_url_middleware)
|
||||
}
|
||||
```
|
||||
|
||||
### Étape 2 — `pmoserver/src/server.rs` : appliquer le layer
|
||||
|
||||
Trouver la construction du routeur principal. Ajouter `base_url_layer()` avant les layers
|
||||
existants (= intérieur dans la pile Tower) :
|
||||
|
||||
```rust
|
||||
router
|
||||
.layer(some_existing_layer()) // extérieur → exécuté en premier
|
||||
.layer(base_url_layer()) // intérieur → voit les headers après nettoyage
|
||||
```
|
||||
|
||||
### Étape 3 — `pmocovers/src/lib.rs` : ajouter `covers_route_for`
|
||||
|
||||
Copier depuis `pmocache/src/lib.rs:149` :
|
||||
|
||||
```rust
|
||||
/// Route relative d'une cover : `/covers/image/{pk}[/{param}]`
|
||||
pub fn covers_route_for(pk: &str, param: Option<&str>) -> String {
|
||||
if let Some(p) = param {
|
||||
format!("/covers/image/{}/{}", pk, p)
|
||||
} else {
|
||||
format!("/covers/image/{}", pk)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Étape 4 — `pmoaudiocache/src/lib.rs` : ajouter `audio_route_for`
|
||||
|
||||
```rust
|
||||
/// Route relative d'un fichier audio : `/audio/tracks/{pk}`
|
||||
pub fn audio_route_for(pk: &str) -> String {
|
||||
format!("/audio/tracks/{}", pk)
|
||||
}
|
||||
```
|
||||
|
||||
### Étape 5 — `pmocache/src/lib.rs` : renommer `covers_absolute_url_for`
|
||||
|
||||
```rust
|
||||
#[deprecated(note = "Utiliser covers_absolute_url_for_upnp() dans les contextes UPnP uniquement")]
|
||||
pub fn covers_absolute_url_for(pk: &str, param: Option<&str>) -> String {
|
||||
covers_absolute_url_for_upnp(pk, param)
|
||||
}
|
||||
|
||||
pub fn covers_absolute_url_for_upnp(pk: &str, param: Option<&str>) -> String {
|
||||
let base = std::env::var("PMO_SERVER_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:8080".to_string());
|
||||
format!("{}{}", base.trim_end_matches('/'), covers_route_for(pk, param))
|
||||
}
|
||||
```
|
||||
|
||||
Mettre à jour l'appel UPnP connu : `pmoupnp/src/cache_registry.rs:57`
|
||||
→ `covers_absolute_url_for(` → `covers_absolute_url_for_upnp(`
|
||||
|
||||
### Étape 6 — `pmoradiofrance/src/metadata_cache.rs:263` : stocker la route, pas l'URL
|
||||
|
||||
**Contexte** : tâche de fond — ne peut pas extraire `Extension<BaseUrl>`.
|
||||
**Principe** : stocker la route relative dans le cache ; le handler rebase au moment de la réponse.
|
||||
|
||||
```rust
|
||||
// Avant :
|
||||
let public_url = pmocache::covers_absolute_url_for(&pk, None);
|
||||
|
||||
// Après :
|
||||
let public_url = pmocovers::covers_route_for(&pk, None); // route relative
|
||||
```
|
||||
|
||||
Le handler REST dans `pmoradiofrance/src/api_rest.rs` qui retourne ces métadonnées doit :
|
||||
1. Ajouter `Extension(base_url): Extension<BaseUrl>` à sa signature
|
||||
2. Construire l'URL : `base_url.url_for(&metadata.album_art_uri)`
|
||||
|
||||
Lire `api_rest.rs` pour identifier le handler exact qui inclut `album_art_uri` dans la réponse.
|
||||
|
||||
### Étape 7 — Handlers REST `pmocontrol/src/pmoserver_ext.rs`
|
||||
|
||||
`get_renderer_full_snapshot` (l.170) et `browse_container` (l.2080) propagent `album_art_uri`
|
||||
depuis les résultats DIDL des media servers UPnP — ces URLs pointent vers l'IP du media server,
|
||||
pas de pmomusic.
|
||||
|
||||
**Action** : après l'audit, vérifier si ces URLs passent par `covers_absolute_url_for`.
|
||||
Si oui → même traitement qu'étape 6. Sinon → pas de changement.
|
||||
|
||||
### Étape 8 — Handlers SSE `pmocontrol/src/sse.rs`
|
||||
|
||||
Pour `renderer_events_sse`, `media_server_events_sse`, `all_events_sse` :
|
||||
|
||||
```rust
|
||||
pub async fn renderer_events_sse(
|
||||
State(control_point): State<Arc<ControlPoint>>,
|
||||
Extension(base_url): Extension<BaseUrl>, // ← ajouter
|
||||
) -> impl IntoResponse {
|
||||
let base_url = base_url.clone(); // avant le stream!
|
||||
let stream = stream! {
|
||||
while let Some(event) = rx.recv().await {
|
||||
// base_url.url_for(...) pour les URLs dans les événements
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Vérifier si les événements SSE contiennent des `album_art_uri` construits avec
|
||||
`covers_absolute_url_for` ou propagés depuis le cache.
|
||||
Si propagation → même traitement qu'étape 6.
|
||||
|
||||
### Étape 9 — Vérification finale
|
||||
|
||||
```bash
|
||||
# Ne doit retourner aucun appel dans les handlers HTTP
|
||||
grep -rn "covers_absolute_url_for[^_]" --include="*.rs"
|
||||
|
||||
# Ne doit retourner aucun résultat dans les handlers HTTP
|
||||
grep -rn "PMO_SERVER_URL" --include="*.rs" | grep -v "pmocache\|pmoserver\|test"
|
||||
|
||||
# Warnings deprecated
|
||||
cargo build 2>&1 | grep "deprecated"
|
||||
```
|
||||
|
||||
### Étape 10 — Tests
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn url_for_combines_base_and_route() {
|
||||
let b = BaseUrl("https://example.com".to_string());
|
||||
assert_eq!(b.url_for("/covers/image/abc"), "https://example.com/covers/image/abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_for_trims_trailing_slash() {
|
||||
let b = BaseUrl("https://example.com/".to_string());
|
||||
assert_eq!(b.url_for("/covers/image/abc"), "https://example.com/covers/image/abc");
|
||||
}
|
||||
// + tests middleware X-Forwarded-Host, fallback PMO_SERVER_URL, fallback localhost
|
||||
// + test intégration : chaque endpoint frontend avec X-Forwarded-Host simulé
|
||||
```
|
||||
|
||||
### Ordre d'exécution
|
||||
|
||||
1. Étape 0 — audit (confirmer la liste des call sites)
|
||||
2. Étapes 3, 4 — ajouter `covers_route_for` / `audio_route_for` (sans breaking change)
|
||||
3. Étape 5 — renommer + `#[deprecated]` (les warnings guident la suite)
|
||||
4. Étape 1 — `BaseUrl` + `base_url_layer` dans `pmoserver`
|
||||
5. Étape 2 — appliquer le layer dans `server.rs`
|
||||
6. Étapes 6, 7, 8 — migrer les handlers (guidés par les warnings de compilation)
|
||||
7. Étapes 9, 10 — vérification + tests
|
||||
|
||||
## Règle après cette modification
|
||||
|
||||
**Interdit** : appeler `covers_absolute_url_for_upnp()`, lire `PMO_SERVER_URL`, ou utiliser
|
||||
`format!("{}/audio/flac/{}", base_url, pk)` dans un handler HTTP qui retourne du JSON au frontend.
|
||||
|
||||
**Obligatoire** : extraire `Extension<BaseUrl>` et utiliser :
|
||||
- `base_url.url_for(&pmocovers::covers_route_for(pk, None))` pour les images
|
||||
- `base_url.url_for(&pmoaudiocache::audio_route_for(pk))` pour les fichiers audio
|
||||
20
Blackboard/Todo/patch_position_info_for_stream.md
Normal file
20
Blackboard/Todo/patch_position_info_for_stream.md
Normal file
@@ -0,0 +1,20 @@
|
||||
** Ce travail devra être réalisé en suivant scrupuleusement les consignes listées dans le fichier [@Rules_optimal.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Rules_optimal.md) **
|
||||
|
||||
Cette modification cible la cratte pmocontrol uniquement
|
||||
|
||||
Les musiques Renderer, Disposent maintenant d'une méthode leur permettant de savoir s'ils sont en train de diffuser une webradio via leur méthode is_playing_a_stream.
|
||||
|
||||
Il faut donc que dans la méthode poll_and_emit_changes On fait ce qui est nécessaire pour envoyer des données de position et de durée de track corrigée si l'on a is_playing_a_stream à vrai.
|
||||
|
||||
Si is_playing_a_stream à vrai:
|
||||
- Maintenir à jour la valeur `track_start_time` de la classe
|
||||
MusicRenderer En la mettant égale à now de metadata.
|
||||
- Loguer cet événement au niveau info.
|
||||
- Extraire la durée du morceau depuis les métadonnées
|
||||
fournies par la structure de position.
|
||||
- Si la durée est disponible:
|
||||
- Utilisez cette donnée pour la pousser sur le bus des événements.
|
||||
- calculer la position dans le flux comme la différence
|
||||
entre now et track_start_time.
|
||||
- Sinon: Envoyer zéro pour la position et none pour la duration.
|
||||
- Sinon, transmettre les données fournies comme actuellement.
|
||||
498
Cargo.lock
generated
498
Cargo.lock
generated
@@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "PMOMusic"
|
||||
version = "0.3.5"
|
||||
version = "0.3.32"
|
||||
dependencies = [
|
||||
"axum 0.8.7",
|
||||
"console-subscriber",
|
||||
@@ -19,6 +19,7 @@ dependencies = [
|
||||
"pmoserver",
|
||||
"pmosource",
|
||||
"pmoupnp",
|
||||
"pmowebrenderer",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -516,6 +517,7 @@ dependencies = [
|
||||
"tower 0.5.2",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -526,6 +528,7 @@ checksum = "5b098575ebe77cb6d14fc7f32749631a6e44edbef6b796f89b020e99ba20d425"
|
||||
dependencies = [
|
||||
"axum-core 0.5.5",
|
||||
"axum-macros",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"form_urlencoded",
|
||||
"futures-util",
|
||||
@@ -544,8 +547,10 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sha1",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tower 0.5.2",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
@@ -570,6 +575,7 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -605,6 +611,30 @@ dependencies = [
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-extra"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c794b30c904f0a1c2fb7740f7df7f7972dfaa14ef6f57cb6178dc63e5dca2f04"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"axum-core 0.4.5",
|
||||
"bytes",
|
||||
"fastrand",
|
||||
"futures-util",
|
||||
"headers",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"multer",
|
||||
"pin-project-lite",
|
||||
"serde",
|
||||
"tower 0.5.2",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-macros"
|
||||
version = "0.5.0"
|
||||
@@ -671,7 +701,7 @@ dependencies = [
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"serde",
|
||||
"spin",
|
||||
"spin 0.10.0",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
]
|
||||
@@ -693,7 +723,7 @@ dependencies = [
|
||||
"bevy_ptr",
|
||||
"bevy_reflect_derive",
|
||||
"bevy_utils",
|
||||
"derive_more",
|
||||
"derive_more 2.0.1",
|
||||
"disqualified",
|
||||
"downcast-rs",
|
||||
"erased-serde",
|
||||
@@ -1276,6 +1306,29 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssparser"
|
||||
version = "0.34.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7c66d1cd8ed61bf80b38432613a7a2f09401ab8d0501110655f8b341484a3e3"
|
||||
dependencies = [
|
||||
"cssparser-macros",
|
||||
"dtoa-short",
|
||||
"itoa",
|
||||
"phf",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssparser-macros"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctr"
|
||||
version = "0.9.2"
|
||||
@@ -1335,6 +1388,17 @@ dependencies = [
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "0.99.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.0.1"
|
||||
@@ -1438,12 +1502,33 @@ version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc"
|
||||
|
||||
[[package]]
|
||||
name = "dtoa"
|
||||
version = "1.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
|
||||
|
||||
[[package]]
|
||||
name = "dtoa-short"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
|
||||
dependencies = [
|
||||
"dtoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dunce"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "ego-tree"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2972feb8dffe7bc8c5463b1dacda1b0dfbed3710e50f977d965429692d74cd8"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
@@ -1615,6 +1700,24 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fdk-aac"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7bb67e142688083cb9afb63f2424203fc98c4e7afb494bf912b60b55513b177e"
|
||||
dependencies = [
|
||||
"fdk-aac-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fdk-aac-sys"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24516d2611506d5cb1833555adc75f6baf9fe2706b9c13e6fc33a6b22c51ca83"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.5"
|
||||
@@ -1708,6 +1811,16 @@ version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[package]]
|
||||
name = "futf"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
|
||||
dependencies = [
|
||||
"mac",
|
||||
"new_debug_unreachable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.31"
|
||||
@@ -1810,6 +1923,15 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fxhash"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gcc"
|
||||
version = "0.3.55"
|
||||
@@ -1848,6 +1970,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getopts"
|
||||
version = "0.2.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
|
||||
dependencies = [
|
||||
"unicode-width 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.16"
|
||||
@@ -2006,6 +2137,30 @@ 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"
|
||||
@@ -2044,6 +2199,18 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.29.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c"
|
||||
dependencies = [
|
||||
"log",
|
||||
"mac",
|
||||
"markup5ever",
|
||||
"match_token",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "htmlescape"
|
||||
version = "0.3.1"
|
||||
@@ -2713,6 +2880,12 @@ dependencies = [
|
||||
"hashbrown 0.15.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mac"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mach2"
|
||||
version = "0.4.3"
|
||||
@@ -2722,6 +2895,31 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18"
|
||||
dependencies = [
|
||||
"log",
|
||||
"phf",
|
||||
"phf_codegen",
|
||||
"string_cache",
|
||||
"string_cache_codegen",
|
||||
"tendril",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "match_token"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
@@ -2915,6 +3113,23 @@ 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"
|
||||
@@ -3528,6 +3743,58 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
||||
dependencies = [
|
||||
"phf_macros",
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_codegen"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
"rand 0.8.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_macros"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.10"
|
||||
@@ -3581,6 +3848,7 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
name = "pmoapp"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"pmoserver",
|
||||
"rust-embed",
|
||||
]
|
||||
@@ -3614,6 +3882,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"futures",
|
||||
"pmoaudio",
|
||||
"pmoaudiocache",
|
||||
"pmocache",
|
||||
@@ -3622,6 +3891,7 @@ dependencies = [
|
||||
"pmometadata",
|
||||
"pmoplaylist",
|
||||
"rand 0.8.5",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
@@ -3726,9 +3996,11 @@ dependencies = [
|
||||
"futures-util",
|
||||
"mdns",
|
||||
"percent-encoding",
|
||||
"pmocovers",
|
||||
"pmodidl",
|
||||
"pmoserver",
|
||||
"pmoupnp",
|
||||
"pmoutils",
|
||||
"quick-xml",
|
||||
"rand 0.9.2",
|
||||
"ratatui",
|
||||
@@ -3745,6 +4017,8 @@ dependencies = [
|
||||
"tracing-log 0.1.4",
|
||||
"tracing-subscriber",
|
||||
"ureq",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"utoipa",
|
||||
"xmltree 0.11.0",
|
||||
]
|
||||
@@ -3767,6 +4041,7 @@ dependencies = [
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
"utoipa",
|
||||
"webp",
|
||||
]
|
||||
@@ -3777,7 +4052,6 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"bevy_reflect",
|
||||
"bevy_reflect_derive",
|
||||
"pmoutils",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"utoipa",
|
||||
@@ -3791,6 +4065,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"claxon",
|
||||
"fdk-aac",
|
||||
"lewton",
|
||||
"libc",
|
||||
"libflac-sys",
|
||||
@@ -3832,6 +4107,7 @@ dependencies = [
|
||||
"pmoparadise",
|
||||
"pmoplaylist",
|
||||
"pmoqobuz",
|
||||
"pmoradiofrance",
|
||||
"pmoserver",
|
||||
"pmosource",
|
||||
"pmoupnp",
|
||||
@@ -3876,6 +4152,7 @@ dependencies = [
|
||||
"pmoaudio",
|
||||
"pmoaudio-ext",
|
||||
"pmoaudiocache",
|
||||
"pmocache",
|
||||
"pmoconfig",
|
||||
"pmocovers",
|
||||
"pmoflac",
|
||||
@@ -3963,6 +4240,38 @@ dependencies = [
|
||||
"utoipa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pmoradiofrance"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"axum 0.8.7",
|
||||
"chrono",
|
||||
"futures",
|
||||
"pmoaudiocache",
|
||||
"pmocache",
|
||||
"pmoconfig",
|
||||
"pmocovers",
|
||||
"pmodidl",
|
||||
"pmoplaylist",
|
||||
"pmoserver",
|
||||
"pmosource",
|
||||
"pmoupnp",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"scraper",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tokio-test",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pmoserver"
|
||||
version = "0.1.0"
|
||||
@@ -3982,6 +4291,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"tower 0.5.2",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"utoipa",
|
||||
@@ -4057,15 +4367,47 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pmoutils"
|
||||
version = "0.1.0"
|
||||
version = "0.1.2"
|
||||
source = "sparse+https://gargoton.petite-maison-orange.fr/api/packages/pmo/cargo/"
|
||||
checksum = "9ed68fb06ffdd1a30fc7792d883dcd6d61ad7705c3a744f54a0a5bcc341fc65e"
|
||||
dependencies = [
|
||||
"get_if_addrs",
|
||||
"netstat2",
|
||||
"os_info",
|
||||
"quick-xml",
|
||||
"sysinfo",
|
||||
"users",
|
||||
"xmltree 0.10.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pmowebrenderer"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum 0.8.7",
|
||||
"axum-extra",
|
||||
"bytes",
|
||||
"futures",
|
||||
"parking_lot",
|
||||
"pmoaudio",
|
||||
"pmoaudio-ext",
|
||||
"pmoconfig",
|
||||
"pmocontrol",
|
||||
"pmodidl",
|
||||
"pmoflac",
|
||||
"pmomediarenderer",
|
||||
"pmometadata",
|
||||
"pmoserver",
|
||||
"pmoupnp",
|
||||
"pmoutils",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4146,6 +4488,12 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "precomputed-hash"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
@@ -4447,7 +4795,7 @@ dependencies = [
|
||||
"strum",
|
||||
"unicode-segmentation",
|
||||
"unicode-truncate",
|
||||
"unicode-width",
|
||||
"unicode-width 0.1.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4836,6 +5184,21 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "scraper"
|
||||
version = "0.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc3d051b884f40e309de6c149734eab57aa8cc1347992710dc80bcc1c2194c15"
|
||||
dependencies = [
|
||||
"cssparser",
|
||||
"ego-tree",
|
||||
"getopts",
|
||||
"html5ever",
|
||||
"precomputed-hash",
|
||||
"selectors",
|
||||
"tendril",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "2.11.1"
|
||||
@@ -4859,6 +5222,25 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.26.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd568a4c9bb598e291a08244a5c1f5a8a6650bee243b5b0f8dbb3d9cc1d87fe8"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"cssparser",
|
||||
"derive_more 0.99.20",
|
||||
"fxhash",
|
||||
"log",
|
||||
"new_debug_unreachable",
|
||||
"phf",
|
||||
"phf_codegen",
|
||||
"precomputed-hash",
|
||||
"servo_arc",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.27"
|
||||
@@ -4959,6 +5341,15 @@ dependencies = [
|
||||
"unsafe-libyaml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "servo_arc"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
@@ -5047,6 +5438,12 @@ version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.11"
|
||||
@@ -5127,6 +5524,12 @@ 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"
|
||||
@@ -5158,6 +5561,31 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
|
||||
dependencies = [
|
||||
"new_debug_unreachable",
|
||||
"parking_lot",
|
||||
"phf_shared",
|
||||
"precomputed-hash",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "string_cache_codegen"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.26.3"
|
||||
@@ -5509,6 +5937,17 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tendril"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
|
||||
dependencies = [
|
||||
"futf",
|
||||
"mac",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
@@ -5702,6 +6141,18 @@ dependencies = [
|
||||
"tokio-stream",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"tokio",
|
||||
"tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.17"
|
||||
@@ -5964,6 +6415,23 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.2",
|
||||
"sha1",
|
||||
"thiserror 2.0.17",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
@@ -6002,7 +6470,7 @@ checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
|
||||
dependencies = [
|
||||
"itertools 0.13.0",
|
||||
"unicode-segmentation",
|
||||
"unicode-width",
|
||||
"unicode-width 0.1.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6011,6 +6479,12 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
@@ -6080,6 +6554,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urlencoding"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "users"
|
||||
version = "0.11.0"
|
||||
|
||||
15
Cargo.toml
15
Cargo.toml
@@ -4,9 +4,9 @@ members = [
|
||||
"PMOMusic",
|
||||
"pmoupnp",
|
||||
"pmomediarenderer",
|
||||
"pmowebrenderer",
|
||||
"pmomediaserver",
|
||||
"pmoconfig",
|
||||
"pmoutils",
|
||||
"pmodidl",
|
||||
"pmoserver",
|
||||
"pmoapp",
|
||||
@@ -14,12 +14,15 @@ members = [
|
||||
"pmocovers",
|
||||
"pmoaudiocache",
|
||||
"pmoaudio",
|
||||
"pmoaudio-ext",
|
||||
"pmoqobuz",
|
||||
"pmoparadise",
|
||||
"pmoradiofrance",
|
||||
"pmosource",
|
||||
"pmoplaylist",
|
||||
"pmoflac",
|
||||
"pmometadata", "pmocontrol",
|
||||
"pmometadata",
|
||||
"pmocontrol",
|
||||
]
|
||||
|
||||
[workspace.dependencies]
|
||||
@@ -35,7 +38,7 @@ async-trait = "0.1"
|
||||
|
||||
# Error handling
|
||||
anyhow = "1.0"
|
||||
thiserror = "2.0" # ⚠️ Unifier sur 2.0 (vous avez 1.0 et 2.0)
|
||||
thiserror = "2.0"
|
||||
|
||||
# Logging
|
||||
tracing = "0.1.41"
|
||||
@@ -44,7 +47,9 @@ tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
|
||||
# HTTP/XML
|
||||
reqwest = { version = "0.12", default-features = false }
|
||||
ureq = "3.1"
|
||||
quick-xml = { version = "0.38", features = ["serialize"] } # ⚠️ Unifier 0.37→0.38
|
||||
quick-xml = { version = "0.38", features = ["serialize"] }
|
||||
axum = "0.8.4"
|
||||
futures = "0.3"
|
||||
|
||||
# Utilities
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
@@ -53,4 +58,4 @@ crossbeam-channel = "0.5"
|
||||
rand = "0.9"
|
||||
|
||||
# Testing
|
||||
tokio-test = "0.4"
|
||||
tokio-test = "0.4"
|
||||
@@ -32,8 +32,9 @@ RUN apt-get update && apt-get install -y \
|
||||
cmake \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy Cargo workspace files
|
||||
# Copy Cargo workspace files and registry configuration
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY .cargo/ ./.cargo/
|
||||
|
||||
# Copy all crates
|
||||
COPY PMOMusic/ ./PMOMusic/
|
||||
@@ -41,7 +42,6 @@ COPY pmoupnp/ ./pmoupnp/
|
||||
COPY pmomediarenderer/ ./pmomediarenderer/
|
||||
COPY pmomediaserver/ ./pmomediaserver/
|
||||
COPY pmoconfig/ ./pmoconfig/
|
||||
COPY pmoutils/ ./pmoutils/
|
||||
COPY pmodidl/ ./pmodidl/
|
||||
COPY pmoserver/ ./pmoserver/
|
||||
COPY pmocache/ ./pmocache/
|
||||
@@ -50,6 +50,8 @@ COPY pmoaudiocache/ ./pmoaudiocache/
|
||||
COPY pmoaudio/ ./pmoaudio/
|
||||
COPY pmoqobuz/ ./pmoqobuz/
|
||||
COPY pmoparadise/ ./pmoparadise/
|
||||
COPY pmoradiofrance/ ./pmoradiofrance/
|
||||
COPY pmowebrenderer/ ./pmowebrenderer/
|
||||
COPY pmosource/ ./pmosource/
|
||||
COPY pmoplaylist/ ./pmoplaylist/
|
||||
COPY pmoflac/ ./pmoflac/
|
||||
|
||||
14
Makefile
14
Makefile
@@ -161,15 +161,17 @@ watch:
|
||||
ci: fmt-check clippy test doc-build webapp
|
||||
@echo "$(GREEN)✓ Toutes les vérifications CI passées$(NC)"
|
||||
|
||||
## run: Lance le binaire en mode debug
|
||||
## run: Compile en debug et lance via Terminal.app (requis pour le multicast sur macOS Sequoia+)
|
||||
run: debug
|
||||
@echo "$(YELLOW)→ Lancement de l'application...$(NC)"
|
||||
./target/debug/$(BINARY_NAME)
|
||||
@echo "$(YELLOW)→ Lancement de l'application via Terminal.app...$(NC)"
|
||||
@echo "$(BLUE) (Terminal.app est nécessaire pour le multicast sur macOS Sequoia+)$(NC)"
|
||||
@osascript -e 'tell application "Terminal" to do script "cd \"$(CURDIR)\" && ./target/debug/$(BINARY_NAME) 2>&1 | tee pmomusic.log; exit"'
|
||||
|
||||
## run-release: Lance le binaire en mode release
|
||||
## run-release: Compile en release et lance via Terminal.app (requis pour le multicast sur macOS Sequoia+)
|
||||
run-release: release
|
||||
@echo "$(YELLOW)→ Lancement de l'application (release)...$(NC)"
|
||||
./$(RUST_TARGET)/$(BINARY_NAME)
|
||||
@echo "$(YELLOW)→ Lancement de l'application (release) via Terminal.app...$(NC)"
|
||||
@echo "$(BLUE) (Terminal.app est nécessaire pour le multicast sur macOS Sequoia+)$(NC)"
|
||||
@osascript -e 'tell application "Terminal" to do script "cd \"$(CURDIR)\" && ./$(RUST_TARGET)/$(BINARY_NAME) 2>&1 | tee pmomusic.log; exit"'
|
||||
|
||||
## size: Affiche la taille du binaire
|
||||
size:
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
[package]
|
||||
name = "PMOMusic"
|
||||
version = "0.3.5"
|
||||
version = "0.3.33"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
pmoconfig = { path = "../pmoconfig" }
|
||||
pmoupnp = { path = "../pmoupnp"}
|
||||
pmomediarenderer = { path = "../pmomediarenderer" }
|
||||
pmomediaserver = { path = "../pmomediaserver", features = ["qobuz", "paradise", "paradise-api", "api"] }
|
||||
pmomediaserver = { path = "../pmomediaserver", features = ["qobuz", "paradise", "paradise-api", "radiofrance", "api"] }
|
||||
pmosource = { path = "../pmosource", features = ["server"] }
|
||||
pmoserver = { path = "../pmoserver" }
|
||||
pmocovers = { path = "../pmocovers", features = ["pmoserver"] }
|
||||
@@ -15,6 +15,7 @@ pmoaudiocache = { path = "../pmoaudiocache", features = ["pmoserver"]}
|
||||
pmoaudio-ext = { path = "../pmoaudio-ext", features = ["all"] }
|
||||
pmoapp = { path = "../pmoapp", features = ["pmoserver"] }
|
||||
pmocontrol = { path = "../pmocontrol", features = ["pmoserver"] }
|
||||
pmowebrenderer = { path = "../pmowebrenderer", features = ["pmoserver"] }
|
||||
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "time", "signal"] }
|
||||
tracing = { workspace = true }
|
||||
|
||||
@@ -7,6 +7,7 @@ use pmomediaserver::{
|
||||
use pmoserver::Server;
|
||||
use pmosource::MusicSourceExt;
|
||||
use pmoupnp::UpnpServerExt;
|
||||
use pmowebrenderer::WebRendererExt;
|
||||
use tracing::info;
|
||||
|
||||
#[tokio::main]
|
||||
@@ -54,6 +55,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
|
||||
// Enregistrer la source Radio France (inclut l'initialisation des routes API)
|
||||
info!("📻 Registering Radio France source...");
|
||||
if let Err(e) = server.write().await.register_radiofrance().await {
|
||||
tracing::warn!("⚠️ Failed to register Radio France source: {}", e);
|
||||
}
|
||||
|
||||
// Lister toutes les sources enregistrées
|
||||
let sources = server.read().await.list_music_sources().await;
|
||||
info!("✅ {} music source(s) registered", sources.len());
|
||||
@@ -67,7 +74,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let renderer_instance = server
|
||||
.write()
|
||||
.await
|
||||
.register_device(MEDIA_RENDERER.clone())
|
||||
.register_device(MEDIA_RENDERER.clone(), true)
|
||||
.await
|
||||
.expect("Failed to register MediaRenderer");
|
||||
|
||||
@@ -78,7 +85,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let server_instance = server
|
||||
.write()
|
||||
.await
|
||||
.register_device(MEDIA_SERVER.clone())
|
||||
.register_device(MEDIA_SERVER.clone(), true)
|
||||
.await
|
||||
.expect("Failed to register MediaServer");
|
||||
|
||||
@@ -98,13 +105,22 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
// Enregistrer le Control Point (découverte renderers/serveurs + API REST + SSE)
|
||||
info!("🎛️ Registering Control Point...");
|
||||
let _control_point = server
|
||||
let control_point = server
|
||||
.write()
|
||||
.await
|
||||
.register_control_point(5)
|
||||
.await
|
||||
.expect("Failed to register Control Point");
|
||||
|
||||
// Enregistrer le WebRenderer (endpoint WebSocket pour renderers navigateur)
|
||||
info!("🌐 Registering WebRenderer...");
|
||||
server
|
||||
.write()
|
||||
.await
|
||||
.register_web_renderer(control_point)
|
||||
.await
|
||||
.expect("Failed to register WebRenderer");
|
||||
|
||||
// Ajouter la webapp via le trait WebAppExt
|
||||
info!("📡 Registering Web application...");
|
||||
server
|
||||
@@ -121,8 +137,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
info!("✅ PMOMusic is ready!");
|
||||
info!("Press Ctrl+C to stop...");
|
||||
|
||||
// Attendre le signal Ctrl+C et l'arrêt du serveur HTTP
|
||||
server.write().await.wait().await;
|
||||
// Extraire le join_handle AVANT de libérer le write lock,
|
||||
// pour pouvoir l'awaiter sans tenir le write lock du serveur global.
|
||||
// (Tenir le write lock pendant wait() bloquerait register_device() dynamique)
|
||||
let join_handle = server.write().await.take_join_handle();
|
||||
if let Some(h) = join_handle {
|
||||
let _ = h.await;
|
||||
}
|
||||
|
||||
// Le serveur HTTP est arrêté, mais des threads (ControlPoint, etc.) peuvent encore tourner
|
||||
// Attendre 2 secondes pour laisser le temps aux threads de se terminer
|
||||
|
||||
2559
metadata.txt
Normal file
2559
metadata.txt
Normal file
File diff suppressed because it is too large
Load Diff
6
package-lock.json
generated
6
package-lock.json
generated
@@ -2,5 +2,9 @@
|
||||
"name": "pmomusic",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pmomusic"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ rust-embed = "8.5.0"
|
||||
path = "../pmoserver"
|
||||
optional = true
|
||||
|
||||
[dependencies.async-trait]
|
||||
optional = true
|
||||
version = "0.1"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
pmoserver = ["dep:pmoserver"]
|
||||
pmoserver = ["dep:pmoserver", "dep:async-trait"]
|
||||
|
||||
@@ -237,6 +237,8 @@
|
||||
//! - [Vue.js Documentation](https://vuejs.org/)
|
||||
//! - [Vite Documentation](https://vitejs.dev/)
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use async_trait::async_trait;
|
||||
use rust_embed::RustEmbed;
|
||||
|
||||
/// Structure représentant l'application web embarquée.
|
||||
@@ -285,6 +287,8 @@ pub struct Webapp;
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[async_trait]
|
||||
pub trait WebAppExt {
|
||||
/// Ajoute une Single Page Application au serveur.
|
||||
///
|
||||
|
||||
@@ -27,10 +27,14 @@
|
||||
//! # }
|
||||
//! ```
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use async_trait::async_trait;
|
||||
use crate::WebAppExt;
|
||||
use pmoserver::Server;
|
||||
use rust_embed::RustEmbed;
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[async_trait]
|
||||
impl WebAppExt for Server {
|
||||
async fn add_webapp<W>(&mut self, path: &str)
|
||||
where
|
||||
|
||||
466
pmoapp/webapp/package-lock.json
generated
466
pmoapp/webapp/package-lock.json
generated
@@ -74,9 +74,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz",
|
||||
"integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -91,9 +91,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz",
|
||||
"integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -108,9 +108,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz",
|
||||
"integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -125,9 +125,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz",
|
||||
"integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -142,9 +142,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz",
|
||||
"integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -159,9 +159,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz",
|
||||
"integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -176,9 +176,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz",
|
||||
"integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -193,9 +193,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz",
|
||||
"integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -210,9 +210,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz",
|
||||
"integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -227,9 +227,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz",
|
||||
"integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -244,9 +244,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz",
|
||||
"integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -261,9 +261,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz",
|
||||
"integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
|
||||
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -278,9 +278,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz",
|
||||
"integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
|
||||
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
@@ -295,9 +295,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz",
|
||||
"integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -312,9 +312,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz",
|
||||
"integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
|
||||
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -329,9 +329,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz",
|
||||
"integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
|
||||
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -346,9 +346,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz",
|
||||
"integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -363,9 +363,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz",
|
||||
"integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -380,9 +380,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz",
|
||||
"integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -397,9 +397,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz",
|
||||
"integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -414,9 +414,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz",
|
||||
"integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -431,9 +431,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz",
|
||||
"integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -448,9 +448,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz",
|
||||
"integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -465,9 +465,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz",
|
||||
"integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -482,9 +482,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz",
|
||||
"integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -499,9 +499,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz",
|
||||
"integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -529,9 +529,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.3.tgz",
|
||||
"integrity": "sha512-h6cqHGZ6VdnwliFG1NXvMPTy/9PS3h8oLh7ImwR+kl+oYnQizgjxsONmmPSb2C66RksfkfIxEVtDSEcJiO0tqw==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz",
|
||||
"integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -543,9 +543,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.3.tgz",
|
||||
"integrity": "sha512-wd+u7SLT/u6knklV/ifG7gr5Qy4GUbH2hMWcDauPFJzmCZUAJ8L2bTkVXC2niOIxp8lk3iH/QX8kSrUxVZrOVw==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz",
|
||||
"integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -557,9 +557,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.3.tgz",
|
||||
"integrity": "sha512-lj9ViATR1SsqycwFkJCtYfQTheBdvlWJqzqxwc9f2qrcVrQaF/gCuBRTiTolkRWS6KvNxSk4KHZWG7tDktLgjg==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz",
|
||||
"integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -571,9 +571,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.3.tgz",
|
||||
"integrity": "sha512-+Dyo7O1KUmIsbzx1l+4V4tvEVnVQqMOIYtrxK7ncLSknl1xnMHLgn7gddJVrYPNZfEB8CIi3hK8gq8bDhb3h5A==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz",
|
||||
"integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -585,9 +585,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.3.tgz",
|
||||
"integrity": "sha512-u9Xg2FavYbD30g3DSfNhxgNrxhi6xVG4Y6i9Ur1C7xUuGDW3banRbXj+qgnIrwRN4KeJ396jchwy9bCIzbyBEQ==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz",
|
||||
"integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -599,9 +599,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.3.tgz",
|
||||
"integrity": "sha512-5M8kyi/OX96wtD5qJR89a/3x5x8x5inXBZO04JWhkQb2JWavOWfjgkdvUqibGJeNNaz1/Z1PPza5/tAPXICI6A==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz",
|
||||
"integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -613,9 +613,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.3.tgz",
|
||||
"integrity": "sha512-IoerZJ4l1wRMopEHRKOO16e04iXRDyZFZnNZKrWeNquh5d6bucjezgd+OxG03mOMTnS1x7hilzb3uURPkJ0OfA==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz",
|
||||
"integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -627,9 +627,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.3.tgz",
|
||||
"integrity": "sha512-ZYdtqgHTDfvrJHSh3W22TvjWxwOgc3ThK/XjgcNGP2DIwFIPeAPNsQxrJO5XqleSlgDux2VAoWQ5iJrtaC1TbA==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz",
|
||||
"integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -641,9 +641,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.3.tgz",
|
||||
"integrity": "sha512-NcViG7A0YtuFDA6xWSgmFb6iPFzHlf5vcqb2p0lGEbT+gjrEEz8nC/EeDHvx6mnGXnGCC1SeVV+8u+smj0CeGQ==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz",
|
||||
"integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -655,9 +655,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.3.tgz",
|
||||
"integrity": "sha512-d3pY7LWno6SYNXRm6Ebsq0DJGoiLXTb83AIPCXl9fmtIQs/rXoS8SJxxUNtFbJ5MiOvs+7y34np77+9l4nfFMw==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz",
|
||||
"integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -669,9 +669,23 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.3.tgz",
|
||||
"integrity": "sha512-3y5GA0JkBuirLqmjwAKwB0keDlI6JfGYduMlJD/Rl7fvb4Ni8iKdQs1eiunMZJhwDWdCvrcqXRY++VEBbvk6Eg==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz",
|
||||
"integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz",
|
||||
"integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -683,9 +697,23 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.3.tgz",
|
||||
"integrity": "sha512-AUUH65a0p3Q0Yfm5oD2KVgzTKgwPyp9DSXc3UA7DtxhEb/WSPfbG4wqXeSN62OG5gSo18em4xv6dbfcUGXcagw==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz",
|
||||
"integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz",
|
||||
"integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -697,9 +725,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.3.tgz",
|
||||
"integrity": "sha512-1makPhFFVBqZE+XFg3Dkq+IkQ7JvmUrwwqaYBL2CE+ZpxPaqkGaiWFEWVGyvTwZace6WLJHwjVh/+CXbKDGPmg==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz",
|
||||
"integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -711,9 +739,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.3.tgz",
|
||||
"integrity": "sha512-OOFJa28dxfl8kLOPMUOQBCO6z3X2SAfzIE276fwT52uXDWUS178KWq0pL7d6p1kz7pkzA0yQwtqL0dEPoVcRWg==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz",
|
||||
"integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -725,9 +753,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.3.tgz",
|
||||
"integrity": "sha512-jMdsML2VI5l+V7cKfZx3ak+SLlJ8fKvLJ0Eoa4b9/vCUrzXKgoKxvHqvJ/mkWhFiyp88nCkM5S2v6nIwRtPcgg==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz",
|
||||
"integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -739,9 +767,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.3.tgz",
|
||||
"integrity": "sha512-tPgGd6bY2M2LJTA1uGq8fkSPK8ZLYjDjY+ZLK9WHncCnfIz29LIXIqUgzCR0hIefzy6Hpbe8Th5WOSwTM8E7LA==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz",
|
||||
"integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -753,9 +781,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.3.tgz",
|
||||
"integrity": "sha512-BCFkJjgk+WFzP+tcSMXq77ymAPIxsX9lFJWs+2JzuZTLtksJ2o5hvgTdIcZ5+oKzUDMwI0PfWzRBYAydAHF2Mw==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz",
|
||||
"integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -766,10 +794,24 @@
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz",
|
||||
"integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.3.tgz",
|
||||
"integrity": "sha512-KTD/EqjZF3yvRaWUJdD1cW+IQBk4fbQaHYJUmP8N4XoKFZilVL8cobFSTDnjTtxWJQ3JYaMgF4nObY/+nYkumA==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz",
|
||||
"integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -781,9 +823,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.3.tgz",
|
||||
"integrity": "sha512-+zteHZdoUYLkyYKObGHieibUFLbttX2r+58l27XZauq0tcWYYuKUwY2wjeCN9oK1Um2YgH2ibd6cnX/wFD7DuA==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz",
|
||||
"integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -795,9 +837,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.3.tgz",
|
||||
"integrity": "sha512-of1iHkTQSo3kr6dTIRX6t81uj/c/b15HXVsPcEElN5sS859qHrOepM5p9G41Hah+CTqSh2r8Bm56dL2z9UQQ7g==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz",
|
||||
"integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -809,9 +851,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.3.tgz",
|
||||
"integrity": "sha512-s0hybmlHb56mWVZQj8ra9048/WZTPLILKxcvcq+8awSZmyiSUZjjem1AhU3Tf4ZKpYhK4mg36HtHDOe8QJS5PQ==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz",
|
||||
"integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -823,9 +865,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.3.tgz",
|
||||
"integrity": "sha512-zGIbEVVXVtauFgl3MRwGWEN36P5ZGenHRMgNw88X5wEhEBpq0XrMEZwOn07+ICrwM17XO5xfMZqh0OldCH5VTA==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz",
|
||||
"integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -859,7 +901,6 @@
|
||||
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -1178,9 +1219,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
|
||||
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
|
||||
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
@@ -1199,9 +1240,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz",
|
||||
"integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -1212,32 +1253,32 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.25.10",
|
||||
"@esbuild/android-arm": "0.25.10",
|
||||
"@esbuild/android-arm64": "0.25.10",
|
||||
"@esbuild/android-x64": "0.25.10",
|
||||
"@esbuild/darwin-arm64": "0.25.10",
|
||||
"@esbuild/darwin-x64": "0.25.10",
|
||||
"@esbuild/freebsd-arm64": "0.25.10",
|
||||
"@esbuild/freebsd-x64": "0.25.10",
|
||||
"@esbuild/linux-arm": "0.25.10",
|
||||
"@esbuild/linux-arm64": "0.25.10",
|
||||
"@esbuild/linux-ia32": "0.25.10",
|
||||
"@esbuild/linux-loong64": "0.25.10",
|
||||
"@esbuild/linux-mips64el": "0.25.10",
|
||||
"@esbuild/linux-ppc64": "0.25.10",
|
||||
"@esbuild/linux-riscv64": "0.25.10",
|
||||
"@esbuild/linux-s390x": "0.25.10",
|
||||
"@esbuild/linux-x64": "0.25.10",
|
||||
"@esbuild/netbsd-arm64": "0.25.10",
|
||||
"@esbuild/netbsd-x64": "0.25.10",
|
||||
"@esbuild/openbsd-arm64": "0.25.10",
|
||||
"@esbuild/openbsd-x64": "0.25.10",
|
||||
"@esbuild/openharmony-arm64": "0.25.10",
|
||||
"@esbuild/sunos-x64": "0.25.10",
|
||||
"@esbuild/win32-arm64": "0.25.10",
|
||||
"@esbuild/win32-ia32": "0.25.10",
|
||||
"@esbuild/win32-x64": "0.25.10"
|
||||
"@esbuild/aix-ppc64": "0.27.4",
|
||||
"@esbuild/android-arm": "0.27.4",
|
||||
"@esbuild/android-arm64": "0.27.4",
|
||||
"@esbuild/android-x64": "0.27.4",
|
||||
"@esbuild/darwin-arm64": "0.27.4",
|
||||
"@esbuild/darwin-x64": "0.27.4",
|
||||
"@esbuild/freebsd-arm64": "0.27.4",
|
||||
"@esbuild/freebsd-x64": "0.27.4",
|
||||
"@esbuild/linux-arm": "0.27.4",
|
||||
"@esbuild/linux-arm64": "0.27.4",
|
||||
"@esbuild/linux-ia32": "0.27.4",
|
||||
"@esbuild/linux-loong64": "0.27.4",
|
||||
"@esbuild/linux-mips64el": "0.27.4",
|
||||
"@esbuild/linux-ppc64": "0.27.4",
|
||||
"@esbuild/linux-riscv64": "0.27.4",
|
||||
"@esbuild/linux-s390x": "0.27.4",
|
||||
"@esbuild/linux-x64": "0.27.4",
|
||||
"@esbuild/netbsd-arm64": "0.27.4",
|
||||
"@esbuild/netbsd-x64": "0.27.4",
|
||||
"@esbuild/openbsd-arm64": "0.27.4",
|
||||
"@esbuild/openbsd-x64": "0.27.4",
|
||||
"@esbuild/openharmony-arm64": "0.27.4",
|
||||
"@esbuild/sunos-x64": "0.27.4",
|
||||
"@esbuild/win32-arm64": "0.27.4",
|
||||
"@esbuild/win32-ia32": "0.27.4",
|
||||
"@esbuild/win32-x64": "0.27.4"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
@@ -1378,12 +1419,11 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -1456,9 +1496,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.3.tgz",
|
||||
"integrity": "sha512-RIDh866U8agLgiIcdpB+COKnlCreHJLfIhWC3LVflku5YHfpnsIKigRZeFfMfCc4dVcqNVfQQ5gO/afOck064A==",
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz",
|
||||
"integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1472,28 +1512,31 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.52.3",
|
||||
"@rollup/rollup-android-arm64": "4.52.3",
|
||||
"@rollup/rollup-darwin-arm64": "4.52.3",
|
||||
"@rollup/rollup-darwin-x64": "4.52.3",
|
||||
"@rollup/rollup-freebsd-arm64": "4.52.3",
|
||||
"@rollup/rollup-freebsd-x64": "4.52.3",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.52.3",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.52.3",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.52.3",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.52.3",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.52.3",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.52.3",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.52.3",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.52.3",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.52.3",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.52.3",
|
||||
"@rollup/rollup-linux-x64-musl": "4.52.3",
|
||||
"@rollup/rollup-openharmony-arm64": "4.52.3",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.52.3",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.52.3",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.52.3",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.52.3",
|
||||
"@rollup/rollup-android-arm-eabi": "4.60.0",
|
||||
"@rollup/rollup-android-arm64": "4.60.0",
|
||||
"@rollup/rollup-darwin-arm64": "4.60.0",
|
||||
"@rollup/rollup-darwin-x64": "4.60.0",
|
||||
"@rollup/rollup-freebsd-arm64": "4.60.0",
|
||||
"@rollup/rollup-freebsd-x64": "4.60.0",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.60.0",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.60.0",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.60.0",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.60.0",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.60.0",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.60.0",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.60.0",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.60.0",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.60.0",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.60.0",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.60.0",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.60.0",
|
||||
"@rollup/rollup-linux-x64-musl": "4.60.0",
|
||||
"@rollup/rollup-openbsd-x64": "4.60.0",
|
||||
"@rollup/rollup-openharmony-arm64": "4.60.0",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.60.0",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.60.0",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.60.0",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.60.0",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
@@ -1550,7 +1593,6 @@
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -1567,14 +1609,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.1.7",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.7.tgz",
|
||||
"integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==",
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"postcss": "^8.5.6",
|
||||
@@ -1654,7 +1695,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz",
|
||||
"integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.22",
|
||||
"@vue/compiler-sfc": "3.5.22",
|
||||
|
||||
272
pmoapp/webapp/src/assets/styles/drawers.css
Normal file
272
pmoapp/webapp/src/assets/styles/drawers.css
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Styles communs pour les drawers (ServerDrawer, RendererDrawer)
|
||||
*
|
||||
* Chaque drawer ajoute uniquement ce qui le différencie :
|
||||
* - position (left/right)
|
||||
* - border (border-right / border-left)
|
||||
* - box-shadow (direction)
|
||||
* - backdrop spécifique (position)
|
||||
*/
|
||||
|
||||
/* ========================================
|
||||
BACKDROP
|
||||
======================================== */
|
||||
|
||||
.drawer-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) and (orientation: portrait) {
|
||||
.drawer-backdrop {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
PANNEAU DRAWER
|
||||
======================================== */
|
||||
|
||||
.app-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 50vw;
|
||||
/* Fond opaque — évite la transparence empilée sur d'autres éléments glass */
|
||||
background: rgba(22, 22, 32, 0.96);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
z-index: 201;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.app-drawer {
|
||||
background: rgba(245, 245, 250, 0.97);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) and (orientation: portrait) {
|
||||
.app-drawer {
|
||||
width: 100vw;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
HEADER
|
||||
======================================== */
|
||||
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-lg);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.drawer-header {
|
||||
border-bottom-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-title-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
color: var(--color-text);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.drawer-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.drawer-title.small {
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
/* Bouton fermer */
|
||||
.drawer-close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast) ease;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.drawer-close-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.drawer-close-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Bouton retour (ServerDrawer navigation) */
|
||||
.drawer-back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast) ease;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.drawer-back-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
CONTENU
|
||||
======================================== */
|
||||
|
||||
/* Add class="scrollbar-thin" on elements needing thin scrollbar (see pmocontrol.css) */
|
||||
.drawer-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.drawer-section {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* TODO: remplacer par la classe globale .section-title de pmocontrol.css */
|
||||
.drawer-section-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0 0 var(--spacing-sm) 0;
|
||||
padding: 0 var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* Liste d'items générique */
|
||||
.drawer-item-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.drawer-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
transition: all var(--transition-fast) ease;
|
||||
}
|
||||
|
||||
.drawer-item.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.drawer-item.clickable:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.drawer-item.clickable:active {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.drawer-item.offline {
|
||||
opacity: var(--opacity-disabled, 0.5);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Icône dans un item */
|
||||
.drawer-item-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Info texte dans un item */
|
||||
.drawer-item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.drawer-item-name {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.drawer-item-sub {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 2px 0 0 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
FOOTER
|
||||
======================================== */
|
||||
|
||||
.drawer-footer {
|
||||
padding: var(--spacing-md);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.drawer-footer {
|
||||
border-top-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Les animations d'entrée/sortie sont définies dans chaque composant (scoped)
|
||||
car elles diffèrent selon la direction (gauche/droite/bas) */
|
||||
@@ -434,3 +434,122 @@ input[type="range"]::-moz-range-thumb:hover {
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
UTILITIES
|
||||
======================================== */
|
||||
|
||||
/* Section title (used in drawers and panels) */
|
||||
.section-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0 0 var(--spacing-sm) 0;
|
||||
}
|
||||
|
||||
/* Icon-only circular button */
|
||||
.icon-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
transition: background var(--transition-fast) ease, transform var(--transition-fast) ease;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.icon-btn:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.icon-btn {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
border-color: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.icon-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
PROTOCOL BADGES (global, used in BottomTabBar, RendererDrawer, RendererCard)
|
||||
======================================== */
|
||||
|
||||
.protocol-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.protocol-upnp {
|
||||
background-color: rgba(59, 130, 246, 0.15);
|
||||
color: #3b82f6;
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.protocol-openhome {
|
||||
background-color: rgba(139, 92, 246, 0.15);
|
||||
color: #8b5cf6;
|
||||
border-color: rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
|
||||
.protocol-hybrid {
|
||||
background-color: rgba(16, 185, 129, 0.15);
|
||||
color: #10b981;
|
||||
border-color: rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.protocol-chromecast {
|
||||
background-color: rgba(245, 158, 11, 0.15);
|
||||
color: #f59e0b;
|
||||
border-color: rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
SCROLLBAR (webkit)
|
||||
======================================== */
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
ANIMATIONS
|
||||
======================================== */
|
||||
|
||||
@keyframes pulse-opacity {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
@@ -78,6 +78,8 @@
|
||||
/* Primary color (brand) */
|
||||
--color-primary: #667eea;
|
||||
--color-primary-hover: #5568d3;
|
||||
/* Primary color RGB components (for rgba() usage) */
|
||||
--color-primary-rgb: 102, 126, 234;
|
||||
|
||||
/* ========================================
|
||||
Transitions
|
||||
|
||||
@@ -1412,17 +1412,17 @@ onMounted(() => {
|
||||
|
||||
<style scoped>
|
||||
.playlist-manager {
|
||||
padding: 1rem;
|
||||
padding: var(--spacing-md);
|
||||
width: 100%;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
color: #f5f5f5;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.playlist-manager {
|
||||
padding: 0.5rem;
|
||||
padding: var(--spacing-sm);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1430,56 +1430,63 @@ onMounted(() => {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #2b2b2b;
|
||||
background: #151515;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
color: #61dafb;
|
||||
color: var(--color-primary);
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0.25rem 0 0;
|
||||
color: #b0b0b0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.header-stats {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
gap: var(--spacing-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.header-stats span {
|
||||
background: #232323;
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: #ddd;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: var(--radius-full);
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.header-actions button {
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
background: #61dafb;
|
||||
color: #0b0b0b;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.header-actions button:disabled {
|
||||
opacity: 0.6;
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.panels {
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: 340px 1fr;
|
||||
gap: var(--spacing-md);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@@ -1490,28 +1497,28 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.section-card {
|
||||
background: #1f1f1f;
|
||||
border: 1px solid #2f2f2f;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 1rem;
|
||||
border: 1px dashed #3a3a3a;
|
||||
border-radius: 8px;
|
||||
padding: var(--spacing-md);
|
||||
border: 1px dashed rgba(255, 255, 255, 0.15);
|
||||
border-radius: var(--radius-md);
|
||||
text-align: center;
|
||||
color: #a8a8a8;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.list-section {
|
||||
@@ -1522,95 +1529,107 @@ onMounted(() => {
|
||||
.playlist-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.playlist-card {
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 10px;
|
||||
padding: 0.85rem;
|
||||
background: #242424;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
gap: 0.3rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
transition: border-color var(--transition-fast), background var(--transition-fast);
|
||||
}
|
||||
|
||||
.playlist-card:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.playlist-card.selected {
|
||||
border-color: #61dafb;
|
||||
box-shadow: 0 0 0 1px rgba(97, 218, 251, 0.4);
|
||||
border-color: var(--color-primary);
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
gap: var(--spacing-sm);
|
||||
font-weight: 600;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.card-metrics,
|
||||
.card-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: #bdbdbd;
|
||||
gap: var(--spacing-sm);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.card-artist {
|
||||
margin-top: 0.25rem;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.artist-label {
|
||||
font-size: 0.85rem;
|
||||
color: #90caf9;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-primary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
gap: var(--spacing-sm);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
background: #111;
|
||||
border: 1px solid #2b2b2b;
|
||||
border-radius: 14px;
|
||||
padding: 1.25rem;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-md);
|
||||
min-height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr auto;
|
||||
gap: 1rem;
|
||||
grid-template-columns: 100px 1fr auto;
|
||||
gap: var(--spacing-md);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-header-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.detail-header-main h3 {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
gap: var(--spacing-sm);
|
||||
align-items: center;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.detail-cover {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 10px;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: #1a1a1a;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #2f2f2f;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.detail-cover img {
|
||||
@@ -1620,23 +1639,29 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.detail-id {
|
||||
margin: 0.2rem 0 0;
|
||||
color: #9c9c9c;
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.detail-header-actions button {
|
||||
background: #232323;
|
||||
border: 1px solid #3d3d3d;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.9rem;
|
||||
color: #f5f5f5;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.4rem 0.8rem;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.detail-header-actions button:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.detail-header {
|
||||
grid-template-columns: 100px 1fr;
|
||||
grid-template-columns: 80px 1fr;
|
||||
}
|
||||
|
||||
.detail-header-actions {
|
||||
@@ -1651,9 +1676,8 @@ onMounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #1b1b1b;
|
||||
color: #666;
|
||||
font-size: 2rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.cover-placeholder.small {
|
||||
@@ -1662,18 +1686,18 @@ onMounted(() => {
|
||||
|
||||
.cover-preview-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
gap: var(--spacing-sm);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cover-preview {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 8px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
border: 1px solid #2d2d2d;
|
||||
background: #0f0f0f;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -1686,27 +1710,28 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.cover-preview.small {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.btn-compact {
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.detail-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
font-size: 0.95rem;
|
||||
gap: var(--spacing-md);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.cover-stat code {
|
||||
background: #0f0f0f;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-right: 0.4rem;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.detail-messages .error,
|
||||
@@ -1715,32 +1740,34 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.section-subcard {
|
||||
background: #181818;
|
||||
border: 1px solid #2b2b2b;
|
||||
border-radius: 10px;
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--spacing-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.section-subcard h4 {
|
||||
margin: 0;
|
||||
color: #e7e7e7;
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.field,
|
||||
.checkbox {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.9rem;
|
||||
gap: 0.3rem;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
@@ -1751,11 +1778,12 @@ form {
|
||||
.field input,
|
||||
.field select,
|
||||
.field textarea {
|
||||
background: #0f0f0f;
|
||||
border: 1px solid #3f3f3f;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
color: #f5f5f5;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.45rem var(--spacing-sm);
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.field textarea {
|
||||
@@ -1764,14 +1792,14 @@ form {
|
||||
|
||||
.field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.field-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.form-messages {
|
||||
@@ -1779,13 +1807,15 @@ form {
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: #4b9ed0;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem var(--spacing-md);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: var(--text-sm);
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.primary:disabled {
|
||||
@@ -1794,19 +1824,35 @@ form {
|
||||
}
|
||||
|
||||
button {
|
||||
font-size: 0.9rem;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #2c2c2c;
|
||||
border: 1px solid #3d3d3d;
|
||||
color: #f5f5f5;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
color: var(--color-text);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.4rem 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #c0392b;
|
||||
border: 1px solid #d35400;
|
||||
background: rgba(192, 57, 43, 0.8);
|
||||
border: 1px solid rgba(211, 84, 0, 0.6);
|
||||
color: #fff;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.4rem 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: rgba(192, 57, 43, 1);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
@@ -1815,68 +1861,75 @@ button:disabled {
|
||||
}
|
||||
|
||||
.pill {
|
||||
background: #2f2f2f;
|
||||
border-radius: 999px;
|
||||
padding: 0.15rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: var(--radius-full);
|
||||
padding: 0.1rem 0.55rem;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 0.85rem;
|
||||
color: #a5a5a5;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #8a8a8a;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.tracks .section-header {
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.track-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.track-card {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr auto;
|
||||
gap: 1rem;
|
||||
padding: 0.9rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #2a2a2a;
|
||||
background: #16181f;
|
||||
grid-template-columns: 80px 1fr auto;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
align-items: center;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.track-card:hover {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.track-card {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: 80px 1fr;
|
||||
}
|
||||
|
||||
.cover-wrapper {
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
.track-actions {
|
||||
grid-column: span 2;
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.track-card.lazy {
|
||||
border-color: #d4a017;
|
||||
box-shadow: 0 0 0 1px rgba(212, 160, 23, 0.4);
|
||||
border-color: rgba(212, 160, 23, 0.5);
|
||||
background: rgba(212, 160, 23, 0.05);
|
||||
}
|
||||
|
||||
.cover-wrapper {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 10px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: #1f1f1f;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cover-wrapper img {
|
||||
@@ -1886,90 +1939,101 @@ button:disabled {
|
||||
}
|
||||
|
||||
.track-card .cover-placeholder {
|
||||
font-size: 2rem;
|
||||
color: #6f6f6f;
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.cover-lazy-pill {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
bottom: 6px;
|
||||
right: 4px;
|
||||
bottom: 4px;
|
||||
background: #d4a017;
|
||||
color: #1a1a1a;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.7rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.track-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.track-title {
|
||||
font-size: 1rem;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.track-artist,
|
||||
.track-album {
|
||||
font-size: 0.9rem;
|
||||
color: #bbbbbb;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.track-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: #a0a0a0;
|
||||
gap: var(--spacing-sm);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.pk-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
word-break: break-word;
|
||||
gap: 0.2rem;
|
||||
font-size: var(--text-xs);
|
||||
word-break: break-all;
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.pk-line {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
gap: 0.3rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pk-line .label {
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pk-line code {
|
||||
background: #0f0f0f;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.added-info {
|
||||
font-size: 0.8rem;
|
||||
color: #909090;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.track-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ff7a7a;
|
||||
color: var(--status-offline);
|
||||
}
|
||||
|
||||
.success {
|
||||
color: #6adf8b;
|
||||
color: var(--status-playing);
|
||||
}
|
||||
</style>
|
||||
.detail-header-main { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed } from "vue";
|
||||
import type { ContainerEntry } from "@/services/pmocontrol/types";
|
||||
import { Folder, Music } from "lucide-vue-next";
|
||||
import ActionMenu from "./ActionMenu.vue";
|
||||
import { useCoverImage } from "@/composables/useCoverImage";
|
||||
|
||||
const props = defineProps<{
|
||||
entry: ContainerEntry;
|
||||
@@ -16,6 +17,17 @@ const emit = defineEmits<{
|
||||
addToQueue: [containerId: string, rendererId: string];
|
||||
}>();
|
||||
|
||||
// Use the new cover image composable
|
||||
const albumArtUri = computed(() => props.entry.album_art_uri);
|
||||
const {
|
||||
imageLoaded,
|
||||
imageError,
|
||||
coverImageRef,
|
||||
cacheBustedUrl,
|
||||
handleImageLoad,
|
||||
handleImageError,
|
||||
} = useCoverImage(albumArtUri);
|
||||
|
||||
const iconComponent = computed(() => {
|
||||
const cls = props.entry.class.toLowerCase();
|
||||
if (cls.includes("playlist")) return Music;
|
||||
@@ -48,15 +60,6 @@ function handlePlayNow(rendererId: string) {
|
||||
function handleAddToQueue(rendererId: string) {
|
||||
emit("addToQueue", props.entry.id, rendererId);
|
||||
}
|
||||
|
||||
function handleImageError(event: Event) {
|
||||
const img = event.target as HTMLImageElement;
|
||||
img.style.display = "none";
|
||||
const placeholder = img.nextElementSibling;
|
||||
if (placeholder && placeholder instanceof HTMLElement) {
|
||||
placeholder.style.display = "flex";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -66,18 +69,30 @@ function handleImageError(event: Event) {
|
||||
<!-- Cover avec icône de type en overlay -->
|
||||
<div class="container-cover">
|
||||
<img
|
||||
v-if="entry.album_art_uri"
|
||||
:src="entry.album_art_uri"
|
||||
ref="coverImageRef"
|
||||
:style="{
|
||||
opacity:
|
||||
cacheBustedUrl && imageLoaded && !imageError
|
||||
? 1
|
||||
: 0,
|
||||
visibility:
|
||||
cacheBustedUrl && imageLoaded && !imageError
|
||||
? 'visible'
|
||||
: 'hidden',
|
||||
position:
|
||||
cacheBustedUrl && imageLoaded && !imageError
|
||||
? 'relative'
|
||||
: 'absolute',
|
||||
}"
|
||||
:src="cacheBustedUrl || ''"
|
||||
:alt="entry.title"
|
||||
class="cover-image"
|
||||
loading="lazy"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div
|
||||
v-show="!cacheBustedUrl || imageError || !imageLoaded"
|
||||
class="cover-placeholder"
|
||||
:style="{
|
||||
display: entry.album_art_uri ? 'none' : 'flex',
|
||||
}"
|
||||
>
|
||||
<component :is="iconComponent" :size="28" />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, toRef, ref } from "vue";
|
||||
import { useRenderer } from "@/composables/useRenderers";
|
||||
import { useCoverImage } from "@/composables/useCoverImage";
|
||||
import { useUIStore } from "@/stores/ui";
|
||||
import { api } from "@/services/pmocontrol/api";
|
||||
import { Music, X } from "lucide-vue-next";
|
||||
@@ -21,10 +22,21 @@ const seekTargetMs = ref<number | null>(null);
|
||||
const showCoverOverlay = ref(false);
|
||||
const showMetadata = ref(false);
|
||||
|
||||
// Use the new cover image composable
|
||||
const albumArtUri = computed(() => metadata.value?.album_art_uri);
|
||||
const {
|
||||
imageLoaded,
|
||||
imageError,
|
||||
coverImageRef,
|
||||
cacheBustedUrl,
|
||||
handleImageLoad,
|
||||
handleImageError,
|
||||
} = useCoverImage(albumArtUri);
|
||||
|
||||
function openCoverOverlay() {
|
||||
if (hasCover.value) {
|
||||
showCoverOverlay.value = true;
|
||||
showMetadata.value = false; // Reset metadata visibility when opening
|
||||
showMetadata.value = true; // Afficher les métadonnées par défaut
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +83,9 @@ const progressPercent = computed(() => {
|
||||
const position = state.value?.position_ms;
|
||||
const duration = state.value?.duration_ms;
|
||||
if (position && duration && duration > 0) {
|
||||
return (position / duration) * 100;
|
||||
// Plafonner à 100% pour éviter que le curseur dépasse en cas de délai de mise à jour
|
||||
const percent = (position / duration) * 100;
|
||||
return Math.min(percent, 100);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
@@ -88,12 +102,9 @@ function formatTime(ms: number | null | undefined): string {
|
||||
const currentTime = computed(() => formatTime(state.value?.position_ms));
|
||||
const totalTime = computed(() => formatTime(state.value?.duration_ms));
|
||||
|
||||
const hasCover = computed(() => !!metadata.value?.album_art_uri);
|
||||
|
||||
function handleImageError(event: Event) {
|
||||
const img = event.target as HTMLImageElement;
|
||||
img.style.display = "none";
|
||||
}
|
||||
const hasCover = computed(
|
||||
() => !!metadata.value?.album_art_uri && !imageError.value,
|
||||
);
|
||||
|
||||
// Calculer le pourcentage à partir d'une position X dans la barre
|
||||
function calculateProgressFromX(
|
||||
@@ -186,6 +197,148 @@ async function handleProgressBarClick(event: MouseEvent) {
|
||||
isSeeking.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Fonctions pour la progress bar de l'overlay (réutilisent la même logique)
|
||||
function handleOverlayProgressBarClick(event: MouseEvent) {
|
||||
handleProgressBarClick(event);
|
||||
}
|
||||
|
||||
function handleOverlayProgressBarMouseDown(event: MouseEvent) {
|
||||
handleProgressBarMouseDown(event);
|
||||
}
|
||||
|
||||
// Gestion tactile pour la progress bar de l'overlay
|
||||
function handleOverlayProgressBarTouchStart(event: TouchEvent) {
|
||||
const duration = state.value?.duration_ms;
|
||||
if (!duration || duration === 0 || isSeeking.value) return;
|
||||
|
||||
const progressBar = event.currentTarget as HTMLElement;
|
||||
const touch = event.touches[0];
|
||||
if (!touch) return;
|
||||
|
||||
isDragging.value = true;
|
||||
dragProgress.value = calculateProgressFromX(touch.clientX, progressBar);
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (!isDragging.value) return;
|
||||
const moveTouch = e.touches[0];
|
||||
if (!moveTouch) return;
|
||||
dragProgress.value = calculateProgressFromX(
|
||||
moveTouch.clientX,
|
||||
progressBar,
|
||||
);
|
||||
};
|
||||
|
||||
const handleTouchEnd = async (e: TouchEvent) => {
|
||||
if (!isDragging.value) return;
|
||||
|
||||
const endTouch = e.changedTouches[0];
|
||||
if (!endTouch) return;
|
||||
|
||||
const finalPercent = calculateProgressFromX(
|
||||
endTouch.clientX,
|
||||
progressBar,
|
||||
);
|
||||
const newPositionMs = (finalPercent / 100) * duration;
|
||||
const newPositionSeconds = Math.floor(newPositionMs / 1000);
|
||||
|
||||
dragProgress.value = finalPercent;
|
||||
seekTargetMs.value = newPositionMs;
|
||||
isDragging.value = false;
|
||||
|
||||
try {
|
||||
isSeeking.value = true;
|
||||
await api.seekTo(props.rendererId, newPositionSeconds);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible de seek: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
seekTargetMs.value = null;
|
||||
dragProgress.value = 0;
|
||||
} finally {
|
||||
isSeeking.value = false;
|
||||
}
|
||||
|
||||
document.removeEventListener("touchmove", handleTouchMove);
|
||||
document.removeEventListener("touchend", handleTouchEnd);
|
||||
};
|
||||
|
||||
document.addEventListener("touchmove", handleTouchMove, { passive: true });
|
||||
document.addEventListener("touchend", handleTouchEnd);
|
||||
}
|
||||
|
||||
// Gestion du swipe down pour fermer l'overlay
|
||||
const swipeStartY = ref(0);
|
||||
const swipeCurrentY = ref(0);
|
||||
const isSwiping = ref(false);
|
||||
const swipeThreshold = 100; // Distance minimale pour fermer
|
||||
|
||||
function handleOverlayTouchStart(event: TouchEvent) {
|
||||
const touch = event.touches[0];
|
||||
if (!touch) return;
|
||||
swipeStartY.value = touch.clientY;
|
||||
swipeCurrentY.value = touch.clientY;
|
||||
isSwiping.value = true;
|
||||
|
||||
// Ajouter les listeners sur le document pour capturer tous les mouvements
|
||||
document.addEventListener("touchmove", handleSwipeTouchMove, {
|
||||
passive: true,
|
||||
});
|
||||
document.addEventListener("touchend", handleSwipeTouchEnd);
|
||||
document.addEventListener("touchcancel", handleSwipeTouchEnd);
|
||||
}
|
||||
|
||||
function handleSwipeTouchMove(event: TouchEvent) {
|
||||
if (!isSwiping.value) return;
|
||||
const touch = event.touches[0];
|
||||
if (!touch) return;
|
||||
swipeCurrentY.value = touch.clientY;
|
||||
}
|
||||
|
||||
function handleSwipeTouchEnd() {
|
||||
if (!isSwiping.value) return;
|
||||
|
||||
const swipeDistance = swipeCurrentY.value - swipeStartY.value;
|
||||
|
||||
// Si swipe vers le bas suffisant, fermer l'overlay
|
||||
if (swipeDistance > swipeThreshold) {
|
||||
closeCoverOverlay();
|
||||
}
|
||||
|
||||
isSwiping.value = false;
|
||||
swipeStartY.value = 0;
|
||||
swipeCurrentY.value = 0;
|
||||
|
||||
// Retirer les listeners
|
||||
document.removeEventListener("touchmove", handleSwipeTouchMove);
|
||||
document.removeEventListener("touchend", handleSwipeTouchEnd);
|
||||
document.removeEventListener("touchcancel", handleSwipeTouchEnd);
|
||||
}
|
||||
|
||||
// Ancienne fonction conservée pour compatibilité template (non utilisée)
|
||||
function handleOverlayTouchMove(event: TouchEvent) {
|
||||
handleSwipeTouchMove(event);
|
||||
}
|
||||
|
||||
function handleOverlayTouchEnd() {
|
||||
handleSwipeTouchEnd();
|
||||
}
|
||||
|
||||
// Calcul du décalage visuel pendant le swipe
|
||||
const swipeOffset = computed(() => {
|
||||
if (!isSwiping.value) return 0;
|
||||
const offset = swipeCurrentY.value - swipeStartY.value;
|
||||
// Ne permettre que le swipe vers le bas (positif)
|
||||
return Math.max(0, offset);
|
||||
});
|
||||
|
||||
// Opacité qui diminue pendant le swipe
|
||||
const swipeOpacity = computed(() => {
|
||||
if (!isSwiping.value) return 1;
|
||||
const offset = swipeOffset.value;
|
||||
// Réduire l'opacité progressivement
|
||||
return Math.max(0.3, 1 - offset / 300);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -197,14 +350,23 @@ async function handleProgressBarClick(event: MouseEvent) {
|
||||
@click="openCoverOverlay"
|
||||
>
|
||||
<img
|
||||
v-if="hasCover"
|
||||
:src="metadata?.album_art_uri!"
|
||||
v-if="cacheBustedUrl"
|
||||
ref="coverImageRef"
|
||||
:style="{
|
||||
opacity: imageLoaded && !imageError ? 1 : 0,
|
||||
visibility: imageLoaded && !imageError ? 'visible' : 'hidden',
|
||||
position: imageLoaded && !imageError ? 'relative' : 'absolute',
|
||||
}"
|
||||
:src="cacheBustedUrl"
|
||||
:alt="metadata?.album || 'Album cover'"
|
||||
class="cover-image"
|
||||
loading="lazy"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div v-else class="cover-placeholder">
|
||||
<div
|
||||
v-show="!cacheBustedUrl || imageError || !imageLoaded"
|
||||
class="cover-placeholder"
|
||||
>
|
||||
<Music :size="64" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -222,6 +384,7 @@ async function handleProgressBarClick(event: MouseEvent) {
|
||||
class="progress-bar"
|
||||
@click="handleProgressBarClick"
|
||||
@mousedown="handleProgressBarMouseDown"
|
||||
@touchstart="handleOverlayProgressBarTouchStart"
|
||||
:class="{ seeking: isSeeking, dragging: isDragging }"
|
||||
>
|
||||
<div
|
||||
@@ -247,7 +410,15 @@ async function handleProgressBarClick(event: MouseEvent) {
|
||||
>
|
||||
<div
|
||||
class="cover-overlay-content"
|
||||
:class="{ swiping: isSwiping }"
|
||||
:style="{
|
||||
transform: `translateY(${swipeOffset}px)`,
|
||||
opacity: swipeOpacity,
|
||||
}"
|
||||
@click.stop="handleOverlayContentClick"
|
||||
@touchstart="handleOverlayTouchStart"
|
||||
@touchmove="handleOverlayTouchMove"
|
||||
@touchend="handleOverlayTouchEnd"
|
||||
>
|
||||
<button
|
||||
class="cover-overlay-close"
|
||||
@@ -257,8 +428,8 @@ async function handleProgressBarClick(event: MouseEvent) {
|
||||
<X :size="24" />
|
||||
</button>
|
||||
<img
|
||||
v-if="hasCover"
|
||||
:src="metadata?.album_art_uri!"
|
||||
v-if="hasCover && cacheBustedUrl"
|
||||
:src="cacheBustedUrl"
|
||||
:alt="metadata?.album || 'Album cover'"
|
||||
class="cover-overlay-image"
|
||||
/>
|
||||
@@ -267,11 +438,46 @@ async function handleProgressBarClick(event: MouseEvent) {
|
||||
v-if="metadata && showMetadata"
|
||||
class="cover-overlay-metadata"
|
||||
>
|
||||
<h2>{{ metadata.title }}</h2>
|
||||
<p class="artist">{{ metadata.artist }}</p>
|
||||
<p v-if="metadata.album" class="album">
|
||||
{{ metadata.album }}
|
||||
</p>
|
||||
<div class="overlay-metadata-text">
|
||||
<h2>{{ metadata.title }}</h2>
|
||||
<p class="artist">{{ metadata.artist }}</p>
|
||||
<p v-if="metadata.album" class="album">
|
||||
{{ metadata.album }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="overlay-progress-section">
|
||||
<div
|
||||
class="overlay-progress-bar"
|
||||
@click.stop="
|
||||
handleOverlayProgressBarClick
|
||||
"
|
||||
@mousedown.stop="
|
||||
handleOverlayProgressBarMouseDown
|
||||
"
|
||||
@touchstart.stop="
|
||||
handleOverlayProgressBarTouchStart
|
||||
"
|
||||
:class="{
|
||||
seeking: isSeeking,
|
||||
dragging: isDragging,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="overlay-progress-bar-fill"
|
||||
:style="{
|
||||
width: `${progressPercent}%`,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="overlay-progress-bar-thumb"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overlay-time-display">
|
||||
<span>{{ currentTime }}</span>
|
||||
<span>{{ totalTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
@@ -529,12 +735,15 @@ async function handleProgressBarClick(event: MouseEvent) {
|
||||
backdrop-filter: blur(20px) saturate(150%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(150%);
|
||||
padding: 16px;
|
||||
/* Empêcher le pull-to-refresh Android de se déclencher */
|
||||
overscroll-behavior: contain;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.cover-overlay-content {
|
||||
position: relative;
|
||||
width: calc(100vw - 32px);
|
||||
height: calc(100vh - 32px);
|
||||
height: calc(100dvh - 32px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -556,7 +765,7 @@ async function handleProgressBarClick(event: MouseEvent) {
|
||||
}
|
||||
|
||||
.cover-overlay-metadata {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -603,7 +812,7 @@ async function handleProgressBarClick(event: MouseEvent) {
|
||||
|
||||
.cover-overlay-image {
|
||||
width: calc(100vw - 64px);
|
||||
height: calc(100vh - 64px);
|
||||
height: calc(100dvh - 64px);
|
||||
border-radius: 16px;
|
||||
box-shadow:
|
||||
0 30px 80px rgba(0, 0, 0, 0.6),
|
||||
@@ -614,16 +823,16 @@ async function handleProgressBarClick(event: MouseEvent) {
|
||||
.cover-overlay-metadata {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
left: 24px;
|
||||
right: 24px;
|
||||
left: 15%;
|
||||
right: 15%;
|
||||
text-align: center;
|
||||
padding: var(--spacing-lg);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(30px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(30px) saturate(180%);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.cover-overlay-metadata h2 {
|
||||
@@ -697,13 +906,13 @@ async function handleProgressBarClick(event: MouseEvent) {
|
||||
|
||||
.cover-overlay-content {
|
||||
width: calc(100vw - 24px);
|
||||
height: calc(100vh - 24px);
|
||||
height: calc(100dvh - 24px);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.cover-overlay-image {
|
||||
width: calc(100vw - 48px);
|
||||
height: calc(100vh - 48px);
|
||||
height: calc(100dvh - 48px);
|
||||
}
|
||||
|
||||
.cover-overlay-metadata {
|
||||
@@ -729,12 +938,12 @@ async function handleProgressBarClick(event: MouseEvent) {
|
||||
|
||||
.cover-overlay-content {
|
||||
width: calc(100vw - 24px);
|
||||
height: calc(100vh - 24px);
|
||||
height: calc(100dvh - 24px);
|
||||
}
|
||||
|
||||
.cover-overlay-image {
|
||||
width: calc(100vw - 48px);
|
||||
height: calc(100vh - 48px);
|
||||
height: calc(100dvh - 48px);
|
||||
}
|
||||
|
||||
.cover-overlay-metadata {
|
||||
@@ -770,4 +979,159 @@ async function handleProgressBarClick(event: MouseEvent) {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Swipe down pour fermer */
|
||||
.cover-overlay-content {
|
||||
transition:
|
||||
transform 0.1s ease-out,
|
||||
opacity 0.1s ease-out;
|
||||
}
|
||||
|
||||
.cover-overlay-content:not(.swiping) {
|
||||
transition:
|
||||
transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||
opacity 0.3s ease;
|
||||
}
|
||||
|
||||
/* Progress bar dans l'overlay - style glassmorphism macOS */
|
||||
.overlay-metadata-text {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.overlay-progress-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
padding-top: var(--spacing-md);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.overlay-progress-bar {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
box-shadow:
|
||||
inset 0 1px 3px rgba(0, 0, 0, 0.4),
|
||||
0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.overlay-progress-bar:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.overlay-progress-bar.dragging {
|
||||
cursor: grabbing;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.overlay-progress-bar.seeking {
|
||||
cursor: wait;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.overlay-progress-bar-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #059669 0%, #10b981 50%, #34d399 100%);
|
||||
border-radius: 4px;
|
||||
transition: width 0.1s linear;
|
||||
z-index: 1;
|
||||
box-shadow: 0 0 12px rgba(16, 185, 129, 0.6);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.overlay-progress-bar.dragging .overlay-progress-bar-fill {
|
||||
transition: width 0s;
|
||||
box-shadow: 0 0 16px rgba(16, 185, 129, 0.9);
|
||||
}
|
||||
|
||||
.overlay-progress-bar-thumb {
|
||||
position: absolute;
|
||||
right: -12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 2px solid rgba(16, 185, 129, 0.8);
|
||||
border-radius: 50%;
|
||||
box-shadow:
|
||||
0 2px 8px rgba(0, 0, 0, 0.5),
|
||||
0 0 12px rgba(16, 185, 129, 0.4),
|
||||
inset 0 1px 2px rgba(255, 255, 255, 0.5);
|
||||
transition: all 0.2s ease;
|
||||
z-index: 2;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.overlay-progress-bar:hover .overlay-progress-bar-thumb {
|
||||
transform: translateY(-50%) scale(1.15);
|
||||
box-shadow:
|
||||
0 3px 12px rgba(0, 0, 0, 0.6),
|
||||
0 0 16px rgba(16, 185, 129, 0.5),
|
||||
inset 0 1px 2px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.overlay-progress-bar.dragging .overlay-progress-bar-thumb {
|
||||
transform: translateY(-50%) scale(1.25);
|
||||
cursor: grabbing;
|
||||
box-shadow:
|
||||
0 4px 16px rgba(0, 0, 0, 0.7),
|
||||
0 0 20px rgba(16, 185, 129, 0.6),
|
||||
inset 0 1px 2px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.overlay-time-display {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: var(--text-sm);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Responsive progress bar overlay */
|
||||
@media (max-width: 768px) {
|
||||
.overlay-progress-bar {
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.overlay-progress-bar-thumb {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
right: -14px;
|
||||
}
|
||||
|
||||
.overlay-time-display {
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
}
|
||||
|
||||
/* Mode kiosque - progress bar overlay */
|
||||
@media (max-height: 700px) and (orientation: landscape) {
|
||||
.overlay-progress-section {
|
||||
padding-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.overlay-progress-bar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.overlay-progress-bar-thumb {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
right: -10px;
|
||||
}
|
||||
|
||||
.overlay-time-display {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { useMediaServers } from "@/composables/useMediaServers";
|
||||
import { useRenderers } from "@/composables/useRenderers";
|
||||
import { useUIStore } from "@/stores/ui";
|
||||
import Breadcrumb from "./Breadcrumb.vue";
|
||||
import ContainerItem from "./ContainerItem.vue";
|
||||
import MediaItem from "./MediaItem.vue";
|
||||
import { Loader2 } from "lucide-vue-next";
|
||||
import { Loader2, Search, X } from "lucide-vue-next";
|
||||
|
||||
const props = defineProps<{
|
||||
serverId: string;
|
||||
@@ -16,20 +16,45 @@ const props = defineProps<{
|
||||
const {
|
||||
getBrowseCached,
|
||||
browseContainer,
|
||||
loadMoreBrowse,
|
||||
hasMore,
|
||||
currentPath: breadcrumbPath,
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
searchResults,
|
||||
searchQuery,
|
||||
searchServer,
|
||||
clearSearch,
|
||||
} = useMediaServers();
|
||||
|
||||
const searchInput = ref('');
|
||||
|
||||
async function handleSearch() {
|
||||
if (searchInput.value.trim()) {
|
||||
await searchServer(props.serverId, searchInput.value.trim());
|
||||
}
|
||||
}
|
||||
|
||||
function handleClearSearch() {
|
||||
searchInput.value = '';
|
||||
clearSearch();
|
||||
}
|
||||
|
||||
const isSearchMode = computed(() => searchQuery.value !== '');
|
||||
|
||||
const { playContent, addToQueue, attachAndPlayPlaylist, attachPlaylist } =
|
||||
useRenderers();
|
||||
const uiStore = useUIStore();
|
||||
|
||||
// Flag pour gérer le rechargement automatique
|
||||
const isRefreshing = ref(false);
|
||||
const sentinelRef = ref<HTMLElement | null>(null);
|
||||
let observer: IntersectionObserver | null = null;
|
||||
|
||||
const browseData = computed(() =>
|
||||
getBrowseCached(props.serverId, props.containerId),
|
||||
isSearchMode.value
|
||||
? searchResults.value
|
||||
: getBrowseCached(props.serverId, props.containerId),
|
||||
);
|
||||
|
||||
const containers = computed(
|
||||
@@ -40,6 +65,28 @@ const items = computed(
|
||||
() => browseData.value?.entries.filter((e) => !e.is_container) || [],
|
||||
);
|
||||
|
||||
const canLoadMore = computed(() => !isSearchMode.value && hasMore(props.serverId, props.containerId));
|
||||
|
||||
function setupObserver() {
|
||||
if (observer) observer.disconnect();
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting && canLoadMore.value && !loadingMore.value) {
|
||||
loadMoreBrowse(props.serverId, props.containerId);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
if (sentinelRef.value) observer.observe(sentinelRef.value);
|
||||
}
|
||||
|
||||
onMounted(() => setupObserver());
|
||||
onBeforeUnmount(() => observer?.disconnect());
|
||||
|
||||
watch(sentinelRef, (el) => {
|
||||
if (el) setupObserver();
|
||||
});
|
||||
|
||||
// Charger le container au montage et quand containerId change
|
||||
watch(
|
||||
() => props.containerId,
|
||||
@@ -51,15 +98,10 @@ watch(
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// Recharger automatiquement si le cache est invalidé (ex: après un ContainersUpdated SSE)
|
||||
// Cela se produit notamment quand on clique sur "Lire maintenant" sur une playlist,
|
||||
// ce qui déclenche un événement ContainersUpdated qui invalide le cache
|
||||
// Le serveur contrôle déjà le flux SSE, pas besoin de debouncing côté client
|
||||
// Recharger si le cache est invalidé (ContainersUpdated SSE)
|
||||
watch(
|
||||
() => browseData.value,
|
||||
async (data) => {
|
||||
// Si browseData devient undefined alors que containerId est présent,
|
||||
// et qu'on n'est pas déjà en train de charger, recharger immédiatement
|
||||
if (
|
||||
!data &&
|
||||
props.containerId &&
|
||||
@@ -144,6 +186,31 @@ async function handleQueueItem(itemId: string, rendererId: string) {
|
||||
@navigate="handleNavigate"
|
||||
/>
|
||||
|
||||
<!-- Search bar -->
|
||||
<div class="search-bar">
|
||||
<div class="search-input-wrapper">
|
||||
<Search :size="16" class="search-icon" />
|
||||
<input
|
||||
v-model="searchInput"
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Rechercher..."
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<button
|
||||
v-if="searchInput || isSearchMode"
|
||||
class="search-clear"
|
||||
@click="handleClearSearch"
|
||||
title="Effacer"
|
||||
>
|
||||
<X :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn-primary search-btn" @click="handleSearch">
|
||||
Rechercher
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="browser-loading">
|
||||
<Loader2 :size="32" class="spinner" />
|
||||
@@ -199,7 +266,15 @@ async function handleQueueItem(itemId: string, rendererId: string) {
|
||||
v-if="!containers.length && !items.length"
|
||||
class="browser-empty"
|
||||
>
|
||||
<p>Ce dossier est vide</p>
|
||||
<p>{{ isSearchMode ? 'Aucun résultat' : 'Ce dossier est vide' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Sentinel infinite scroll -->
|
||||
<div ref="sentinelRef" class="scroll-sentinel" />
|
||||
|
||||
<!-- Spinner load more -->
|
||||
<div v-if="loadingMore" class="load-more-spinner">
|
||||
<Loader2 :size="20" class="spinner" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -228,14 +303,7 @@ async function handleQueueItem(itemId: string, rendererId: string) {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
/* @keyframes spin is now global in pmocontrol.css */
|
||||
|
||||
/* Error */
|
||||
.browser-error {
|
||||
@@ -295,6 +363,75 @@ async function handleQueueItem(itemId: string, rendererId: string) {
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.scroll-sentinel {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.load-more-spinner {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-md);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: var(--spacing-sm);
|
||||
color: var(--color-text-tertiary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: var(--spacing-xs) var(--spacing-xl) var(--spacing-xs) calc(var(--spacing-sm) + 20px);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.search-clear {
|
||||
position: absolute;
|
||||
right: var(--spacing-xs);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.search-clear:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
white-space: nowrap;
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.browser-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
|
||||
@@ -1,185 +1,208 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContainerEntry } from '@/services/pmocontrol/types'
|
||||
import { Music } from 'lucide-vue-next'
|
||||
import ActionMenu from './ActionMenu.vue'
|
||||
import { computed } from "vue";
|
||||
import type { ContainerEntry } from "@/services/pmocontrol/types";
|
||||
import { Music } from "lucide-vue-next";
|
||||
import ActionMenu from "./ActionMenu.vue";
|
||||
import { useCoverImage } from "@/composables/useCoverImage";
|
||||
|
||||
const props = defineProps<{
|
||||
entry: ContainerEntry
|
||||
serverId: string
|
||||
showActions?: boolean
|
||||
}>()
|
||||
entry: ContainerEntry;
|
||||
serverId: string;
|
||||
showActions?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
playNow: [itemId: string, rendererId: string]
|
||||
addToQueue: [itemId: string, rendererId: string]
|
||||
}>()
|
||||
playNow: [itemId: string, rendererId: string];
|
||||
addToQueue: [itemId: string, rendererId: string];
|
||||
}>();
|
||||
|
||||
function handleImageError(event: Event) {
|
||||
const img = event.target as HTMLImageElement
|
||||
img.style.display = 'none'
|
||||
const placeholder = img.nextElementSibling
|
||||
if (placeholder && placeholder instanceof HTMLElement) {
|
||||
placeholder.style.display = 'flex'
|
||||
}
|
||||
}
|
||||
// Use the new cover image composable
|
||||
const albumArtUri = computed(() => props.entry.album_art_uri);
|
||||
const {
|
||||
imageLoaded,
|
||||
imageError,
|
||||
coverImageRef,
|
||||
cacheBustedUrl,
|
||||
handleImageLoad,
|
||||
handleImageError,
|
||||
} = useCoverImage(albumArtUri);
|
||||
|
||||
function handlePlayNow(rendererId: string) {
|
||||
emit('playNow', props.entry.id, rendererId)
|
||||
emit("playNow", props.entry.id, rendererId);
|
||||
}
|
||||
|
||||
function handleAddToQueue(rendererId: string) {
|
||||
emit('addToQueue', props.entry.id, rendererId)
|
||||
emit("addToQueue", props.entry.id, rendererId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="media-item">
|
||||
<!-- Cover miniature -->
|
||||
<div class="media-cover">
|
||||
<img
|
||||
v-if="entry.album_art_uri"
|
||||
:src="entry.album_art_uri"
|
||||
:alt="entry.album || entry.title"
|
||||
class="cover-image"
|
||||
loading="lazy"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div class="cover-placeholder" :style="{ display: entry.album_art_uri ? 'none' : 'flex' }">
|
||||
<Music :size="20" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="media-item">
|
||||
<!-- Cover miniature -->
|
||||
<div class="media-cover">
|
||||
<img
|
||||
ref="coverImageRef"
|
||||
:style="{
|
||||
opacity:
|
||||
cacheBustedUrl && imageLoaded && !imageError ? 1 : 0,
|
||||
visibility:
|
||||
cacheBustedUrl && imageLoaded && !imageError
|
||||
? 'visible'
|
||||
: 'hidden',
|
||||
position:
|
||||
cacheBustedUrl && imageLoaded && !imageError
|
||||
? 'relative'
|
||||
: 'absolute',
|
||||
}"
|
||||
:src="cacheBustedUrl || ''"
|
||||
:alt="entry.album || entry.title"
|
||||
class="cover-image"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div
|
||||
v-show="!cacheBustedUrl || imageError || !imageLoaded"
|
||||
class="cover-placeholder"
|
||||
>
|
||||
<Music :size="20" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metadata -->
|
||||
<div class="media-metadata">
|
||||
<div class="media-title">{{ entry.title }}</div>
|
||||
<div class="media-details">
|
||||
<span v-if="entry.artist" class="media-artist">{{ entry.artist }}</span>
|
||||
<span v-if="entry.album" class="media-album">{{ entry.album }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Metadata -->
|
||||
<div class="media-metadata">
|
||||
<div class="media-title">{{ entry.title }}</div>
|
||||
<div class="media-details">
|
||||
<span v-if="entry.artist" class="media-artist">{{
|
||||
entry.artist
|
||||
}}</span>
|
||||
<span v-if="entry.album" class="media-album">{{
|
||||
entry.album
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions menu -->
|
||||
<div class="media-actions">
|
||||
<ActionMenu
|
||||
type="item"
|
||||
:entry-id="entry.id"
|
||||
:server-id="serverId"
|
||||
@play-now="handlePlayNow"
|
||||
@add-to-queue="handleAddToQueue"
|
||||
/>
|
||||
<!-- Actions menu -->
|
||||
<div class="media-actions">
|
||||
<ActionMenu
|
||||
type="item"
|
||||
:entry-id="entry.id"
|
||||
:server-id="serverId"
|
||||
@play-now="handlePlayNow"
|
||||
@add-to-queue="handleAddToQueue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.media-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-sm);
|
||||
border-radius: var(--radius-md);
|
||||
transition: background-color var(--transition-fast);
|
||||
border: 1px solid transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-sm);
|
||||
border-radius: var(--radius-md);
|
||||
transition: background-color var(--transition-fast);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.media-item:hover {
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-color: var(--color-border);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
/* Cover */
|
||||
.media-cover {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.cover-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.cover-placeholder {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-tertiary);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* Metadata */
|
||||
.media-metadata {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.media-title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.media-details {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.media-artist {
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.media-album {
|
||||
flex-shrink: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex-shrink: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.media-album::before {
|
||||
content: '•';
|
||||
margin-right: var(--spacing-sm);
|
||||
content: "•";
|
||||
margin-right: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.media-actions {
|
||||
flex-shrink: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,139 +1,170 @@
|
||||
<script setup lang="ts">
|
||||
import { Music, Play } from 'lucide-vue-next'
|
||||
import type { QueueItem } from '@/services/pmocontrol/types'
|
||||
import { computed } from "vue";
|
||||
import { Music, Play } from "lucide-vue-next";
|
||||
import type { QueueItem } from "@/services/pmocontrol/types";
|
||||
import { useCoverImage } from "@/composables/useCoverImage";
|
||||
|
||||
defineProps<{
|
||||
item: QueueItem
|
||||
isCurrent: boolean
|
||||
}>()
|
||||
const props = defineProps<{
|
||||
item: QueueItem;
|
||||
isCurrent: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [item: QueueItem]
|
||||
}>()
|
||||
click: [item: QueueItem];
|
||||
}>();
|
||||
|
||||
// Use the new cover image composable
|
||||
const albumArtUri = computed(() => props.item.album_art_uri);
|
||||
const {
|
||||
imageLoaded,
|
||||
imageError,
|
||||
coverImageRef,
|
||||
cacheBustedUrl,
|
||||
handleImageLoad,
|
||||
handleImageError,
|
||||
} = useCoverImage(albumArtUri);
|
||||
|
||||
function handleClick(item: QueueItem) {
|
||||
console.log('[QueueItem] Click detected on item:', item.index, item.title)
|
||||
emit('click', item)
|
||||
console.log("[QueueItem] Click detected on item:", item.index, item.title);
|
||||
emit("click", item);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['queue-item', { current: isCurrent }]" @click="handleClick(item)">
|
||||
<!-- Indicateur piste en cours -->
|
||||
<div class="current-indicator" v-if="isCurrent">
|
||||
<Play :size="16" fill="currentColor" />
|
||||
</div>
|
||||
<div
|
||||
:class="['queue-item', { current: isCurrent }]"
|
||||
@click="handleClick(item)"
|
||||
>
|
||||
<!-- Indicateur piste en cours -->
|
||||
<div class="current-indicator" v-if="isCurrent">
|
||||
<Play :size="16" fill="currentColor" />
|
||||
</div>
|
||||
|
||||
<!-- Index (1-based pour l'affichage) -->
|
||||
<span class="item-index">{{ item.index + 1 }}</span>
|
||||
<!-- Index (1-based pour l'affichage) -->
|
||||
<span class="item-index">{{ item.index + 1 }}</span>
|
||||
|
||||
<!-- Cover miniature -->
|
||||
<div class="item-cover">
|
||||
<img
|
||||
v-if="item.album_art_uri"
|
||||
:src="item.album_art_uri"
|
||||
:alt="item.album || 'Album cover'"
|
||||
class="cover-image"
|
||||
loading="lazy"
|
||||
@error="(e: Event) => (e.target as HTMLImageElement).style.display = 'none'"
|
||||
/>
|
||||
<Music v-else :size="20" />
|
||||
</div>
|
||||
<!-- Cover miniature -->
|
||||
<div class="item-cover">
|
||||
<img
|
||||
ref="coverImageRef"
|
||||
:style="{
|
||||
opacity:
|
||||
cacheBustedUrl && imageLoaded && !imageError ? 1 : 0,
|
||||
visibility:
|
||||
cacheBustedUrl && imageLoaded && !imageError
|
||||
? 'visible'
|
||||
: 'hidden',
|
||||
position:
|
||||
cacheBustedUrl && imageLoaded && !imageError
|
||||
? 'relative'
|
||||
: 'absolute',
|
||||
}"
|
||||
:src="cacheBustedUrl || ''"
|
||||
:alt="item.album || 'Album cover'"
|
||||
class="cover-image"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<Music
|
||||
v-show="!cacheBustedUrl || imageError || !imageLoaded"
|
||||
:size="20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Métadonnées -->
|
||||
<div class="item-metadata">
|
||||
<div class="item-title">{{ item.title || 'Sans titre' }}</div>
|
||||
<div class="item-artist">
|
||||
{{ item.artist || 'Artiste inconnu' }}
|
||||
<span v-if="item.album"> • {{ item.album }}</span>
|
||||
</div>
|
||||
<!-- Métadonnées -->
|
||||
<div class="item-metadata">
|
||||
<div class="item-title">{{ item.title || "Sans titre" }}</div>
|
||||
<div class="item-artist">
|
||||
{{ item.artist || "Artiste inconnu" }}
|
||||
<span v-if="item.album"> • {{ item.album }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.queue-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-sm);
|
||||
border-radius: var(--radius-md);
|
||||
transition: background-color var(--transition-fast);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-sm);
|
||||
border-radius: var(--radius-md);
|
||||
transition: background-color var(--transition-fast);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.queue-item:hover {
|
||||
background-color: var(--color-bg-secondary);
|
||||
background-color: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.queue-item.current {
|
||||
background-color: var(--status-playing-bg);
|
||||
border: 1px solid var(--status-playing);
|
||||
font-weight: 600;
|
||||
background-color: var(--status-playing-bg);
|
||||
border: 1px solid var(--status-playing);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.current-indicator {
|
||||
position: absolute;
|
||||
left: var(--spacing-xs);
|
||||
color: var(--status-playing);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
left: var(--spacing-xs);
|
||||
color: var(--status-playing);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.item-index {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
min-width: 2rem;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
min-width: 2rem;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.queue-item.current .item-index {
|
||||
margin-left: var(--spacing-lg);
|
||||
margin-left: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.item-cover {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-tertiary);
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-tertiary);
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cover-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.item-metadata {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.queue-item.current .item-title {
|
||||
color: var(--status-playing);
|
||||
color: var(--status-playing);
|
||||
}
|
||||
|
||||
.item-artist {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, ref, watch, nextTick, toRef } from "vue";
|
||||
import { useRenderer } from "@/composables/useRenderers";
|
||||
import QueueItem from "./QueueItem.vue";
|
||||
import { Link } from "lucide-vue-next";
|
||||
import { Link, Radio, RefreshCw } from "lucide-vue-next";
|
||||
import type { QueueItem as QueueItemType } from "@/services/pmocontrol/types";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -13,7 +13,7 @@ const emit = defineEmits<{
|
||||
clickItem: [item: QueueItemType];
|
||||
}>();
|
||||
|
||||
const { queue, binding } = useRenderer(toRef(props, "rendererId"));
|
||||
const { queue, binding, isStream, queueRefreshing } = useRenderer(toRef(props, "rendererId"));
|
||||
|
||||
const isAttached = computed(() => !!binding.value);
|
||||
|
||||
@@ -59,10 +59,25 @@ watch(
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<!-- Indicateur playlist attachée -->
|
||||
<div v-if="isAttached" class="binding-indicator">
|
||||
<Link :size="16" />
|
||||
<span class="binding-text"> Attachée à une playlist </span>
|
||||
<!-- Indicateurs de status -->
|
||||
<div class="status-indicators">
|
||||
<!-- Indicateur playlist attachée -->
|
||||
<div v-if="isAttached" class="binding-indicator">
|
||||
<Link :size="16" />
|
||||
<span class="binding-text"> Attachée à une playlist </span>
|
||||
</div>
|
||||
|
||||
<!-- Indicateur web radio -->
|
||||
<div v-if="isStream" class="stream-indicator">
|
||||
<Radio :size="16" />
|
||||
<span class="stream-text"> Web Radio </span>
|
||||
</div>
|
||||
|
||||
<!-- Indicateur de mise à jour de queue -->
|
||||
<div v-if="queueRefreshing" class="refresh-indicator">
|
||||
<RefreshCw :size="14" class="refresh-icon" />
|
||||
<span class="refresh-text"> Mise à jour... </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -111,6 +126,12 @@ watch(
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.status-indicators {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.binding-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -129,6 +150,51 @@ watch(
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.stream-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
background-color: rgba(147, 51, 234, 0.1);
|
||||
color: #9333ea;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
border: 1px solid #9333ea;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.stream-text {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.refresh-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
background-color: var(--color-bg-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--color-border);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.refresh-icon {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.refresh-text {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.queue-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -1,331 +1,355 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import type {
|
||||
RendererCapabilitiesSummary,
|
||||
RendererSummary,
|
||||
RendererState,
|
||||
} from '@/services/pmocontrol/types'
|
||||
import StatusBadge from './StatusBadge.vue'
|
||||
import { Music, Volume2, VolumeX } from 'lucide-vue-next'
|
||||
RendererCapabilitiesSummary,
|
||||
RendererSummary,
|
||||
RendererState,
|
||||
} from "@/services/pmocontrol/types";
|
||||
import StatusBadge from "./StatusBadge.vue";
|
||||
import { Music, Volume2, VolumeX } from "lucide-vue-next";
|
||||
import { useCoverImage } from "@/composables/useCoverImage";
|
||||
|
||||
const props = defineProps<{
|
||||
renderer: RendererSummary
|
||||
state: RendererState | null
|
||||
}>()
|
||||
renderer: RendererSummary;
|
||||
state: RendererState | null;
|
||||
}>();
|
||||
|
||||
const router = useRouter()
|
||||
const router = useRouter();
|
||||
|
||||
// Métadonnées proviennent directement de l'état du renderer (API + SSE)
|
||||
const metadata = computed(() => props.state?.current_track)
|
||||
const metadata = computed(() => props.state?.current_track);
|
||||
|
||||
// Use the new cover image composable
|
||||
const albumArtUri = computed(() => metadata.value?.album_art_uri);
|
||||
const {
|
||||
imageLoaded,
|
||||
imageError,
|
||||
coverImageRef,
|
||||
cacheBustedUrl,
|
||||
handleImageLoad,
|
||||
handleImageError,
|
||||
} = useCoverImage(albumArtUri);
|
||||
|
||||
const protocolLabel = computed(() => {
|
||||
switch (props.renderer.protocol) {
|
||||
case 'upnp':
|
||||
return 'UPnP AV'
|
||||
case 'openhome':
|
||||
return 'OpenHome'
|
||||
case 'hybrid':
|
||||
return 'Hybrid (UPnP + OpenHome)'
|
||||
default:
|
||||
return 'Inconnu'
|
||||
}
|
||||
})
|
||||
switch (props.renderer.protocol) {
|
||||
case "upnp":
|
||||
return "UPnP AV";
|
||||
case "openhome":
|
||||
return "OpenHome";
|
||||
case "hybrid":
|
||||
return "Hybrid (UPnP + OpenHome)";
|
||||
default:
|
||||
return "Inconnu";
|
||||
}
|
||||
});
|
||||
|
||||
const protocolClass = computed(() => {
|
||||
switch (props.renderer.protocol) {
|
||||
case 'upnp':
|
||||
return 'protocol-upnp'
|
||||
case 'openhome':
|
||||
return 'protocol-openhome'
|
||||
case 'hybrid':
|
||||
return 'protocol-hybrid'
|
||||
default:
|
||||
return 'protocol-unknown'
|
||||
}
|
||||
})
|
||||
switch (props.renderer.protocol) {
|
||||
case "upnp":
|
||||
return "protocol-upnp";
|
||||
case "openhome":
|
||||
return "protocol-openhome";
|
||||
case "hybrid":
|
||||
return "protocol-hybrid";
|
||||
default:
|
||||
return "protocol-unknown";
|
||||
}
|
||||
});
|
||||
|
||||
const capabilityBadges = computed(() => {
|
||||
const caps = props.renderer.capabilities
|
||||
if (!caps) return []
|
||||
const mapping: Array<{ key: keyof RendererCapabilitiesSummary; label: string }> = [
|
||||
{ key: 'has_avtransport', label: 'AVTransport' },
|
||||
{ key: 'has_oh_playlist', label: 'OpenHome' },
|
||||
{ key: 'has_linkplay_http', label: 'Hybrid' },
|
||||
{ key: 'has_oh_volume', label: 'Vol' },
|
||||
{ key: 'has_oh_time', label: 'Time' },
|
||||
{ key: 'has_oh_info', label: 'Info' },
|
||||
]
|
||||
return mapping.filter(({ key }) => caps[key]).map(({ key, label }) => ({ key, label }))
|
||||
})
|
||||
const caps = props.renderer.capabilities;
|
||||
if (!caps) return [];
|
||||
const mapping: Array<{
|
||||
key: keyof RendererCapabilitiesSummary;
|
||||
label: string;
|
||||
}> = [
|
||||
{ key: "has_avtransport", label: "AVTransport" },
|
||||
{ key: "has_oh_playlist", label: "OpenHome" },
|
||||
{ key: "has_linkplay_http", label: "Hybrid" },
|
||||
{ key: "has_oh_volume", label: "Vol" },
|
||||
{ key: "has_oh_time", label: "Time" },
|
||||
{ key: "has_oh_info", label: "Info" },
|
||||
];
|
||||
return mapping
|
||||
.filter(({ key }) => caps[key])
|
||||
.map(({ key, label }) => ({ key, label }));
|
||||
});
|
||||
|
||||
const hasCover = computed(() => !!metadata.value?.album_art_uri)
|
||||
const hasCover = computed(
|
||||
() => !!metadata.value?.album_art_uri && !imageError.value,
|
||||
);
|
||||
|
||||
function goToRenderer() {
|
||||
router.push(`/renderer/${props.renderer.id}`)
|
||||
router.push(`/renderer/${props.renderer.id}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['renderer-card', { offline: !renderer.online }]">
|
||||
<!-- Header -->
|
||||
<div class="card-header">
|
||||
<div class="header-content">
|
||||
<h3 class="renderer-name">{{ renderer.friendly_name }}</h3>
|
||||
<p class="renderer-model">{{ renderer.model_name }}</p>
|
||||
</div>
|
||||
<div class="badges">
|
||||
<span :class="['protocol-badge', protocolClass]">
|
||||
{{ protocolLabel }}
|
||||
</span>
|
||||
<StatusBadge v-if="state" :status="state.transport_state" />
|
||||
</div>
|
||||
</div>
|
||||
<div :class="['renderer-card', { offline: !renderer.online }]">
|
||||
<!-- Header -->
|
||||
<div class="card-header">
|
||||
<div class="header-content">
|
||||
<h3 class="renderer-name">{{ renderer.friendly_name }}</h3>
|
||||
<p class="renderer-model">{{ renderer.model_name }}</p>
|
||||
</div>
|
||||
<div class="badges">
|
||||
<span :class="['protocol-badge', protocolClass]">
|
||||
{{ protocolLabel }}
|
||||
</span>
|
||||
<StatusBadge v-if="state" :status="state.transport_state" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cover Art -->
|
||||
<div v-if="capabilityBadges.length" class="capabilities">
|
||||
<span v-for="badge in capabilityBadges" :key="badge.key" class="capability-badge">
|
||||
{{ badge.label }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Cover Art -->
|
||||
<div v-if="capabilityBadges.length" class="capabilities">
|
||||
<span
|
||||
v-for="badge in capabilityBadges"
|
||||
:key="badge.key"
|
||||
class="capability-badge"
|
||||
>
|
||||
{{ badge.label }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card-cover">
|
||||
<img
|
||||
v-if="hasCover"
|
||||
:src="metadata?.album_art_uri!"
|
||||
:alt="metadata?.album || 'Album cover'"
|
||||
class="cover-image"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div v-else class="cover-placeholder">
|
||||
<Music :size="48" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-cover">
|
||||
<img
|
||||
ref="coverImageRef"
|
||||
:style="{
|
||||
opacity: hasCover && cacheBustedUrl && imageLoaded ? 1 : 0,
|
||||
visibility:
|
||||
hasCover && cacheBustedUrl && imageLoaded
|
||||
? 'visible'
|
||||
: 'hidden',
|
||||
position:
|
||||
hasCover && cacheBustedUrl && imageLoaded
|
||||
? 'relative'
|
||||
: 'absolute',
|
||||
}"
|
||||
:src="cacheBustedUrl || ''"
|
||||
:alt="metadata?.album || 'Album cover'"
|
||||
class="cover-image"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div
|
||||
v-show="!cacheBustedUrl || !imageLoaded"
|
||||
class="cover-placeholder"
|
||||
>
|
||||
<Music :size="48" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metadata (current track) -->
|
||||
<div v-if="metadata" class="card-metadata">
|
||||
<p class="track-title">{{ metadata.title || 'Sans titre' }}</p>
|
||||
<p class="track-artist">{{ metadata.artist || 'Artiste inconnu' }}</p>
|
||||
</div>
|
||||
<div v-else class="card-metadata empty">
|
||||
<p class="track-title">Aucun média</p>
|
||||
</div>
|
||||
<!-- Metadata (current track) -->
|
||||
<div v-if="metadata" class="card-metadata">
|
||||
<p class="track-title">{{ metadata.title || "Sans titre" }}</p>
|
||||
<p class="track-artist">
|
||||
{{ metadata.artist || "Artiste inconnu" }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="card-metadata empty">
|
||||
<p class="track-title">Aucun média</p>
|
||||
</div>
|
||||
|
||||
<!-- Volume -->
|
||||
<div v-if="state && state.volume !== null" class="card-volume">
|
||||
<VolumeX v-if="state.mute" :size="16" class="volume-icon muted" />
|
||||
<Volume2 v-else :size="16" class="volume-icon" />
|
||||
<div class="volume-bar">
|
||||
<div
|
||||
class="volume-bar-fill"
|
||||
:style="{ width: `${state.mute ? 0 : state.volume}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="volume-value">{{ state.volume }}</span>
|
||||
</div>
|
||||
<!-- Volume -->
|
||||
<div v-if="state && state.volume !== null" class="card-volume">
|
||||
<VolumeX v-if="state.mute" :size="16" class="volume-icon muted" />
|
||||
<Volume2 v-else :size="16" class="volume-icon" />
|
||||
<div class="volume-bar">
|
||||
<div
|
||||
class="volume-bar-fill"
|
||||
:style="{ width: `${state.mute ? 0 : state.volume}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="volume-value">{{ state.volume }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Control Button -->
|
||||
<button class="btn btn-primary card-control-btn" @click="goToRenderer">
|
||||
Contrôler
|
||||
</button>
|
||||
</div>
|
||||
<!-- Control Button -->
|
||||
<button class="btn btn-primary card-control-btn" @click="goToRenderer">
|
||||
Contrôler
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.renderer-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-lg);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
transition: all var(--transition-normal);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-lg);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.renderer-card:hover {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.renderer-card.offline {
|
||||
opacity: 0.6;
|
||||
filter: grayscale(0.5);
|
||||
opacity: 0.6;
|
||||
filter: grayscale(0.5);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.card-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.renderer-name {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0 0 var(--spacing-xs);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0 0 var(--spacing-xs);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.renderer-model {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
flex-wrap: wrap;
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.capabilities {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-xs);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.capability-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.protocol-badge {
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.protocol-upnp {
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6;
|
||||
border: 1px solid #3b82f6;
|
||||
}
|
||||
|
||||
.protocol-openhome {
|
||||
background-color: rgba(139, 92, 246, 0.1);
|
||||
color: #8b5cf6;
|
||||
border: 1px solid #8b5cf6;
|
||||
}
|
||||
|
||||
.protocol-hybrid {
|
||||
background-color: rgba(16, 185, 129, 0.1);
|
||||
color: #10b981;
|
||||
border: 1px solid #10b981;
|
||||
}
|
||||
/* Protocol badge colors (.protocol-upnp, .protocol-openhome, etc.) → pmocontrol.css */
|
||||
|
||||
/* Cover */
|
||||
.card-cover {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.cover-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.cover-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-tertiary);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* Metadata */
|
||||
.card-metadata {
|
||||
min-height: 3rem;
|
||||
min-height: 3rem;
|
||||
}
|
||||
|
||||
.track-title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0 0 var(--spacing-xs);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0 0 var(--spacing-xs);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.track-artist {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-metadata.empty .track-title {
|
||||
color: var(--color-text-tertiary);
|
||||
font-weight: 400;
|
||||
color: var(--color-text-tertiary);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Volume */
|
||||
.card-volume {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.volume-icon {
|
||||
color: var(--color-text-secondary);
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.volume-icon.muted {
|
||||
color: var(--status-offline);
|
||||
color: var(--status-offline);
|
||||
}
|
||||
|
||||
.volume-bar {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.volume-bar-fill {
|
||||
height: 100%;
|
||||
background-color: var(--color-primary);
|
||||
transition: width var(--transition-fast);
|
||||
height: 100%;
|
||||
background-color: var(--color-primary);
|
||||
transition: width var(--transition-fast);
|
||||
}
|
||||
|
||||
.volume-value {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
min-width: 2rem;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
min-width: 2rem;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Control Button */
|
||||
.card-control-btn {
|
||||
width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Responsive grid handled by parent (DashboardView) */
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
:title="buttonTitle"
|
||||
ref="buttonRef"
|
||||
>
|
||||
<Clock :size="24" />
|
||||
<Clock :size="36" />
|
||||
<span
|
||||
v-if="timerState?.active && remainingMinutes !== null"
|
||||
class="timer-badge"
|
||||
|
||||
@@ -148,18 +148,18 @@ function handleRendererDrawerClick() {
|
||||
gap: var(--spacing-md);
|
||||
height: 72px;
|
||||
padding: 0 var(--spacing-md);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(30px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(30px) saturate(180%);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.1);
|
||||
background: rgba(22, 22, 32, 0.96);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.3);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@media (prefers-color-scheme: light) {
|
||||
.bottom-bar {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(245, 245, 250, 0.97);
|
||||
border-top-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,40 +293,7 @@ function handleRendererDrawerClick() {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Protocol badge */
|
||||
.protocol-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.protocol-upnp {
|
||||
background-color: rgba(59, 130, 246, 0.15);
|
||||
color: #3b82f6;
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.protocol-openhome {
|
||||
background-color: rgba(139, 92, 246, 0.15);
|
||||
color: #8b5cf6;
|
||||
border: 1px solid rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
|
||||
.protocol-hybrid {
|
||||
background-color: rgba(16, 185, 129, 0.15);
|
||||
color: #10b981;
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.protocol-chromecast {
|
||||
background-color: rgba(244, 114, 182, 0.15);
|
||||
color: #f472b6;
|
||||
border: 1px solid rgba(244, 114, 182, 0.3);
|
||||
}
|
||||
/* Protocol badge classes (.protocol-badge, .protocol-upnp, etc.) are now global in pmocontrol.css */
|
||||
|
||||
.status-badge {
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -153,14 +153,7 @@ async function handleRefresh() {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
/* @keyframes spin is now global in pmocontrol.css */
|
||||
|
||||
/* Responsive mobile */
|
||||
@media (max-width: 768px) {
|
||||
|
||||
@@ -116,6 +116,15 @@ const offlineRenderers = computed(() =>
|
||||
allRenderers.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) {
|
||||
counts.set(r.friendly_name, (counts.get(r.friendly_name) ?? 0) + 1);
|
||||
}
|
||||
return new Set([...counts.entries()].filter(([, n]) => n > 1).map(([name]) => name));
|
||||
});
|
||||
|
||||
function close() {
|
||||
emit("update:modelValue", false);
|
||||
}
|
||||
@@ -129,7 +138,7 @@ function handleRendererClick(renderer: RendererSummary) {
|
||||
}
|
||||
|
||||
function handleSettingsClick() {
|
||||
router.push("/debug/api-dashboard");
|
||||
router.push("/debug");
|
||||
close();
|
||||
}
|
||||
|
||||
@@ -201,7 +210,7 @@ async function handleTransferQueue(event: Event, targetRendererId: string) {
|
||||
|
||||
<!-- Drawer -->
|
||||
<Transition name="drawer">
|
||||
<aside v-if="modelValue" class="renderer-drawer">
|
||||
<aside v-if="modelValue" class="app-drawer renderer-drawer">
|
||||
<!-- Header -->
|
||||
<header class="drawer-header">
|
||||
<div class="drawer-title-section">
|
||||
@@ -296,7 +305,7 @@ async function handleTransferQueue(event: Event, targetRendererId: string) {
|
||||
getProtocolLabel(
|
||||
renderer.protocol,
|
||||
)
|
||||
}}
|
||||
}}{{ duplicateNames.has(renderer.friendly_name) && renderer.server_host ? ` [${renderer.server_host}]` : '' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="renderer-details">
|
||||
@@ -383,7 +392,7 @@ async function handleTransferQueue(event: Event, targetRendererId: string) {
|
||||
</div>
|
||||
<div class="renderer-info">
|
||||
<p class="renderer-name">
|
||||
{{ renderer.friendly_name }}
|
||||
{{ renderer.friendly_name }}{{ duplicateNames.has(renderer.friendly_name) && renderer.server_host ? ` [${renderer.server_host}]` : '' }}
|
||||
</p>
|
||||
<p
|
||||
v-if="renderer.model_name"
|
||||
@@ -440,95 +449,25 @@ async function handleTransferQueue(event: Event, targetRendererId: string) {
|
||||
|
||||
@media (max-width: 768px) and (orientation: portrait) {
|
||||
.drawer-backdrop {
|
||||
right: 0; /* Mobile portrait: backdrop prend tout l'écran */
|
||||
background: rgba(0, 0, 0, 0.4); /* Plus sombre sur mobile */
|
||||
display: none; /* Mobile portrait: le drawer couvre 100vw, pas besoin de backdrop */
|
||||
}
|
||||
}
|
||||
|
||||
/* Positionnement spécifique — le reste est dans drawers.css (.app-drawer) */
|
||||
.renderer-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 50vw; /* Desktop/landscape: 50% de l'écran */
|
||||
background: rgba(255, 255, 255, 0.08); /* Plus transparent */
|
||||
backdrop-filter: blur(40px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(40px) saturate(180%);
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.15);
|
||||
box-shadow: -4px 0 32px rgba(0, 0, 0, 0.25);
|
||||
z-index: 201;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.12);
|
||||
box-shadow: -4px 0 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@media (prefers-color-scheme: light) {
|
||||
.renderer-drawer {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border-left-color: rgba(255, 255, 255, 0.1);
|
||||
border-left-color: rgba(0, 0, 0, 0.1);
|
||||
box-shadow: -4px 0 32px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-lg);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.drawer-title-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
color: var(--color-text);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.drawer-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.drawer-close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.drawer-close-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.drawer-close-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.drawer-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
/* .drawer-header, .drawer-title, .drawer-close-btn, .drawer-content définis dans drawers.css */
|
||||
|
||||
.renderer-section {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
@@ -655,40 +594,7 @@ async function handleTransferQueue(event: Event, targetRendererId: string) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Protocol badge */
|
||||
.protocol-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.protocol-upnp {
|
||||
background-color: rgba(59, 130, 246, 0.15);
|
||||
color: #3b82f6;
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.protocol-openhome {
|
||||
background-color: rgba(139, 92, 246, 0.15);
|
||||
color: #8b5cf6;
|
||||
border: 1px solid rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
|
||||
.protocol-hybrid {
|
||||
background-color: rgba(16, 185, 129, 0.15);
|
||||
color: #10b981;
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.protocol-chromecast {
|
||||
background-color: rgba(244, 114, 182, 0.15);
|
||||
color: #f472b6;
|
||||
border: 1px solid rgba(244, 114, 182, 0.3);
|
||||
}
|
||||
/* Protocol badge classes (.protocol-badge, .protocol-upnp, etc.) are now global in pmocontrol.css */
|
||||
|
||||
/* Transport button */
|
||||
.transport-btn {
|
||||
@@ -756,7 +662,7 @@ async function handleTransferQueue(event: Event, targetRendererId: string) {
|
||||
right: 0;
|
||||
top: calc(100% + 4px);
|
||||
min-width: 200px;
|
||||
background: var(--color-surface-elevated);
|
||||
background: rgba(20, 20, 30, 0.98);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
|
||||
@@ -228,11 +228,11 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(30px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(30px) saturate(180%);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.2);
|
||||
background: rgba(22, 22, 32, 0.96);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
box-shadow: 0 -4px 32px rgba(0, 0, 0, 0.4);
|
||||
/* Fermé: caché sauf le toggle (56px) qui dépasse au-dessus de la BottomTabBar (64px) */
|
||||
transform: translateY(calc(100% - 56px - 64px));
|
||||
transition: transform 0.3s ease;
|
||||
@@ -250,6 +250,13 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
pointer-events: auto; /* Ouvert: capture les clics */
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.queue-drawer {
|
||||
background: rgba(245, 245, 250, 0.97);
|
||||
border-top-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.queue-drawer-toggle {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { ref, computed, watch, reactive, onMounted, onBeforeUnmount } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import {
|
||||
X,
|
||||
@@ -18,7 +18,6 @@ import { useMediaServers } from "@/composables/useMediaServers";
|
||||
import { useRenderers } from "@/composables/useRenderers";
|
||||
import type {
|
||||
MediaServerSummary,
|
||||
BrowseResponse,
|
||||
ContainerEntry,
|
||||
} from "@/services/pmocontrol/types";
|
||||
|
||||
@@ -35,11 +34,32 @@ const {
|
||||
allServers,
|
||||
fetchServers,
|
||||
browseContainer,
|
||||
getBrowseCached,
|
||||
loadMoreBrowse,
|
||||
hasMore,
|
||||
loadingMore,
|
||||
currentPath,
|
||||
setPath,
|
||||
clearPath,
|
||||
searchResults,
|
||||
searchQuery,
|
||||
searchServer,
|
||||
clearSearch,
|
||||
} = useMediaServers();
|
||||
|
||||
const searchInput = ref('');
|
||||
const isSearchMode = computed(() => searchQuery.value !== '');
|
||||
|
||||
async function handleSearch() {
|
||||
if (!currentServer.value || !searchInput.value.trim()) return;
|
||||
await searchServer(currentServer.value.id, searchInput.value.trim());
|
||||
}
|
||||
|
||||
function handleClearSearch() {
|
||||
searchInput.value = '';
|
||||
clearSearch();
|
||||
}
|
||||
|
||||
const { playContent, addToQueue, addAfterCurrent, attachAndPlayPlaylist } =
|
||||
useRenderers();
|
||||
|
||||
@@ -47,12 +67,77 @@ const router = useRouter();
|
||||
|
||||
// État de navigation
|
||||
const currentServer = ref<MediaServerSummary | null>(null);
|
||||
const browseData = ref<BrowseResponse | null>(null);
|
||||
const currentContainerId = ref<string | null>(null);
|
||||
const browseData = computed(() =>
|
||||
currentServer.value && currentContainerId.value
|
||||
? (getBrowseCached(currentServer.value.id, currentContainerId.value) ?? null)
|
||||
: null,
|
||||
);
|
||||
const isLoading = ref(false);
|
||||
|
||||
// Infinite scroll
|
||||
const sentinelRef = ref<HTMLElement | null>(null);
|
||||
const drawerContentRef = ref<HTMLElement | null>(null);
|
||||
let observer: IntersectionObserver | null = null;
|
||||
|
||||
const canLoadMore = computed(() =>
|
||||
currentServer.value && currentContainerId.value
|
||||
? hasMore(currentServer.value.id, currentContainerId.value)
|
||||
: false,
|
||||
);
|
||||
|
||||
function setupObserver() {
|
||||
if (observer) observer.disconnect();
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting && canLoadMore.value && !loadingMore.value) {
|
||||
if (currentServer.value && currentContainerId.value) {
|
||||
loadMoreBrowse(currentServer.value.id, currentContainerId.value);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ root: drawerContentRef.value, threshold: 0.1 },
|
||||
);
|
||||
if (sentinelRef.value) observer.observe(sentinelRef.value);
|
||||
}
|
||||
|
||||
onMounted(() => setupObserver());
|
||||
onBeforeUnmount(() => observer?.disconnect());
|
||||
|
||||
watch(sentinelRef, (el) => {
|
||||
if (el) setupObserver();
|
||||
});
|
||||
|
||||
watch(drawerContentRef, (el) => {
|
||||
if (el) setupObserver();
|
||||
});
|
||||
|
||||
// État du menu dropdown (pour chaque item, on stocke si son menu est ouvert)
|
||||
const openMenuId = ref<string | null>(null);
|
||||
|
||||
// Track image loading state per item
|
||||
const imageStates = reactive<Map<string, { loaded: boolean; error: boolean }>>(
|
||||
new Map(),
|
||||
);
|
||||
|
||||
function getImageState(itemId: string) {
|
||||
if (!imageStates.has(itemId)) {
|
||||
imageStates.set(itemId, { loaded: false, error: false });
|
||||
}
|
||||
return imageStates.get(itemId)!;
|
||||
}
|
||||
|
||||
function handleImageLoad(itemId: string) {
|
||||
const state = getImageState(itemId);
|
||||
state.loaded = true;
|
||||
state.error = false;
|
||||
}
|
||||
|
||||
function handleImageError(itemId: string) {
|
||||
const state = getImageState(itemId);
|
||||
state.error = true;
|
||||
}
|
||||
|
||||
// Rafraîchir la liste quand le drawer s'ouvre
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
@@ -62,9 +147,10 @@ watch(
|
||||
} else {
|
||||
// Reset navigation quand on ferme
|
||||
currentServer.value = null;
|
||||
browseData.value = null;
|
||||
currentContainerId.value = null;
|
||||
clearPath();
|
||||
closeMenu();
|
||||
imageStates.clear();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -119,11 +205,12 @@ async function handleServerClick(server: MediaServerSummary) {
|
||||
|
||||
try {
|
||||
// Browse racine (containerId = "0")
|
||||
browseData.value = await browseContainer(server.id, "0");
|
||||
await browseContainer(server.id, "0");
|
||||
currentContainerId.value = "0";
|
||||
setPath([{ id: "0", title: server.friendly_name }]);
|
||||
} catch (error) {
|
||||
console.error("[ServerDrawer] Erreur browse racine:", error);
|
||||
browseData.value = null;
|
||||
currentContainerId.value = null;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
@@ -131,7 +218,7 @@ async function handleServerClick(server: MediaServerSummary) {
|
||||
|
||||
function goBack() {
|
||||
currentServer.value = null;
|
||||
browseData.value = null;
|
||||
currentContainerId.value = null;
|
||||
clearPath();
|
||||
}
|
||||
|
||||
@@ -140,10 +227,8 @@ async function handleContainerClick(item: ContainerEntry) {
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
browseData.value = await browseContainer(
|
||||
currentServer.value.id,
|
||||
item.id,
|
||||
);
|
||||
await browseContainer(currentServer.value.id, item.id);
|
||||
currentContainerId.value = item.id;
|
||||
setPath([...currentPath.value, { id: item.id, title: item.title }]);
|
||||
} catch (error) {
|
||||
console.error("[ServerDrawer] Erreur browse container:", error);
|
||||
@@ -161,10 +246,8 @@ async function handleBreadcrumbClick(index: number) {
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
browseData.value = await browseContainer(
|
||||
currentServer.value.id,
|
||||
targetCrumb.id,
|
||||
);
|
||||
await browseContainer(currentServer.value.id, targetCrumb.id);
|
||||
currentContainerId.value = targetCrumb.id;
|
||||
setPath(currentPath.value.slice(0, index + 1));
|
||||
} catch (error) {
|
||||
console.error("[ServerDrawer] Erreur breadcrumb navigation:", error);
|
||||
@@ -287,7 +370,7 @@ async function handleAddAfterCurrent(event: Event, item: ContainerEntry) {
|
||||
}
|
||||
|
||||
function handleSettingsClick() {
|
||||
router.push("/debug/api-dashboard");
|
||||
router.push("/debug");
|
||||
close();
|
||||
}
|
||||
</script>
|
||||
@@ -301,7 +384,7 @@ function handleSettingsClick() {
|
||||
|
||||
<!-- Drawer -->
|
||||
<Transition name="drawer">
|
||||
<aside v-if="modelValue" class="server-drawer">
|
||||
<aside v-if="modelValue" class="app-drawer server-drawer">
|
||||
<!-- Header - Liste des serveurs -->
|
||||
<header v-if="!isNavigating" class="drawer-header">
|
||||
<div class="drawer-title-section">
|
||||
@@ -320,7 +403,7 @@ function handleSettingsClick() {
|
||||
<!-- Header - Navigation dans un serveur -->
|
||||
<header v-else class="drawer-header">
|
||||
<button
|
||||
class="back-btn"
|
||||
class="drawer-back-btn"
|
||||
@click="goBack"
|
||||
aria-label="Retour aux serveurs"
|
||||
>
|
||||
@@ -361,8 +444,30 @@ function handleSettingsClick() {
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Search bar (visible en mode navigation) -->
|
||||
<div v-if="isNavigating" class="search-bar">
|
||||
<input
|
||||
v-model="searchInput"
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Rechercher..."
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<button
|
||||
v-if="searchInput || isSearchMode"
|
||||
class="search-clear-btn"
|
||||
@click="handleClearSearch"
|
||||
title="Effacer"
|
||||
>
|
||||
<X :size="14" />
|
||||
</button>
|
||||
<button class="search-btn" @click="handleSearch" title="Rechercher">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Contenu -->
|
||||
<div class="drawer-content">
|
||||
<div ref="drawerContentRef" class="drawer-content">
|
||||
<!-- Liste des serveurs -->
|
||||
<div v-if="!isNavigating">
|
||||
<!-- Servers online -->
|
||||
@@ -454,6 +559,53 @@ function handleSettingsClick() {
|
||||
<p>Chargement...</p>
|
||||
</div>
|
||||
|
||||
<!-- Résultats de recherche -->
|
||||
<div v-else-if="isSearchMode && searchResults">
|
||||
<p v-if="searchResults.entries.length === 0" class="empty-state">Aucun résultat</p>
|
||||
<ul v-else class="content-list">
|
||||
<li
|
||||
v-for="item in searchResults.entries"
|
||||
:key="item.id"
|
||||
class="content-item"
|
||||
:class="{ navigable: item.is_container }"
|
||||
@click="handleItemClick(item)"
|
||||
>
|
||||
<div class="content-cover">
|
||||
<img
|
||||
v-if="item.album_art_uri && !getImageState(item.id).error"
|
||||
:src="item.album_art_uri"
|
||||
:alt="item.title"
|
||||
class="cover-img"
|
||||
:class="{ loaded: getImageState(item.id).loaded }"
|
||||
@load="handleImageLoad(item.id)"
|
||||
@error="handleImageError(item.id)"
|
||||
/>
|
||||
<div v-else class="cover-placeholder">
|
||||
<Folder v-if="item.is_container" :size="24" />
|
||||
<Music v-else :size="24" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-info">
|
||||
<p class="content-title">{{ item.title }}</p>
|
||||
<p v-if="item.artist" class="content-subtitle">{{ item.artist }}</p>
|
||||
</div>
|
||||
<div class="item-actions" @click.stop>
|
||||
<button class="action-btn play-btn" @click="handlePlayItem($event, item)" title="Lire">
|
||||
<Play :size="14" />
|
||||
</button>
|
||||
<button class="action-btn" @click="toggleMenu(item.id, $event)" title="Plus">
|
||||
<MoreVertical :size="14" />
|
||||
</button>
|
||||
<div v-if="openMenuId === item.id" class="item-menu">
|
||||
<button @click="handleAddToQueue($event, item)"><Plus :size="14" /> Ajouter à la queue</button>
|
||||
<button @click="handleAddAfterCurrent($event, item)"><Plus :size="14" /> Après le current</button>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight v-if="item.is_container" :size="16" class="content-chevron" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Contenu du serveur -->
|
||||
<ul v-else-if="browseData" class="content-list">
|
||||
<li
|
||||
@@ -470,33 +622,24 @@ function handleSettingsClick() {
|
||||
<!-- Cover avec image ou icône -->
|
||||
<div class="content-cover">
|
||||
<img
|
||||
v-if="item.album_art_uri"
|
||||
v-if="
|
||||
item.album_art_uri &&
|
||||
!getImageState(item.id).error
|
||||
"
|
||||
v-show="getImageState(item.id).loaded"
|
||||
:src="item.album_art_uri"
|
||||
:alt="item.title"
|
||||
class="cover-image"
|
||||
loading="lazy"
|
||||
@error="
|
||||
(e) => {
|
||||
(
|
||||
e.target as HTMLImageElement
|
||||
).style.display = 'none';
|
||||
const placeholder = (
|
||||
e.target as HTMLElement
|
||||
)
|
||||
.nextElementSibling as HTMLElement;
|
||||
if (placeholder)
|
||||
placeholder.style.display =
|
||||
'flex';
|
||||
}
|
||||
"
|
||||
@load="handleImageLoad(item.id)"
|
||||
@error="handleImageError(item.id)"
|
||||
/>
|
||||
<div
|
||||
v-if="
|
||||
!item.album_art_uri ||
|
||||
getImageState(item.id).error ||
|
||||
!getImageState(item.id).loaded
|
||||
"
|
||||
class="cover-placeholder"
|
||||
:style="{
|
||||
display: item.album_art_uri
|
||||
? 'none'
|
||||
: 'flex',
|
||||
}"
|
||||
>
|
||||
<Folder
|
||||
v-if="item.is_container"
|
||||
@@ -612,8 +755,16 @@ function handleSettingsClick() {
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Sentinel infinite scroll -->
|
||||
<div v-if="canLoadMore" ref="sentinelRef" class="scroll-sentinel" />
|
||||
|
||||
<!-- Spinner load more -->
|
||||
<div v-if="loadingMore" class="load-more-spinner">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
|
||||
<!-- Vide -->
|
||||
<div v-else class="empty-servers">
|
||||
<div v-else-if="!browseData" class="empty-servers">
|
||||
<Folder :size="48" />
|
||||
<p>Dossier vide</p>
|
||||
</div>
|
||||
@@ -651,115 +802,81 @@ function handleSettingsClick() {
|
||||
|
||||
@media (max-width: 768px) and (orientation: portrait) {
|
||||
.drawer-backdrop {
|
||||
left: 0; /* Mobile portrait: backdrop commence à gauche car drawer prend 100vw */
|
||||
background: rgba(0, 0, 0, 0.4); /* Plus sombre sur mobile */
|
||||
display: none; /* Mobile portrait: le drawer couvre 100vw, pas besoin de backdrop */
|
||||
}
|
||||
}
|
||||
|
||||
/* Positionnement spécifique — le reste est dans drawers.css (.app-drawer) */
|
||||
.server-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 50vw; /* Desktop/landscape: 50% de l'écran */
|
||||
background: rgba(255, 255, 255, 0.08); /* Plus transparent */
|
||||
backdrop-filter: blur(40px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(40px) saturate(180%);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.15);
|
||||
box-shadow: 4px 0 32px rgba(0, 0, 0, 0.25);
|
||||
z-index: 201;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.12);
|
||||
box-shadow: 4px 0 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@media (prefers-color-scheme: light) {
|
||||
.server-drawer {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border-right-color: rgba(255, 255, 255, 0.1);
|
||||
border-right-color: rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 4px 0 32px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.drawer-header {
|
||||
/* Breadcrumb */
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-lg);
|
||||
gap: 6px;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.drawer-title-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
color: var(--color-text);
|
||||
.search-input {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.drawer-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.drawer-title.small {
|
||||
font-size: var(--text-base);
|
||||
.search-input::placeholder {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
.search-clear-btn,
|
||||
.search-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
padding: 6px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: var(--color-text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-clear-btn:hover,
|
||||
.search-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.05);
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: var(--spacing-xl);
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.drawer-close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.drawer-close-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.drawer-close-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Breadcrumb */
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -800,12 +917,7 @@ function handleSettingsClick() {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.drawer-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
/* .drawer-content défini dans drawers.css */
|
||||
|
||||
.server-section {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
@@ -1179,11 +1291,7 @@ function handleSettingsClick() {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
/* @keyframes spin is now global in pmocontrol.css */
|
||||
|
||||
.loading-state p {
|
||||
margin: 0;
|
||||
@@ -1242,6 +1350,17 @@ function handleSettingsClick() {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.scroll-sentinel {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.load-more-spinner {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-md);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.backdrop-enter-active {
|
||||
transition: opacity 0.3s ease-out;
|
||||
|
||||
@@ -215,9 +215,6 @@ watch(editingVar, (newVar) => {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Error state */
|
||||
.error-state {
|
||||
|
||||
272
pmoapp/webapp/src/composables/apiCache.ts
Normal file
272
pmoapp/webapp/src/composables/apiCache.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Cache API centralisé pour les requêtes HTTP
|
||||
*
|
||||
* Fonctionnalités:
|
||||
* - Cache mémoire avec TTL configurable
|
||||
* - Dédupplication des requêtes en cours (une seule requête pour plusieurs callers)
|
||||
* - Invalidation par pattern (ex: invalidate('renderers/*'))
|
||||
* - Subscribe aux changements de données pour reactivity
|
||||
*/
|
||||
|
||||
export interface CacheEntry<T> {
|
||||
data: T;
|
||||
timestamp: number;
|
||||
etag?: string;
|
||||
}
|
||||
|
||||
export interface ApiCacheOptions {
|
||||
ttl?: number;
|
||||
staleWhileRevalidate?: boolean;
|
||||
}
|
||||
|
||||
interface PendingRequest {
|
||||
promise: Promise<unknown>;
|
||||
subscribers: Set<(data: unknown) => void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classe principale du cache API
|
||||
*/
|
||||
class ApiCacheService {
|
||||
private cache = new Map<string, CacheEntry<unknown>>();
|
||||
private pendingRequests = new Map<string, PendingRequest>();
|
||||
private subscriptions = new Map<string, Set<(data: unknown) => void>>();
|
||||
|
||||
private options: Required<ApiCacheOptions> = {
|
||||
ttl: 2000,
|
||||
staleWhileRevalidate: true,
|
||||
};
|
||||
|
||||
configure(options: Partial<ApiCacheOptions>) {
|
||||
this.options = { ...this.options, ...options };
|
||||
}
|
||||
|
||||
private makeKey(endpoint: string, params?: Record<string, string | number | boolean>): string {
|
||||
if (!params) return endpoint;
|
||||
const sorted = Object.entries(params).sort(([a], [b]) => a.localeCompare(b));
|
||||
const query = sorted.map(([k, v]) => `${k}=${v}`).join('&');
|
||||
return `${endpoint}?${query}`;
|
||||
}
|
||||
|
||||
private isFresh(key: string): boolean {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) return false;
|
||||
return Date.now() - entry.timestamp < this.options.ttl;
|
||||
}
|
||||
|
||||
get<T>(endpoint: string, params?: Record<string, string | number | boolean>): T | null {
|
||||
const key = this.makeKey(endpoint, params);
|
||||
const entry = this.cache.get(key) as CacheEntry<T> | undefined;
|
||||
|
||||
if (!entry) return null;
|
||||
if (!this.isFresh(key)) {
|
||||
return this.options.staleWhileRevalidate ? entry.data : null;
|
||||
}
|
||||
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
set<T>(endpoint: string, data: T, params?: Record<string, string | number | boolean>, etag?: string): void {
|
||||
const key = this.makeKey(endpoint, params);
|
||||
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
etag,
|
||||
});
|
||||
|
||||
this.notifySubscribers(key, data);
|
||||
}
|
||||
|
||||
async fetch<T>(
|
||||
endpoint: string,
|
||||
params: Record<string, string | number | boolean> | undefined,
|
||||
fetcher: () => Promise<T>,
|
||||
options: { force?: boolean; ttl?: number } = {}
|
||||
): Promise<T> {
|
||||
const key = this.makeKey(endpoint, params);
|
||||
const { force = false, ttl } = options;
|
||||
|
||||
if (!force && this.isFresh(key)) {
|
||||
const cached = this.get<T>(endpoint, params);
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
const existing = this.pendingRequests.get(key);
|
||||
if (existing) {
|
||||
return existing.promise as Promise<T>;
|
||||
}
|
||||
|
||||
let resolvePromise!: (value: unknown) => void;
|
||||
let rejectPromise!: (reason: unknown) => void;
|
||||
|
||||
const promise = new Promise<unknown>((resolve, reject) => {
|
||||
resolvePromise = resolve;
|
||||
rejectPromise = reject;
|
||||
});
|
||||
|
||||
this.pendingRequests.set(key, {
|
||||
promise,
|
||||
subscribers: new Set(),
|
||||
});
|
||||
|
||||
try {
|
||||
const data = await fetcher();
|
||||
|
||||
if (ttl) {
|
||||
const originalTtl = this.options.ttl;
|
||||
this.options.ttl = ttl;
|
||||
this.set(endpoint, data, params);
|
||||
this.options.ttl = originalTtl;
|
||||
} else {
|
||||
this.set(endpoint, data, params);
|
||||
}
|
||||
|
||||
resolvePromise(data);
|
||||
|
||||
const pending = this.pendingRequests.get(key);
|
||||
if (pending) {
|
||||
pending.subscribers.forEach(cb => cb(data));
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
rejectPromise(error);
|
||||
throw error;
|
||||
} finally {
|
||||
this.pendingRequests.delete(key);
|
||||
}
|
||||
|
||||
return Promise.reject(new Error('Unreachable'));
|
||||
}
|
||||
|
||||
subscribe<T>(endpoint: string, params: Record<string, string | number | boolean>, callback: (data: T) => void): () => void {
|
||||
const key = this.makeKey(endpoint, params);
|
||||
|
||||
if (!this.subscriptions.has(key)) {
|
||||
this.subscriptions.set(key, new Set());
|
||||
}
|
||||
|
||||
this.subscriptions.get(key)!.add(callback as (data: unknown) => void);
|
||||
|
||||
const cached = this.get<T>(endpoint, params);
|
||||
if (cached) {
|
||||
callback(cached);
|
||||
}
|
||||
|
||||
return () => {
|
||||
const subs = this.subscriptions.get(key);
|
||||
if (subs) {
|
||||
subs.delete(callback as (data: unknown) => void);
|
||||
if (subs.size === 0) {
|
||||
this.subscriptions.delete(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
invalidate(pattern: string): void {
|
||||
const keysToDelete: string[] = [];
|
||||
|
||||
if (pattern.includes('*')) {
|
||||
const prefix = pattern.replace('*', '');
|
||||
this.cache.forEach((_, key) => {
|
||||
if (key.startsWith(prefix)) {
|
||||
keysToDelete.push(key);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (this.cache.has(pattern)) {
|
||||
keysToDelete.push(pattern);
|
||||
}
|
||||
}
|
||||
|
||||
keysToDelete.forEach(key => {
|
||||
this.cache.delete(key);
|
||||
this.subscriptions.delete(key);
|
||||
});
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
this.subscriptions.clear();
|
||||
}
|
||||
|
||||
async invalidateAndFetch<T>(
|
||||
endpoint: string,
|
||||
params: Record<string, string | number | boolean>,
|
||||
fetcher: () => Promise<T>
|
||||
): Promise<T> {
|
||||
this.invalidate(this.makeKey(endpoint, params));
|
||||
return this.fetch(endpoint, params, fetcher, { force: true });
|
||||
}
|
||||
|
||||
getStats() {
|
||||
let fresh = 0;
|
||||
let stale = 0;
|
||||
const now = Date.now();
|
||||
|
||||
this.cache.forEach((entry) => {
|
||||
if (now - entry.timestamp < this.options.ttl) {
|
||||
fresh++;
|
||||
} else {
|
||||
stale++;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
total: this.cache.size,
|
||||
fresh,
|
||||
stale,
|
||||
pending: this.pendingRequests.size,
|
||||
subscriptions: this.subscriptions.size,
|
||||
};
|
||||
}
|
||||
|
||||
private notifySubscribers(key: string, data: unknown) {
|
||||
const subs = this.subscriptions.get(key);
|
||||
if (subs) {
|
||||
subs.forEach(cb => {
|
||||
try {
|
||||
cb(data);
|
||||
} catch (e) {
|
||||
console.error('[ApiCache] Error in subscriber:', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const apiCache = new ApiCacheService();
|
||||
|
||||
export function useApiCache() {
|
||||
return {
|
||||
fetch<T>(
|
||||
endpoint: string,
|
||||
params: Record<string, string | number | boolean> | undefined,
|
||||
fetcher: () => Promise<T>,
|
||||
options?: { force?: boolean; ttl?: number }
|
||||
): Promise<T> {
|
||||
return apiCache.fetch(endpoint, params, fetcher, options);
|
||||
},
|
||||
|
||||
subscribe<T>(
|
||||
endpoint: string,
|
||||
params: Record<string, string | number | boolean>,
|
||||
callback: (data: T) => void
|
||||
): () => void {
|
||||
return apiCache.subscribe(endpoint, params, callback);
|
||||
},
|
||||
|
||||
invalidate(pattern: string): void {
|
||||
apiCache.invalidate(pattern);
|
||||
},
|
||||
|
||||
clear(): void {
|
||||
apiCache.clear();
|
||||
},
|
||||
|
||||
getStats() {
|
||||
return apiCache.getStats();
|
||||
},
|
||||
};
|
||||
}
|
||||
253
pmoapp/webapp/src/composables/imageCache.ts
Normal file
253
pmoapp/webapp/src/composables/imageCache.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Cache centralisé pour les images de couvertures
|
||||
*
|
||||
* Ce service fournit:
|
||||
* - Cache mémoire pour les états de chargement (évite les requêtes doubles)
|
||||
* - Intégration avec le cache serveur (/api/covers)
|
||||
* - Gestion centralisée des retries
|
||||
* - Subscription aux changements d'état (plusieurs composants partagent le même état)
|
||||
*/
|
||||
|
||||
import { ref, computed, onUnmounted, watch, type Ref } from 'vue';
|
||||
|
||||
// Types pour le cache
|
||||
export interface ImageCacheEntry {
|
||||
url: string;
|
||||
loaded: boolean;
|
||||
error: boolean;
|
||||
loading: boolean;
|
||||
retryCount: number;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
export interface ImageCacheOptions {
|
||||
maxRetries?: number;
|
||||
retryDelay?: number;
|
||||
useServerCache?: boolean;
|
||||
}
|
||||
|
||||
// Singleton - état du cache global
|
||||
class ImageCacheService {
|
||||
private cache = new Map<string, ImageCacheEntry>();
|
||||
private subscriptions = new Map<string, Set<(entry: ImageCacheEntry) => void>>();
|
||||
private options: ImageCacheOptions = {
|
||||
maxRetries: 5,
|
||||
retryDelay: 500,
|
||||
useServerCache: true,
|
||||
};
|
||||
|
||||
private readonly CACHE_CLEANUP_MS = 5 * 60 * 1000;
|
||||
|
||||
constructor() {
|
||||
setInterval(() => this.cleanup(), this.CACHE_CLEANUP_MS);
|
||||
}
|
||||
|
||||
configure(options: Partial<ImageCacheOptions>) {
|
||||
this.options = { ...this.options, ...options };
|
||||
}
|
||||
|
||||
getOrCreate(url: string | null | undefined): ImageCacheEntry | null {
|
||||
if (!url) return null;
|
||||
|
||||
const normalizedUrl = url.replace(/[?&]_cb=[^&]*/, '');
|
||||
const cacheKey = normalizedUrl;
|
||||
|
||||
if (!this.cache.has(cacheKey)) {
|
||||
this.cache.set(cacheKey, {
|
||||
url: normalizedUrl,
|
||||
loaded: false,
|
||||
error: false,
|
||||
loading: false,
|
||||
retryCount: 0,
|
||||
lastError: null,
|
||||
});
|
||||
}
|
||||
|
||||
return this.cache.get(cacheKey)!;
|
||||
}
|
||||
|
||||
subscribe(url: string | null | undefined, callback: (entry: ImageCacheEntry) => void): () => void {
|
||||
const entry = this.getOrCreate(url);
|
||||
if (!entry) return () => {};
|
||||
|
||||
const normalizedUrl = entry.url;
|
||||
|
||||
if (!this.subscriptions.has(normalizedUrl)) {
|
||||
this.subscriptions.set(normalizedUrl, new Set());
|
||||
}
|
||||
|
||||
this.subscriptions.get(normalizedUrl)!.add(callback);
|
||||
callback(entry);
|
||||
|
||||
return () => {
|
||||
const subs = this.subscriptions.get(normalizedUrl);
|
||||
if (subs) {
|
||||
subs.delete(callback);
|
||||
if (subs.size === 0) {
|
||||
this.subscriptions.delete(normalizedUrl);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
startLoading(url: string | null | undefined) {
|
||||
const entry = this.getOrCreate(url);
|
||||
if (!entry) return;
|
||||
|
||||
entry.loading = true;
|
||||
this.notifySubscribers(entry.url);
|
||||
}
|
||||
|
||||
markLoaded(url: string | null | undefined) {
|
||||
const entry = this.getOrCreate(url);
|
||||
if (!entry) return;
|
||||
|
||||
entry.loaded = true;
|
||||
entry.error = false;
|
||||
entry.loading = false;
|
||||
entry.retryCount = 0;
|
||||
entry.lastError = null;
|
||||
this.notifySubscribers(entry.url);
|
||||
}
|
||||
|
||||
markError(url: string | null | undefined, error: string) {
|
||||
const entry = this.getOrCreate(url);
|
||||
if (!entry) return;
|
||||
|
||||
entry.error = true;
|
||||
entry.loading = false;
|
||||
entry.lastError = error;
|
||||
this.notifySubscribers(entry.url);
|
||||
}
|
||||
|
||||
shouldRetry(url: string | null | undefined): boolean {
|
||||
const entry = this.getOrCreate(url);
|
||||
if (!entry) return false;
|
||||
|
||||
if (entry.retryCount >= (this.options.maxRetries ?? 5)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
entry.retryCount++;
|
||||
entry.loading = true;
|
||||
this.notifySubscribers(entry.url);
|
||||
return true;
|
||||
}
|
||||
|
||||
getRetryDelay(retryCount: number): number {
|
||||
const baseDelay = this.options.retryDelay ?? 500;
|
||||
return baseDelay * Math.pow(2, retryCount - 1);
|
||||
}
|
||||
|
||||
private notifySubscribers(url: string) {
|
||||
const entry = this.cache.get(url);
|
||||
if (!entry) return;
|
||||
|
||||
const subs = this.subscriptions.get(url);
|
||||
if (subs) {
|
||||
subs.forEach(callback => {
|
||||
try {
|
||||
callback(entry);
|
||||
} catch (e) {
|
||||
console.error('[ImageCache] Error in subscriber:', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private cleanup() {
|
||||
const toDelete: string[] = [];
|
||||
|
||||
this.cache.forEach((entry, url) => {
|
||||
const hasSubs = this.subscriptions.has(url);
|
||||
if (!hasSubs && entry.loaded) {
|
||||
toDelete.push(url);
|
||||
}
|
||||
});
|
||||
|
||||
toDelete.forEach(url => this.cache.delete(url));
|
||||
}
|
||||
|
||||
getStats() {
|
||||
let loaded = 0;
|
||||
let loading = 0;
|
||||
let error = 0;
|
||||
let pending = 0;
|
||||
|
||||
this.cache.forEach(entry => {
|
||||
if (entry.loaded) loaded++;
|
||||
else if (entry.loading) loading++;
|
||||
else if (entry.error) error++;
|
||||
else pending++;
|
||||
});
|
||||
|
||||
return {
|
||||
total: this.cache.size,
|
||||
loaded,
|
||||
loading,
|
||||
error,
|
||||
pending,
|
||||
subscribers: this.subscriptions.size,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton
|
||||
export const imageCache = new ImageCacheService();
|
||||
|
||||
/**
|
||||
* Hook pour utiliser le cache d'images de manière reactive
|
||||
*/
|
||||
export function useImageCache(imageUrl: Ref<string | null | undefined>) {
|
||||
const entry = ref<ImageCacheEntry | null>(null);
|
||||
const cleanup = ref<(() => void) | null>(null);
|
||||
|
||||
const loading = computed(() => entry.value?.loading ?? false);
|
||||
const loaded = computed(() => entry.value?.loaded ?? false);
|
||||
const error = computed(() => entry.value?.error ?? false);
|
||||
const lastError = computed(() => entry.value?.lastError ?? null);
|
||||
const retryCount = computed(() => entry.value?.retryCount ?? 0);
|
||||
|
||||
watch(
|
||||
imageUrl,
|
||||
(newUrl) => {
|
||||
if (cleanup.value) {
|
||||
cleanup.value();
|
||||
cleanup.value = null;
|
||||
}
|
||||
|
||||
if (newUrl) {
|
||||
cleanup.value = imageCache.subscribe(newUrl, (newEntry) => {
|
||||
entry.value = newEntry;
|
||||
});
|
||||
} else {
|
||||
entry.value = null;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
if (cleanup.value) {
|
||||
cleanup.value();
|
||||
cleanup.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
function reload() {
|
||||
const url = imageUrl.value;
|
||||
if (url) {
|
||||
imageCache.startLoading(url);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
entry,
|
||||
loading,
|
||||
loaded,
|
||||
error,
|
||||
lastError,
|
||||
retryCount,
|
||||
reload,
|
||||
};
|
||||
}
|
||||
182
pmoapp/webapp/src/composables/useCoverImage.ts
Normal file
182
pmoapp/webapp/src/composables/useCoverImage.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Composable pour gérer les images de couvertures
|
||||
*
|
||||
* Version optimisée avec:
|
||||
* - Cache centralisé pour partager l'état entre composants
|
||||
* - Intégration optionnelle avec le cache serveur
|
||||
* - Retry automatique avec backoff exponentiel
|
||||
* - Cache-busting pour éviter les problèmes de cache navigateur
|
||||
*/
|
||||
import { ref, watch, computed, type Ref } from "vue";
|
||||
import { imageCache, useImageCache } from "./imageCache";
|
||||
import { simpleHash } from "../utils/string";
|
||||
|
||||
export interface CoverImageOptions {
|
||||
maxRetries?: number;
|
||||
retryDelay?: number;
|
||||
forceReload?: boolean;
|
||||
useServerCache?: boolean; // Passer par /api/covers pour le caching serveur
|
||||
}
|
||||
|
||||
export function useCoverImage(
|
||||
imageUrl: Ref<string | null | undefined>,
|
||||
options: CoverImageOptions = {},
|
||||
) {
|
||||
const {
|
||||
maxRetries = 5,
|
||||
retryDelay = 500,
|
||||
forceReload = true,
|
||||
useServerCache = true
|
||||
} = options;
|
||||
|
||||
// Configurer le cache global
|
||||
imageCache.configure({ maxRetries, retryDelay });
|
||||
|
||||
// État local
|
||||
const imageLoaded = ref(false);
|
||||
const imageError = ref(false);
|
||||
const coverImageRef = ref<HTMLImageElement | null>(null);
|
||||
const cacheBustedUrl = ref<string | null>(null);
|
||||
const isLoadingNewImage = ref(false);
|
||||
|
||||
// Utiliser le cache centralisé pour l'état de chargement
|
||||
const cacheEntry = useImageCache(imageUrl);
|
||||
|
||||
// Computed: synchroniser avec le cache centralisé
|
||||
// Note: on garde le controle local du loaded/error pour éviter les effets de bord
|
||||
|
||||
// Génère une URL avec cache-busting
|
||||
function getCacheBustedUrl(url: string, retry: number): string {
|
||||
if (!forceReload && retry === 0) {
|
||||
return url;
|
||||
}
|
||||
|
||||
// Si on utilise le cache serveur, transformer l'URL
|
||||
if (useServerCache && url.startsWith('http')) {
|
||||
// L'URL sera transformée côté serveur via le cache
|
||||
const separator = url.includes("?") ? "&" : "?";
|
||||
const cacheBuster = retry > 0
|
||||
? `${simpleHash(url)}_r${retry}_${Date.now()}`
|
||||
: simpleHash(url);
|
||||
return `${url}${separator}_cb=${cacheBuster}`;
|
||||
}
|
||||
|
||||
// Pour les URLs locales (data: ou /api/), juste ajouter un paramètre de cache-busting
|
||||
const separator = url.includes("?") ? "&" : "?";
|
||||
const cacheBuster = retry > 0
|
||||
? `${simpleHash(url)}_r${retry}_${Date.now()}`
|
||||
: simpleHash(url);
|
||||
return `${url}${separator}_cb=${cacheBuster}`;
|
||||
}
|
||||
|
||||
// Gère le chargement réussi
|
||||
function handleImageLoad() {
|
||||
const url = imageUrl.value;
|
||||
if (url) {
|
||||
imageCache.markLoaded(url);
|
||||
}
|
||||
imageLoaded.value = true;
|
||||
imageError.value = false;
|
||||
isLoadingNewImage.value = false;
|
||||
}
|
||||
|
||||
// Gère l'erreur de chargement
|
||||
function handleImageError(event: Event) {
|
||||
const url = imageUrl.value;
|
||||
const img = event.target as HTMLImageElement;
|
||||
|
||||
console.warn(`[useCoverImage] Image load error: ${img.src}`);
|
||||
|
||||
imageLoaded.value = false;
|
||||
|
||||
if (url) {
|
||||
// Demander au cache si on doit réessayer
|
||||
if (imageCache.shouldRetry(url)) {
|
||||
const delay = imageCache.getRetryDelay(cacheEntry.retryCount.value);
|
||||
console.log(`[useCoverImage] Retrying in ${delay}ms...`);
|
||||
|
||||
setTimeout(() => {
|
||||
// Générer une nouvelle URL avec retry count
|
||||
const retry = cacheEntry.retryCount.value;
|
||||
cacheBustedUrl.value = getCacheBustedUrl(url, retry);
|
||||
|
||||
// Forcer le rechargement de l'image
|
||||
if (coverImageRef.value) {
|
||||
coverImageRef.value.src = cacheBustedUrl.value;
|
||||
}
|
||||
}, delay);
|
||||
} else {
|
||||
imageError.value = true;
|
||||
imageCache.markError(url, "Max retries reached");
|
||||
}
|
||||
} else {
|
||||
imageError.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Watch sur l'URL pour générer la cache-busted URL
|
||||
watch(
|
||||
imageUrl,
|
||||
(newUri, oldUri) => {
|
||||
// Reset de l'état d'erreur
|
||||
imageError.value = false;
|
||||
|
||||
// Gestion des transitions
|
||||
if (oldUri && newUri && oldUri !== newUri) {
|
||||
isLoadingNewImage.value = true;
|
||||
// Garder l'image précédente visible pendant le chargement
|
||||
} else if (!newUri) {
|
||||
imageLoaded.value = false;
|
||||
isLoadingNewImage.value = false;
|
||||
cacheBustedUrl.value = null;
|
||||
} else if (!oldUri && newUri) {
|
||||
imageLoaded.value = false;
|
||||
isLoadingNewImage.value = true;
|
||||
}
|
||||
|
||||
if (newUri) {
|
||||
// Indiquer au cache qu'on commence à charger
|
||||
imageCache.startLoading(newUri);
|
||||
|
||||
// Générer l'URL avec cache-busting
|
||||
cacheBustedUrl.value = getCacheBustedUrl(newUri, 0);
|
||||
} else {
|
||||
cacheBustedUrl.value = null;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// Callback pour le ref de l'image
|
||||
function setImageRef(el: HTMLImageElement | null) {
|
||||
coverImageRef.value = el;
|
||||
|
||||
// Si on a une URL et une référence, initiate le chargement
|
||||
if (el && cacheBustedUrl.value && !imageLoaded.value) {
|
||||
// L'image va commencer à charger naturellement via le src
|
||||
// Le handler handleImageLoad sera appelé quand terminé
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// État
|
||||
imageLoaded: computed(() => imageLoaded.value || cacheEntry.loaded.value),
|
||||
imageError: computed(() => imageError.value || cacheEntry.error.value),
|
||||
coverImageRef: ref(coverImageRef),
|
||||
cacheBustedUrl,
|
||||
isLoadingNewImage,
|
||||
|
||||
// Méthodes
|
||||
handleImageLoad,
|
||||
handleImageError,
|
||||
setImageRef,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Version simplifiée de useCoverImage pour les cas où on n'a pas besoin
|
||||
* de tous les options. Utilise le cache centralisé par défaut.
|
||||
*/
|
||||
export function useCover(url: Ref<string | null | undefined>) {
|
||||
return useCoverImage(url, { forceReload: false, useServerCache: true });
|
||||
}
|
||||
@@ -4,10 +4,11 @@
|
||||
*/
|
||||
import { ref, computed } from 'vue'
|
||||
import { api } from '../services/pmocontrol/api'
|
||||
import { sse } from '../services/pmocontrol/sse'
|
||||
import { useSSE } from './useSSE'
|
||||
import { apiCache } from './apiCache'
|
||||
import type {
|
||||
MediaServerSummary,
|
||||
BrowseResponse
|
||||
ContainerEntry,
|
||||
} from '../services/pmocontrol/types'
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
@@ -15,31 +16,36 @@ export interface BreadcrumbItem {
|
||||
title: string
|
||||
}
|
||||
|
||||
export interface BrowseState {
|
||||
container_id: string
|
||||
entries: ContainerEntry[]
|
||||
total_count: number
|
||||
}
|
||||
|
||||
// Cache global partagé
|
||||
const serversCache = ref<Map<string, MediaServerSummary>>(new Map())
|
||||
const browseCache = ref<Map<string, BrowseResponse>>(new Map())
|
||||
const browseCache = ref<Map<string, BrowseState>>(new Map())
|
||||
const currentPath = ref<BreadcrumbItem[]>([])
|
||||
|
||||
// Timestamps
|
||||
const lastFetch = {
|
||||
servers: 0
|
||||
}
|
||||
const searchResults = ref<BrowseState | null>(null)
|
||||
const searchQuery = ref<string>('')
|
||||
|
||||
const CACHE_DURATION_MS = 2000
|
||||
|
||||
// Connecter SSE une seule fois
|
||||
let sseConnected = false
|
||||
function ensureSSEConnected() {
|
||||
if (sseConnected) return
|
||||
// Initialiser SSE une seule fois via le composable centralisé
|
||||
let sseInitialized = false
|
||||
function ensureSSEInitialized() {
|
||||
if (sseInitialized) return
|
||||
|
||||
sse.onMediaServerEvent((event) => {
|
||||
const { onMediaServerEvent, connect } = useSSE()
|
||||
|
||||
// Démarrer la connexion SSE
|
||||
connect()
|
||||
|
||||
onMediaServerEvent((event) => {
|
||||
const serverId = event.server_id
|
||||
|
||||
switch (event.type) {
|
||||
case 'online':
|
||||
// Nouveau serveur découvert
|
||||
console.log(`[useMediaServers] Serveur ${serverId} (${event.friendly_name}) est maintenant en ligne`)
|
||||
|
||||
// Ajouter au cache avec les infos disponibles
|
||||
const server: MediaServerSummary = {
|
||||
id: serverId,
|
||||
@@ -55,9 +61,6 @@ function ensureSSEConnected() {
|
||||
break
|
||||
|
||||
case 'offline':
|
||||
// Serveur déconnecté
|
||||
console.log(`[useMediaServers] Serveur ${serverId} est maintenant hors ligne`)
|
||||
|
||||
// Marquer comme offline dans le cache
|
||||
const existingServer = serversCache.value.get(serverId)
|
||||
if (existingServer) {
|
||||
@@ -77,7 +80,6 @@ function ensureSSEConnected() {
|
||||
|
||||
case 'global_updated':
|
||||
// Invalider tout le cache de ce serveur
|
||||
console.log(`[useMediaServers] GlobalUpdated pour ${serverId}`)
|
||||
const globalKeysToDelete: string[] = []
|
||||
browseCache.value.forEach((_, key) => {
|
||||
if (key.startsWith(serverId + '/')) {
|
||||
@@ -89,7 +91,6 @@ function ensureSSEConnected() {
|
||||
|
||||
case 'containers_updated':
|
||||
// Invalider les containers spécifiques
|
||||
console.log(`[useMediaServers] ContainersUpdated pour ${serverId}:`, event.container_ids)
|
||||
event.container_ids.forEach(containerId => {
|
||||
const key = `${serverId}/${containerId}`
|
||||
browseCache.value.delete(key)
|
||||
@@ -98,16 +99,17 @@ function ensureSSEConnected() {
|
||||
}
|
||||
})
|
||||
|
||||
sseConnected = true
|
||||
sseInitialized = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable principal pour gérer les media servers
|
||||
*/
|
||||
export function useMediaServers() {
|
||||
ensureSSEConnected()
|
||||
ensureSSEInitialized()
|
||||
|
||||
const loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Getters computed
|
||||
@@ -116,19 +118,20 @@ export function useMediaServers() {
|
||||
|
||||
// Fetch servers list
|
||||
async function fetchServers(force = false) {
|
||||
const now = Date.now()
|
||||
if (!force && now - lastFetch.servers < CACHE_DURATION_MS) {
|
||||
return // Cache encore valide
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const data = await api.getServers()
|
||||
|
||||
// Utiliser le cache API centralisé
|
||||
const data = await apiCache.fetch(
|
||||
'/servers',
|
||||
undefined,
|
||||
() => api.getServers(),
|
||||
{ force, ttl: CACHE_DURATION_MS }
|
||||
)
|
||||
|
||||
serversCache.value.clear()
|
||||
data.forEach(s => serversCache.value.set(s.id, s))
|
||||
lastFetch.servers = now
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Erreur fetch servers'
|
||||
console.error('[useMediaServers] Erreur fetch:', e)
|
||||
@@ -137,11 +140,10 @@ export function useMediaServers() {
|
||||
}
|
||||
}
|
||||
|
||||
// Browse container (avec cache automatique)
|
||||
// Charge la première page (remplace le cache)
|
||||
async function browseContainer(serverId: string, containerId: string, useCache = true) {
|
||||
const key = `${serverId}/${containerId}`
|
||||
|
||||
// Vérifier le cache
|
||||
if (useCache && browseCache.value.has(key)) {
|
||||
return browseCache.value.get(key)!
|
||||
}
|
||||
@@ -150,12 +152,15 @@ export function useMediaServers() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
const data = await api.browseContainer(serverId, containerId)
|
||||
const data = await api.browseContainer(serverId, containerId, 0)
|
||||
|
||||
// Mettre en cache
|
||||
browseCache.value.set(key, data)
|
||||
browseCache.value.set(key, {
|
||||
container_id: data.container_id,
|
||||
entries: data.entries,
|
||||
total_count: data.total_count,
|
||||
})
|
||||
|
||||
return data
|
||||
return browseCache.value.get(key)!
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Erreur browse container'
|
||||
console.error(`[useMediaServers] Erreur browse ${serverId}/${containerId}:`, e)
|
||||
@@ -165,6 +170,66 @@ export function useMediaServers() {
|
||||
}
|
||||
}
|
||||
|
||||
// Charge la page suivante et accumule (infinite scroll)
|
||||
async function loadMoreBrowse(serverId: string, containerId: string) {
|
||||
const key = `${serverId}/${containerId}`
|
||||
const state = browseCache.value.get(key)
|
||||
|
||||
if (!state) return
|
||||
if (state.entries.length >= state.total_count) return
|
||||
if (loadingMore.value) return
|
||||
|
||||
try {
|
||||
loadingMore.value = true
|
||||
|
||||
const offset = state.entries.length
|
||||
const data = await api.browseContainer(serverId, containerId, offset)
|
||||
|
||||
// Accumuler les nouvelles entrées
|
||||
state.entries.push(...data.entries)
|
||||
state.total_count = data.total_count
|
||||
// Forcer la réactivité
|
||||
browseCache.value.set(key, { ...state })
|
||||
} catch (e) {
|
||||
console.error(`[useMediaServers] Erreur load more ${serverId}/${containerId}:`, e)
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Recherche dans un serveur
|
||||
async function searchServer(serverId: string, query: string) {
|
||||
if (!query.trim()) {
|
||||
searchResults.value = null
|
||||
searchQuery.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
searchQuery.value = query
|
||||
|
||||
const data = await api.searchServer(serverId, query)
|
||||
searchResults.value = {
|
||||
container_id: 'search',
|
||||
entries: data.entries,
|
||||
total_count: data.total_count,
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Erreur recherche'
|
||||
console.error(`[useMediaServers] Erreur search ${serverId}:`, e)
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
searchResults.value = null
|
||||
searchQuery.value = ''
|
||||
}
|
||||
|
||||
// Getters
|
||||
function getServerById(id: string) {
|
||||
return serversCache.value.get(id)
|
||||
@@ -175,6 +240,13 @@ export function useMediaServers() {
|
||||
return browseCache.value.get(key)
|
||||
}
|
||||
|
||||
function hasMore(serverId: string, containerId: string): boolean {
|
||||
const key = `${serverId}/${containerId}`
|
||||
const state = browseCache.value.get(key)
|
||||
if (!state) return false
|
||||
return state.entries.length < state.total_count
|
||||
}
|
||||
|
||||
// Breadcrumb path management
|
||||
function setPath(path: BreadcrumbItem[]) {
|
||||
currentPath.value = path
|
||||
@@ -187,11 +259,9 @@ export function useMediaServers() {
|
||||
// Invalidation du cache
|
||||
function invalidateCache(serverId: string, containerId?: string) {
|
||||
if (containerId) {
|
||||
// Invalider un container spécifique
|
||||
const key = `${serverId}/${containerId}`
|
||||
browseCache.value.delete(key)
|
||||
} else {
|
||||
// Invalider tous les containers d'un serveur
|
||||
const keysToDelete: string[] = []
|
||||
browseCache.value.forEach((_, key) => {
|
||||
if (key.startsWith(serverId + '/')) {
|
||||
@@ -205,6 +275,7 @@ export function useMediaServers() {
|
||||
return {
|
||||
// État
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
currentPath,
|
||||
// Getters
|
||||
@@ -212,9 +283,16 @@ export function useMediaServers() {
|
||||
onlineServers,
|
||||
getServerById,
|
||||
getBrowseCached,
|
||||
hasMore,
|
||||
// Search
|
||||
searchResults,
|
||||
searchQuery,
|
||||
searchServer,
|
||||
clearSearch,
|
||||
// Actions
|
||||
fetchServers,
|
||||
browseContainer,
|
||||
loadMoreBrowse,
|
||||
setPath,
|
||||
clearPath,
|
||||
invalidateCache
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
* - Les snapshots complets proviennent de /renderers/{id}/full
|
||||
* - Les événements SSE ne servent qu'à déclencher un refetch.
|
||||
*/
|
||||
import { ref, reactive, computed, type Ref } from "vue";
|
||||
import { ref, reactive, computed, toRaw, type Ref } from "vue";
|
||||
import { api } from "../services/pmocontrol/api";
|
||||
import { sse } from "../services/pmocontrol/sse";
|
||||
import { useSSE } from "./useSSE";
|
||||
import { apiCache } from "./apiCache";
|
||||
import { parseTimeToMs } from "../utils/time";
|
||||
import type {
|
||||
RendererSummary,
|
||||
RendererState,
|
||||
@@ -20,39 +22,41 @@ interface RendererSnapshotState {
|
||||
lastSnapshotAt: Map<string, number>;
|
||||
lastEventAt: Map<string, number>;
|
||||
loadingIds: Set<string>;
|
||||
queueRefreshingIds: Set<string>;
|
||||
selectedRendererId: string | null;
|
||||
}
|
||||
|
||||
const renderersCache = ref<Map<string, RendererSummary>>(new Map());
|
||||
const RENDERERS_CACHE_MS = 2000;
|
||||
const lastRenderersFetch = ref(0);
|
||||
|
||||
const snapshotState = reactive<RendererSnapshotState>({
|
||||
snapshots: reactive(new Map<string, FullRendererSnapshot>()),
|
||||
lastSnapshotAt: reactive(new Map<string, number>()),
|
||||
lastEventAt: reactive(new Map<string, number>()),
|
||||
loadingIds: reactive(new Set<string>()),
|
||||
queueRefreshingIds: reactive(new Set<string>()),
|
||||
selectedRendererId: null,
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
let sseConnected = false;
|
||||
function ensureSSEConnected() {
|
||||
if (sseConnected) return;
|
||||
// Utiliser le composable SSE centralisé
|
||||
let sseInitialized = false;
|
||||
function ensureSSEInitialized() {
|
||||
if (sseInitialized) return;
|
||||
|
||||
sse.onRendererEvent((event) => {
|
||||
const { onRendererEvent, connect } = useSSE();
|
||||
|
||||
// Démarrer la connexion SSE
|
||||
connect();
|
||||
|
||||
onRendererEvent((event) => {
|
||||
const rendererId = event.renderer_id;
|
||||
const timestamp = Date.parse(event.timestamp ?? "") || Date.now();
|
||||
|
||||
// Gérer les événements Online/Offline différemment
|
||||
if (event.type === "online") {
|
||||
// Nouveau renderer découvert
|
||||
console.log(
|
||||
`[useRenderers] Renderer ${rendererId} (${event.friendly_name}) est maintenant en ligne`,
|
||||
);
|
||||
|
||||
// Ajouter au cache avec les infos disponibles
|
||||
// Note: on n'a pas toutes les infos (capabilities, protocol) donc on fetch ensuite
|
||||
const renderer: RendererSummary = {
|
||||
@@ -86,11 +90,6 @@ function ensureSSEConnected() {
|
||||
}
|
||||
|
||||
if (event.type === "offline") {
|
||||
// Renderer déconnecté
|
||||
console.log(
|
||||
`[useRenderers] Renderer ${rendererId} est maintenant hors ligne`,
|
||||
);
|
||||
|
||||
// Marquer comme offline dans le cache
|
||||
const renderer = renderersCache.value.get(rendererId);
|
||||
if (renderer) {
|
||||
@@ -123,28 +122,32 @@ function ensureSSEConnected() {
|
||||
break;
|
||||
|
||||
case "position_changed":
|
||||
// Mettre à jour position et durée de manière atomique pour garantir la cohérence
|
||||
// Le backend envoie TOUJOURS les deux valeurs (même si null)
|
||||
|
||||
// Convertir rel_time (HH:MM:SS) en millisecondes
|
||||
if (event.rel_time) {
|
||||
const parts = event.rel_time.split(":").map(Number);
|
||||
if (parts.length === 3) {
|
||||
snapshot.state.position_ms =
|
||||
((parts[0] ?? 0) * 3600 +
|
||||
(parts[1] ?? 0) * 60 +
|
||||
(parts[2] ?? 0)) *
|
||||
1000;
|
||||
}
|
||||
}
|
||||
const positionMs = parseTimeToMs(event.rel_time ?? null);
|
||||
snapshot.state.position_ms = positionMs ?? 0;
|
||||
|
||||
// Convertir track_duration (HH:MM:SS) en millisecondes
|
||||
if (event.track_duration) {
|
||||
const parts = event.track_duration.split(":").map(Number);
|
||||
if (parts.length === 3) {
|
||||
snapshot.state.duration_ms =
|
||||
((parts[0] ?? 0) * 3600 +
|
||||
(parts[1] ?? 0) * 60 +
|
||||
(parts[2] ?? 0)) *
|
||||
1000;
|
||||
}
|
||||
}
|
||||
const durationMs = parseTimeToMs(event.track_duration ?? null);
|
||||
// Si track_duration est null/undefined (flux continu sans durée),
|
||||
// mettre duration_ms à null pour afficher "--:--"
|
||||
snapshot.state.duration_ms = durationMs;
|
||||
|
||||
// Important: Trigger reactivity en réassignant l'objet complet avec deep copy
|
||||
// Le shallow copy ne suffit pas car snapshot.state est partagé entre renderers
|
||||
// Il faut copier state aussi pour éviter que les modifications d'un renderer
|
||||
// n'affectent les autres renderers
|
||||
// IMPORTANT: Utiliser toRaw() pour obtenir l'objet brut non-réactif avant de copier
|
||||
// sinon Vue copie les getters réactifs qui continuent à pointer vers l'objet d'origine
|
||||
const rawState = toRaw(snapshot.state);
|
||||
const newState = { ...rawState };
|
||||
const newSnapshot = {
|
||||
...snapshot,
|
||||
state: newState,
|
||||
};
|
||||
snapshotState.snapshots.set(rendererId, newSnapshot);
|
||||
break;
|
||||
|
||||
case "volume_changed":
|
||||
@@ -168,10 +171,20 @@ function ensureSSEConnected() {
|
||||
snapshot.state.current_track.artist = event.artist;
|
||||
snapshot.state.current_track.album = event.album;
|
||||
snapshot.state.current_track.album_art_uri = event.album_art_uri;
|
||||
// Important: Trigger reactivity en réassignant l'objet complet avec deep copy
|
||||
snapshotState.snapshots.set(rendererId, {
|
||||
...snapshot,
|
||||
state: { ...snapshot.state },
|
||||
});
|
||||
break;
|
||||
|
||||
case "queue_refreshing":
|
||||
snapshotState.queueRefreshingIds.add(rendererId);
|
||||
break;
|
||||
|
||||
case "queue_updated":
|
||||
snapshot.state.queue_len = event.queue_length;
|
||||
snapshotState.queueRefreshingIds.delete(rendererId);
|
||||
// Pour la queue complète, on doit refetch
|
||||
void fetchRendererSnapshot(rendererId, { force: true });
|
||||
break;
|
||||
@@ -190,6 +203,10 @@ function ensureSSEConnected() {
|
||||
}
|
||||
break;
|
||||
|
||||
case "stream_state_changed":
|
||||
snapshot.is_stream = event.is_stream;
|
||||
break;
|
||||
|
||||
case "timer_started":
|
||||
case "timer_updated":
|
||||
case "timer_tick":
|
||||
@@ -204,7 +221,7 @@ function ensureSSEConnected() {
|
||||
snapshotState.snapshots.set(rendererId, snapshot);
|
||||
});
|
||||
|
||||
sseConnected = true;
|
||||
sseInitialized = true;
|
||||
}
|
||||
|
||||
const allRenderers = computed(() => Array.from(renderersCache.value.values()));
|
||||
@@ -244,26 +261,32 @@ function isSnapshotLoading(id: string) {
|
||||
return snapshotState.loadingIds.has(id);
|
||||
}
|
||||
|
||||
function isQueueRefreshing(id: string) {
|
||||
return snapshotState.queueRefreshingIds.has(id);
|
||||
}
|
||||
|
||||
function selectRenderer(id: string | null) {
|
||||
snapshotState.selectedRendererId = id;
|
||||
}
|
||||
|
||||
async function fetchRenderers(force = false) {
|
||||
ensureSSEConnected();
|
||||
|
||||
const now = Date.now();
|
||||
if (!force && now - lastRenderersFetch.value < RENDERERS_CACHE_MS) {
|
||||
return;
|
||||
}
|
||||
ensureSSEInitialized();
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
const data = await api.getRenderers();
|
||||
|
||||
// Utiliser le cache API centralisé
|
||||
const data = await apiCache.fetch(
|
||||
'/renderers',
|
||||
undefined,
|
||||
() => api.getRenderers(),
|
||||
{ force, ttl: RENDERERS_CACHE_MS }
|
||||
);
|
||||
|
||||
renderersCache.value = new Map(
|
||||
data.map((renderer) => [renderer.id, renderer]),
|
||||
);
|
||||
lastRenderersFetch.value = now;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Erreur fetch renderers";
|
||||
console.error("[useRenderers] Erreur fetch:", err);
|
||||
@@ -276,7 +299,7 @@ async function fetchRendererSnapshot(
|
||||
rendererId: string,
|
||||
opts?: { force?: boolean },
|
||||
) {
|
||||
ensureSSEConnected();
|
||||
ensureSSEInitialized();
|
||||
const force = opts?.force ?? false;
|
||||
const hasSnapshot = snapshotState.snapshots.has(rendererId);
|
||||
|
||||
@@ -304,6 +327,46 @@ async function fetchRendererSnapshot(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch les snapshots de plusieurs renderers en parallèle controlée.
|
||||
* - Limite le nombre de requêtes simultanées (concurrency)
|
||||
* - Ajoute un délai entre chaque batch pour ne pas saturer le réseau
|
||||
* - Continue même si certaines requêtes échouent
|
||||
*/
|
||||
async function fetchBatchSnapshots(
|
||||
rendererIds: string[],
|
||||
options: {
|
||||
concurrency?: number; // Nombre max de requêtes parallèles (défaut: 3)
|
||||
batchDelay?: number; // Délai entre les batches en ms (défaut: 100ms)
|
||||
force?: boolean; // Forcer le refetch même en cache
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
const { concurrency = 3, batchDelay = 100, force = false } = options;
|
||||
|
||||
// Filtrer les rendererIds valides
|
||||
const validIds = rendererIds.filter(id => id && typeof id === 'string');
|
||||
|
||||
if (validIds.length === 0) return;
|
||||
|
||||
// Fonction pour traiter un batch
|
||||
const processBatch = async (batch: string[]): Promise<void> => {
|
||||
await Promise.allSettled(
|
||||
batch.map(id => fetchRendererSnapshot(id, { force }))
|
||||
);
|
||||
};
|
||||
|
||||
// Exécuter par batches avec controlled concurrency
|
||||
for (let i = 0; i < validIds.length; i += concurrency) {
|
||||
const batch = validIds.slice(i, i + concurrency);
|
||||
await processBatch(batch);
|
||||
|
||||
// Délai entre les batches (sauf pour le dernier)
|
||||
if (i + concurrency < validIds.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, batchDelay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Transport controls
|
||||
async function play(id: string) {
|
||||
await api.play(id);
|
||||
@@ -414,7 +477,7 @@ async function addAfterCurrent(
|
||||
}
|
||||
|
||||
export function useRenderers() {
|
||||
ensureSSEConnected();
|
||||
ensureSSEInitialized();
|
||||
|
||||
return {
|
||||
loading,
|
||||
@@ -430,11 +493,13 @@ export function useRenderers() {
|
||||
getQueueById,
|
||||
getBindingById,
|
||||
isSnapshotLoading,
|
||||
isQueueRefreshing,
|
||||
selectRenderer,
|
||||
snapshotState,
|
||||
// Fetchers
|
||||
fetchRenderers,
|
||||
fetchRendererSnapshot,
|
||||
fetchBatchSnapshots,
|
||||
// Transport controls
|
||||
play,
|
||||
resumeOrPlayFromQueue,
|
||||
@@ -458,15 +523,16 @@ export function useRenderers() {
|
||||
}
|
||||
|
||||
export function useRenderer(rendererId: Ref<string>) {
|
||||
ensureSSEConnected();
|
||||
ensureSSEInitialized();
|
||||
|
||||
const renderer = computed(() => renderersCache.value.get(rendererId.value));
|
||||
const snapshot = computed(
|
||||
() => snapshotState.snapshots.get(rendererId.value) ?? null,
|
||||
);
|
||||
const state = computed(() => snapshot.value?.state ?? null);
|
||||
const queue = computed(() => snapshot.value?.queue ?? null);
|
||||
const binding = computed(() => snapshot.value?.binding ?? null);
|
||||
// Delegates to useRenderers functions - no duplication
|
||||
const renderer = computed(() => getRendererById(rendererId.value));
|
||||
const snapshot = computed(() => getSnapshotById(rendererId.value));
|
||||
const state = computed(() => getStateById(rendererId.value));
|
||||
const queue = computed(() => getQueueById(rendererId.value));
|
||||
const binding = computed(() => getBindingById(rendererId.value));
|
||||
const isStream = computed(() => snapshot.value?.is_stream ?? false);
|
||||
const queueRefreshing = computed(() => isQueueRefreshing(rendererId.value));
|
||||
|
||||
async function refresh(force = true) {
|
||||
await Promise.all([
|
||||
@@ -481,6 +547,8 @@ export function useRenderer(rendererId: Ref<string>) {
|
||||
state,
|
||||
queue,
|
||||
binding,
|
||||
isStream,
|
||||
queueRefreshing,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
||||
244
pmoapp/webapp/src/composables/useSSE.ts
Normal file
244
pmoapp/webapp/src/composables/useSSE.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Composable centralisé pour la gestion des Server-Sent Events
|
||||
*
|
||||
* Ce composable fournit une interface unifiée pour:
|
||||
* - Une seule connexion SSE (évite les connexions multiples)
|
||||
* - Des abonnements typés aux événements (renderers, servers)
|
||||
* - Le suivi de l'état de connexion
|
||||
*
|
||||
* Usage:
|
||||
* const { connected, onRendererEvent, onMediaServerEvent } = useSSE()
|
||||
*/
|
||||
import { ref, readonly, onUnmounted } from 'vue'
|
||||
import { sse } from '../services/pmocontrol/sse'
|
||||
import type {
|
||||
RendererEventPayload,
|
||||
MediaServerEventPayload
|
||||
} from '../services/pmocontrol/types'
|
||||
|
||||
// État global partagé
|
||||
const connected = ref(sse.isConnectedState())
|
||||
const connectionCallbacks: Set<(connected: boolean) => void> = new Set()
|
||||
|
||||
// Abonnement à l'état de connexion global
|
||||
function setupConnectionListener() {
|
||||
// S'assurer qu'on ne s'abonne qu'une seule fois
|
||||
if (connectionCallbacks.size === 0) {
|
||||
sse.onConnectionChange((isConnected) => {
|
||||
connected.value = isConnected
|
||||
connectionCallbacks.forEach(cb => cb(isConnected))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook principal pour utiliser SSE
|
||||
*/
|
||||
export function useSSE() {
|
||||
// S'assurer que le listener de connexion est configuré
|
||||
setupConnectionListener()
|
||||
|
||||
/**
|
||||
* Abonnement aux événements de type renderer
|
||||
* Retourne une fonction de cleanup
|
||||
*/
|
||||
function onRendererEvent(
|
||||
callback: (event: RendererEventPayload) => void
|
||||
): () => void {
|
||||
return sse.onRendererEvent(callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Abonnement aux événements de type media server
|
||||
* Retourne une fonction de cleanup
|
||||
*/
|
||||
function onMediaServerEvent(
|
||||
callback: (event: MediaServerEventPayload) => void
|
||||
): () => void {
|
||||
return sse.onMediaServerEvent(callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Abonnement aux changements de connexion
|
||||
* Retourne une fonction de cleanup
|
||||
*/
|
||||
function onConnectionChange(
|
||||
callback: (connected: boolean) => void
|
||||
): () => void {
|
||||
connectionCallbacks.add(callback)
|
||||
// Appeler immédiatement avec l'état actuel
|
||||
callback(connected.value)
|
||||
|
||||
// Retourner fonction de cleanup
|
||||
return () => {
|
||||
connectionCallbacks.delete(callback)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force la connexion SSE
|
||||
*/
|
||||
function connect(): void {
|
||||
sse.connect()
|
||||
}
|
||||
|
||||
/**
|
||||
* Force la déconnexion SSE
|
||||
*/
|
||||
function disconnect(): void {
|
||||
sse.disconnect()
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si actuellement connecté
|
||||
*/
|
||||
function isConnected(): boolean {
|
||||
return connected.value
|
||||
}
|
||||
|
||||
return {
|
||||
// État (readonly pour éviter les modifications directes)
|
||||
connected: readonly(connected),
|
||||
|
||||
// Abonnements
|
||||
onRendererEvent,
|
||||
onMediaServerEvent,
|
||||
onConnectionChange,
|
||||
|
||||
// Actions
|
||||
connect,
|
||||
disconnect,
|
||||
isConnected,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour s'abonner à un type spécifique d'événement renderer
|
||||
* avec filtrage optionnel par rendererId
|
||||
*/
|
||||
export function useRendererEvents(
|
||||
rendererId: () => string | null,
|
||||
options?: {
|
||||
onStateChanged?: (event: RendererEventPayload) => void
|
||||
onPositionChanged?: (event: RendererEventPayload) => void
|
||||
onVolumeChanged?: (event: RendererEventPayload) => void
|
||||
onMetadataChanged?: (event: RendererEventPayload) => void
|
||||
onQueueUpdated?: (event: RendererEventPayload) => void
|
||||
onBindingChanged?: (event: RendererEventPayload) => void
|
||||
onTimerEvent?: (event: RendererEventPayload) => void
|
||||
}
|
||||
) {
|
||||
const { onRendererEvent } = useSSE()
|
||||
|
||||
let cleanup: (() => void) | null = null
|
||||
|
||||
function setup() {
|
||||
cleanup = onRendererEvent((event) => {
|
||||
const currentId = rendererId()
|
||||
|
||||
// Si un rendererId est spécifié, filtrer
|
||||
if (currentId && event.renderer_id !== currentId) {
|
||||
return
|
||||
}
|
||||
|
||||
// Dispatch vers le handler approprié
|
||||
switch (event.type) {
|
||||
case 'state_changed':
|
||||
case 'online':
|
||||
case 'offline':
|
||||
options?.onStateChanged?.(event)
|
||||
break
|
||||
case 'position_changed':
|
||||
options?.onPositionChanged?.(event)
|
||||
break
|
||||
case 'volume_changed':
|
||||
case 'mute_changed':
|
||||
options?.onVolumeChanged?.(event)
|
||||
break
|
||||
case 'metadata_changed':
|
||||
options?.onMetadataChanged?.(event)
|
||||
break
|
||||
case 'queue_updated':
|
||||
case 'queue_refreshing':
|
||||
options?.onQueueUpdated?.(event)
|
||||
break
|
||||
case 'binding_changed':
|
||||
options?.onBindingChanged?.(event)
|
||||
break
|
||||
case 'timer_started':
|
||||
case 'timer_updated':
|
||||
case 'timer_tick':
|
||||
case 'timer_expired':
|
||||
case 'timer_cancelled':
|
||||
options?.onTimerEvent?.(event)
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
setup()
|
||||
|
||||
// Cleanup automatique au unmount du composant
|
||||
onUnmounted(() => {
|
||||
cleanup?.()
|
||||
})
|
||||
|
||||
return {
|
||||
refresh: setup, // Permet de recréer l'abonnement si besoin
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour s'abonner aux événements media server
|
||||
* avec filtrage optionnel par serverId
|
||||
*/
|
||||
export function useMediaServerEvents(
|
||||
serverId: () => string | null,
|
||||
options?: {
|
||||
onOnline?: (event: MediaServerEventPayload) => void
|
||||
onOffline?: (event: MediaServerEventPayload) => void
|
||||
onGlobalUpdated?: (event: MediaServerEventPayload) => void
|
||||
onContainersUpdated?: (event: MediaServerEventPayload) => void
|
||||
}
|
||||
) {
|
||||
const { onMediaServerEvent } = useSSE()
|
||||
|
||||
let cleanup: (() => void) | null = null
|
||||
|
||||
function setup() {
|
||||
cleanup = onMediaServerEvent((event) => {
|
||||
const currentId = serverId()
|
||||
|
||||
// Si un serverId est spécifié, filtrer
|
||||
if (currentId && event.server_id !== currentId) {
|
||||
return
|
||||
}
|
||||
|
||||
// Dispatch vers le handler approprié
|
||||
switch (event.type) {
|
||||
case 'online':
|
||||
options?.onOnline?.(event)
|
||||
break
|
||||
case 'offline':
|
||||
options?.onOffline?.(event)
|
||||
break
|
||||
case 'global_updated':
|
||||
options?.onGlobalUpdated?.(event)
|
||||
break
|
||||
case 'containers_updated':
|
||||
options?.onContainersUpdated?.(event)
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
setup()
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup?.()
|
||||
})
|
||||
|
||||
return {
|
||||
refresh: setup,
|
||||
}
|
||||
}
|
||||
@@ -3,42 +3,45 @@
|
||||
* Onglets renderer auto-générés depuis la liste des renderers online.
|
||||
* Onglets server ouverts manuellement via le drawer (fermables).
|
||||
*/
|
||||
import { reactive, computed, watch, onMounted, type Component } from 'vue'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import { Radio, Server } from 'lucide-vue-next'
|
||||
import type { RendererSummary, MediaServerSummary } from '../services/pmocontrol/types'
|
||||
import { reactive, computed, watch, onMounted, type Component } from "vue";
|
||||
import { useMediaQuery } from "@vueuse/core";
|
||||
import { Radio, Server } from "lucide-vue-next";
|
||||
import type {
|
||||
RendererSummary,
|
||||
MediaServerSummary,
|
||||
} from "../services/pmocontrol/types";
|
||||
|
||||
export interface Tab {
|
||||
id: string // "renderer-{id}", "server-{id}"
|
||||
type: 'renderer' | 'server'
|
||||
title: string // Nom affiché (tronqué sur mobile)
|
||||
fullTitle: string // Nom complet (pour tooltip)
|
||||
icon: Component
|
||||
id: string; // "renderer-{id}", "server-{id}"
|
||||
type: "renderer" | "server";
|
||||
title: string; // Nom affiché (tronqué sur mobile)
|
||||
fullTitle: string; // Nom complet (pour tooltip)
|
||||
icon: Component;
|
||||
metadata?: {
|
||||
rendererId?: string
|
||||
serverId?: string
|
||||
}
|
||||
closeable: boolean // renderer: false (auto-géré), server: true (manuel)
|
||||
rendererId?: string;
|
||||
serverId?: string;
|
||||
};
|
||||
closeable: boolean; // renderer: false (auto-géré), server: true (manuel)
|
||||
}
|
||||
|
||||
interface TabsState {
|
||||
tabs: Tab[]
|
||||
activeTabId: string
|
||||
tabHistory: string[] // Pour back/forward navigation
|
||||
tabs: Tab[];
|
||||
activeTabId: string;
|
||||
tabHistory: string[]; // Pour back/forward navigation
|
||||
}
|
||||
|
||||
const MAX_TABS = 12 // Augmenté car onglets auto-générés
|
||||
const STORAGE_KEY = 'pmo-tabs-state'
|
||||
const MAX_TABS = 12; // Augmenté car onglets auto-générés
|
||||
const STORAGE_KEY = "pmo-tabs-state";
|
||||
|
||||
// État global partagé entre toutes les instances du composable
|
||||
const state = reactive<TabsState>({
|
||||
tabs: [],
|
||||
activeTabId: '',
|
||||
activeTabId: "",
|
||||
tabHistory: [],
|
||||
})
|
||||
});
|
||||
|
||||
// Flag pour éviter les boucles de sauvegarde
|
||||
let isRestoringFromStorage = false
|
||||
let isRestoringFromStorage = false;
|
||||
|
||||
/**
|
||||
* Retourne le titre complet sans troncature
|
||||
@@ -46,7 +49,7 @@ let isRestoringFromStorage = false
|
||||
*/
|
||||
function truncateTitle(title: string): string {
|
||||
// Retourner le titre complet, le CSS gère l'ellipsis de façon stable
|
||||
return title
|
||||
return title;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,13 +57,13 @@ function truncateTitle(title: string): string {
|
||||
* Note: On ne sauvegarde que les onglets server (les renderer tabs sont auto-générés)
|
||||
*/
|
||||
function saveToLocalStorage() {
|
||||
if (isRestoringFromStorage) return
|
||||
if (isRestoringFromStorage) return;
|
||||
|
||||
try {
|
||||
const stateToSave = {
|
||||
// Sauvegarder uniquement les onglets server (fermables manuellement)
|
||||
tabs: state.tabs
|
||||
.filter((tab) => tab.type === 'server')
|
||||
.filter((tab) => tab.type === "server")
|
||||
.map((tab) => ({
|
||||
...tab,
|
||||
// On ne peut pas sauvegarder les composants Vue, on sauve juste le type
|
||||
@@ -68,10 +71,10 @@ function saveToLocalStorage() {
|
||||
})),
|
||||
activeTabId: state.activeTabId,
|
||||
tabHistory: state.tabHistory,
|
||||
}
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave))
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave));
|
||||
} catch (error) {
|
||||
console.error('[useTabs] Erreur sauvegarde localStorage:', error)
|
||||
console.error("[useTabs] Erreur sauvegarde localStorage:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,37 +84,37 @@ function saveToLocalStorage() {
|
||||
*/
|
||||
function restoreFromLocalStorage() {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (!saved) return
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (!saved) return;
|
||||
|
||||
isRestoringFromStorage = true
|
||||
const savedState = JSON.parse(saved)
|
||||
isRestoringFromStorage = true;
|
||||
const savedState = JSON.parse(saved);
|
||||
|
||||
// Reconstituer uniquement les tabs server avec les bonnes icônes
|
||||
const serverTabs = (savedState.tabs || [])
|
||||
.filter((tab: Tab) => tab.type === 'server')
|
||||
.filter((tab: Tab) => tab.type === "server")
|
||||
.map((tab: Tab) => ({
|
||||
...tab,
|
||||
icon: Server,
|
||||
closeable: true,
|
||||
fullTitle: tab.fullTitle || tab.title, // Fallback si fullTitle n'existe pas
|
||||
}))
|
||||
}));
|
||||
|
||||
// Ajouter les tabs server restaurés (les renderer tabs seront ajoutés par syncWithRenderers)
|
||||
state.tabs.push(...serverTabs)
|
||||
state.tabs.push(...serverTabs);
|
||||
|
||||
state.activeTabId = savedState.activeTabId || ''
|
||||
state.tabHistory = savedState.tabHistory || []
|
||||
state.activeTabId = savedState.activeTabId || "";
|
||||
state.tabHistory = savedState.tabHistory || [];
|
||||
|
||||
// Vérifier que l'onglet actif existe toujours (sera validé après syncWithRenderers)
|
||||
if (!state.tabs.find((t) => t.id === state.activeTabId)) {
|
||||
state.activeTabId = ''
|
||||
state.activeTabId = "";
|
||||
}
|
||||
|
||||
isRestoringFromStorage = false
|
||||
isRestoringFromStorage = false;
|
||||
} catch (error) {
|
||||
console.error('[useTabs] Erreur restauration localStorage:', error)
|
||||
isRestoringFromStorage = false
|
||||
console.error("[useTabs] Erreur restauration localStorage:", error);
|
||||
isRestoringFromStorage = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,31 +122,33 @@ function restoreFromLocalStorage() {
|
||||
* Trouve un onglet par son ID
|
||||
*/
|
||||
function findTab(tabId: string): Tab | undefined {
|
||||
return state.tabs.find((t) => t.id === tabId)
|
||||
return state.tabs.find((t) => t.id === tabId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ouvre un nouvel onglet ou active un onglet existant
|
||||
*/
|
||||
function openTab(newTab: Omit<Tab, 'id' | 'fullTitle'> & { id?: string; fullTitle?: string }): string {
|
||||
function openTab(
|
||||
newTab: Omit<Tab, "id" | "fullTitle"> & { id?: string; fullTitle?: string },
|
||||
): string {
|
||||
// Générer un ID si non fourni
|
||||
const tabId =
|
||||
newTab.id ||
|
||||
(newTab.type === 'renderer'
|
||||
(newTab.type === "renderer"
|
||||
? `renderer-${newTab.metadata?.rendererId}`
|
||||
: `server-${newTab.metadata?.serverId}`)
|
||||
: `server-${newTab.metadata?.serverId}`);
|
||||
|
||||
// Si l'onglet existe déjà, on le sélectionne
|
||||
const existingTab = findTab(tabId)
|
||||
const existingTab = findTab(tabId);
|
||||
if (existingTab) {
|
||||
switchTab(tabId)
|
||||
return tabId
|
||||
switchTab(tabId);
|
||||
return tabId;
|
||||
}
|
||||
|
||||
// Vérifier la limite max
|
||||
if (state.tabs.length >= MAX_TABS) {
|
||||
console.warn(`[useTabs] Limite max de ${MAX_TABS} onglets atteinte`)
|
||||
return state.activeTabId
|
||||
console.warn(`[useTabs] Limite max de ${MAX_TABS} onglets atteinte`);
|
||||
return state.activeTabId;
|
||||
}
|
||||
|
||||
// Créer le nouvel onglet
|
||||
@@ -155,12 +160,12 @@ function openTab(newTab: Omit<Tab, 'id' | 'fullTitle'> & { id?: string; fullTitl
|
||||
icon: newTab.icon,
|
||||
metadata: newTab.metadata,
|
||||
closeable: newTab.closeable !== false, // true par défaut sauf si explicitement false
|
||||
}
|
||||
};
|
||||
|
||||
state.tabs.push(tab)
|
||||
switchTab(tabId)
|
||||
state.tabs.push(tab);
|
||||
switchTab(tabId);
|
||||
|
||||
return tabId
|
||||
return tabId;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -168,24 +173,26 @@ function openTab(newTab: Omit<Tab, 'id' | 'fullTitle'> & { id?: string; fullTitl
|
||||
* Les onglets renderer sont auto-gérés et ne peuvent pas être fermés manuellement
|
||||
*/
|
||||
function closeTab(tabId: string) {
|
||||
const tab = findTab(tabId)
|
||||
if (!tab) return
|
||||
const tab = findTab(tabId);
|
||||
if (!tab) return;
|
||||
|
||||
// Ne fermer que les onglets server (closeable = true)
|
||||
// Les renderer tabs sont auto-gérés par syncWithRenderers
|
||||
if (!tab.closeable || tab.type === 'renderer') {
|
||||
console.warn('[useTabs] Impossible de fermer un onglet renderer (auto-géré)')
|
||||
return
|
||||
if (!tab.closeable || tab.type === "renderer") {
|
||||
console.warn(
|
||||
"[useTabs] Impossible de fermer un onglet renderer (auto-géré)",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const tabIndex = state.tabs.findIndex((t) => t.id === tabId)
|
||||
if (tabIndex === -1) return
|
||||
const tabIndex = state.tabs.findIndex((t) => t.id === tabId);
|
||||
if (tabIndex === -1) return;
|
||||
|
||||
// Supprimer l'onglet
|
||||
state.tabs.splice(tabIndex, 1)
|
||||
state.tabs.splice(tabIndex, 1);
|
||||
|
||||
// Supprimer de l'historique
|
||||
state.tabHistory = state.tabHistory.filter((id) => id !== tabId)
|
||||
state.tabHistory = state.tabHistory.filter((id) => id !== tabId);
|
||||
|
||||
// Si c'était l'onglet actif, basculer vers le précédent dans l'historique
|
||||
if (state.activeTabId === tabId) {
|
||||
@@ -193,17 +200,17 @@ function closeTab(tabId: string) {
|
||||
const previousTab = state.tabHistory
|
||||
.slice()
|
||||
.reverse()
|
||||
.find((id) => state.tabs.some((t) => t.id === id))
|
||||
.find((id) => state.tabs.some((t) => t.id === id));
|
||||
|
||||
if (previousTab) {
|
||||
switchTab(previousTab)
|
||||
switchTab(previousTab);
|
||||
} else {
|
||||
// Fallback sur le premier onglet disponible (ou vide)
|
||||
const firstTab = state.tabs[0]
|
||||
const firstTab = state.tabs[0];
|
||||
if (firstTab) {
|
||||
switchTab(firstTab.id)
|
||||
switchTab(firstTab.id);
|
||||
} else {
|
||||
state.activeTabId = ''
|
||||
state.activeTabId = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -213,21 +220,21 @@ function closeTab(tabId: string) {
|
||||
* Change l'onglet actif
|
||||
*/
|
||||
function switchTab(tabId: string) {
|
||||
const tab = findTab(tabId)
|
||||
const tab = findTab(tabId);
|
||||
if (!tab) {
|
||||
console.warn(`[useTabs] Onglet ${tabId} introuvable`)
|
||||
return
|
||||
console.warn(`[useTabs] Onglet ${tabId} introuvable`);
|
||||
return;
|
||||
}
|
||||
|
||||
state.activeTabId = tabId
|
||||
state.activeTabId = tabId;
|
||||
|
||||
// Ajouter à l'historique (en évitant les doublons consécutifs)
|
||||
if (state.tabHistory[state.tabHistory.length - 1] !== tabId) {
|
||||
state.tabHistory.push(tabId)
|
||||
state.tabHistory.push(tabId);
|
||||
|
||||
// Limiter la taille de l'historique
|
||||
if (state.tabHistory.length > 20) {
|
||||
state.tabHistory.shift()
|
||||
state.tabHistory.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -236,20 +243,21 @@ function switchTab(tabId: string) {
|
||||
* Onglet suivant (pour swipe gesture)
|
||||
*/
|
||||
function nextTab() {
|
||||
const currentIndex = state.tabs.findIndex((t) => t.id === state.activeTabId)
|
||||
const nextIndex = (currentIndex + 1) % state.tabs.length
|
||||
const nextTabObj = state.tabs[nextIndex]
|
||||
if (nextTabObj) switchTab(nextTabObj.id)
|
||||
const currentIndex = state.tabs.findIndex((t) => t.id === state.activeTabId);
|
||||
const nextIndex = (currentIndex + 1) % state.tabs.length;
|
||||
const nextTabObj = state.tabs[nextIndex];
|
||||
if (nextTabObj) switchTab(nextTabObj.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Onglet précédent (pour swipe gesture)
|
||||
*/
|
||||
function previousTab() {
|
||||
const currentIndex = state.tabs.findIndex((t) => t.id === state.activeTabId)
|
||||
const previousIndex = currentIndex === 0 ? state.tabs.length - 1 : currentIndex - 1
|
||||
const prevTabObj = state.tabs[previousIndex]
|
||||
if (prevTabObj) switchTab(prevTabObj.id)
|
||||
const currentIndex = state.tabs.findIndex((t) => t.id === state.activeTabId);
|
||||
const previousIndex =
|
||||
currentIndex === 0 ? state.tabs.length - 1 : currentIndex - 1;
|
||||
const prevTabObj = state.tabs[previousIndex];
|
||||
if (prevTabObj) switchTab(prevTabObj.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -257,56 +265,63 @@ function previousTab() {
|
||||
* Les renderer tabs sont automatiquement créés/supprimés selon l'état online
|
||||
*/
|
||||
function syncWithRenderers(renderers: RendererSummary[]) {
|
||||
const onlineRenderers = renderers.filter((r) => r.online)
|
||||
const onlineRenderers = renderers.filter((r) => r.online);
|
||||
|
||||
// Extraire les tabs renderer actuels
|
||||
const currentRendererTabs = state.tabs.filter((t) => t.type === 'renderer')
|
||||
const currentRendererTabs = state.tabs.filter((t) => t.type === "renderer");
|
||||
|
||||
// IDs des renderers online
|
||||
const onlineRendererIds = new Set(onlineRenderers.map((r) => `renderer-${r.id}`))
|
||||
const onlineRendererIds = new Set(
|
||||
onlineRenderers.map((r) => `renderer-${r.id}`),
|
||||
);
|
||||
|
||||
// Supprimer les tabs des renderers qui ne sont plus online
|
||||
const renderersToRemove = currentRendererTabs.filter((t) => !onlineRendererIds.has(t.id))
|
||||
const renderersToRemove = currentRendererTabs.filter(
|
||||
(t) => !onlineRendererIds.has(t.id),
|
||||
);
|
||||
renderersToRemove.forEach((tab) => {
|
||||
const index = state.tabs.findIndex((t) => t.id === tab.id)
|
||||
if (index !== -1) state.tabs.splice(index, 1)
|
||||
state.tabHistory = state.tabHistory.filter((id) => id !== tab.id)
|
||||
})
|
||||
const index = state.tabs.findIndex((t) => t.id === tab.id);
|
||||
if (index !== -1) state.tabs.splice(index, 1);
|
||||
state.tabHistory = state.tabHistory.filter((id) => id !== tab.id);
|
||||
});
|
||||
|
||||
// Ajouter les nouveaux renderers online
|
||||
const currentRendererIds = new Set(currentRendererTabs.map((t) => t.id))
|
||||
const currentRendererIds = new Set(currentRendererTabs.map((t) => t.id));
|
||||
onlineRenderers.forEach((renderer) => {
|
||||
const tabId = `renderer-${renderer.id}`
|
||||
const tabId = `renderer-${renderer.id}`;
|
||||
if (!currentRendererIds.has(tabId)) {
|
||||
const newTab: Tab = {
|
||||
id: tabId,
|
||||
type: 'renderer',
|
||||
type: "renderer",
|
||||
title: truncateTitle(renderer.friendly_name),
|
||||
fullTitle: renderer.friendly_name,
|
||||
icon: Radio,
|
||||
metadata: { rendererId: renderer.id },
|
||||
closeable: false, // renderer tabs ne sont pas fermables manuellement
|
||||
}
|
||||
state.tabs.unshift(newTab) // Ajouter au début
|
||||
};
|
||||
state.tabs.unshift(newTab); // Ajouter au début
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// Vérifier que l'onglet actif existe toujours
|
||||
if (state.activeTabId && !state.tabs.find((t) => t.id === state.activeTabId)) {
|
||||
if (
|
||||
state.activeTabId &&
|
||||
!state.tabs.find((t) => t.id === state.activeTabId)
|
||||
) {
|
||||
// Basculer vers le premier onglet disponible
|
||||
const firstTab = state.tabs[0]
|
||||
const firstTab = state.tabs[0];
|
||||
if (firstTab) {
|
||||
state.activeTabId = firstTab.id
|
||||
state.activeTabId = firstTab.id;
|
||||
} else {
|
||||
state.activeTabId = ''
|
||||
state.activeTabId = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Si aucun onglet actif et qu'il y a des onglets, sélectionner le premier
|
||||
if (!state.activeTabId && state.tabs.length > 0) {
|
||||
const firstTab = state.tabs[0]
|
||||
const firstTab = state.tabs[0];
|
||||
if (firstTab) {
|
||||
state.activeTabId = firstTab.id
|
||||
state.activeTabId = firstTab.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -315,15 +330,15 @@ function syncWithRenderers(renderers: RendererSummary[]) {
|
||||
* Ouvre un onglet server (manuel)
|
||||
*/
|
||||
function openServer(server: MediaServerSummary | undefined) {
|
||||
if (!server) return ''
|
||||
if (!server) return "";
|
||||
|
||||
return openTab({
|
||||
type: 'server',
|
||||
type: "server",
|
||||
title: server.friendly_name,
|
||||
icon: Server,
|
||||
metadata: { serverId: server.id },
|
||||
closeable: true,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -334,29 +349,45 @@ export function useTabs() {
|
||||
watch(
|
||||
() => [state.tabs, state.activeTabId, state.tabHistory],
|
||||
() => {
|
||||
saveToLocalStorage()
|
||||
saveToLocalStorage();
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
);
|
||||
|
||||
// Restaurer au montage (uniquement les server tabs)
|
||||
onMounted(() => {
|
||||
// Restaurer seulement si pas déjà fait
|
||||
if (state.tabs.filter((t) => t.type === 'server').length === 0) {
|
||||
restoreFromLocalStorage()
|
||||
if (state.tabs.filter((t) => t.type === "server").length === 0) {
|
||||
restoreFromLocalStorage();
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// Détection de la largeur d'écran pour le mode compact
|
||||
// Mode compact sur les écrans < 900px (tablettes et mobiles)
|
||||
const isNarrowScreen = useMediaQuery('(max-width: 900px)')
|
||||
const isNarrowScreen = useMediaQuery("(max-width: 900px)");
|
||||
|
||||
// Watch activeTabId changes for debugging
|
||||
watch(
|
||||
() => state.activeTabId,
|
||||
(newId, oldId) => {
|
||||
if (newId !== oldId) {
|
||||
const newTab = findTab(newId);
|
||||
const oldTab = findTab(oldId);
|
||||
console.log(
|
||||
`[useTabs] activeTabId changed from ${oldId} (${oldTab?.fullTitle}) to ${newId} (${newTab?.fullTitle})`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Computed properties
|
||||
const activeTab = computed(() => findTab(state.activeTabId) || state.tabs[0] || null)
|
||||
const hasMultipleTabs = computed(() => state.tabs.length > 1)
|
||||
const canAddTab = computed(() => state.tabs.length < MAX_TABS)
|
||||
const isEmpty = computed(() => state.tabs.length === 0)
|
||||
const compactMode = computed(() => isNarrowScreen.value)
|
||||
const activeTab = computed(
|
||||
() => findTab(state.activeTabId) || state.tabs[0] || null,
|
||||
);
|
||||
const hasMultipleTabs = computed(() => state.tabs.length > 1);
|
||||
const canAddTab = computed(() => state.tabs.length < MAX_TABS);
|
||||
const isEmpty = computed(() => state.tabs.length === 0);
|
||||
const compactMode = computed(() => isNarrowScreen.value);
|
||||
|
||||
return {
|
||||
// State
|
||||
@@ -378,5 +409,5 @@ export function useTabs() {
|
||||
previousTab,
|
||||
openServer,
|
||||
findTab,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
289
pmoapp/webapp/src/composables/useWebRenderer.ts
Normal file
289
pmoapp/webapp/src/composables/useWebRenderer.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
|
||||
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";
|
||||
|
||||
function generateUUID(): string {
|
||||
if (typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
|
||||
const hex = Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
|
||||
function getOrCreateInstanceId(): string {
|
||||
try {
|
||||
let id = sessionStorage.getItem(INSTANCE_ID_KEY);
|
||||
if (!id) {
|
||||
id = generateUUID();
|
||||
sessionStorage.setItem(INSTANCE_ID_KEY, id);
|
||||
}
|
||||
return id;
|
||||
} catch {
|
||||
return generateUUID();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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);
|
||||
|
||||
let audioEl: HTMLAudioElement | null = null;
|
||||
let instanceId: string | null = null;
|
||||
let currentStreamUrl: string | 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();
|
||||
|
||||
const body: RegisterRequest = {
|
||||
instance_id: instanceId,
|
||||
user_agent: navigator.userAgent,
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/webrenderer/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
console.error("[WebRenderer] register failed:", resp.status);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await resp.json()) as RegisterResponse;
|
||||
streamUrl.value = data.stream_url;
|
||||
currentStreamUrl = data.stream_url;
|
||||
rendererUdn.value = data.udn;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Désenregistrement ─────────────────────────────────────────────────────
|
||||
|
||||
async function unregister(): Promise<void> {
|
||||
if (!instanceId) return;
|
||||
try {
|
||||
await fetch(`/api/webrenderer/${instanceId}`, { method: "DELETE" });
|
||||
} catch {
|
||||
// Ignoré lors du déchargement de page
|
||||
}
|
||||
instanceId = null;
|
||||
}
|
||||
|
||||
// ── Volume / Mute ─────────────────────────────────────────────────────────
|
||||
|
||||
function setVolume(v: number): void {
|
||||
if (audioEl) audioEl.volume = v;
|
||||
}
|
||||
|
||||
function setMute(m: boolean): void {
|
||||
if (audioEl) audioEl.muted = m;
|
||||
}
|
||||
|
||||
// ── 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,
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import "./style.css";
|
||||
import "./assets/styles/variables.css";
|
||||
import "./assets/styles/pmocontrol.css";
|
||||
import "./assets/styles/glass-theme.css";
|
||||
import "./assets/styles/drawers.css";
|
||||
|
||||
// Créer l'application Vue
|
||||
const app = createApp(App);
|
||||
@@ -34,12 +35,7 @@ const uiStore = useUIStore();
|
||||
|
||||
sse.onConnectionChange((connected) => {
|
||||
uiStore.setSSEConnected(connected);
|
||||
if (connected) {
|
||||
console.log("[App] SSE connecté");
|
||||
}
|
||||
});
|
||||
|
||||
// Démarrer la connexion SSE
|
||||
sse.connect();
|
||||
|
||||
console.log("[App] PMOControl initialisé");
|
||||
|
||||
@@ -17,6 +17,7 @@ import PlayListManager from "../components/PlayListManager.vue";
|
||||
import UpnpExplorer from "../components/UpnpExplorer.vue";
|
||||
import APIDashboard from "../components/APIDashboard.vue";
|
||||
import RadioParadiseExplorer from "../components/RadioParadiseExplorer.vue";
|
||||
import DebugView from "../views/DebugView.vue";
|
||||
|
||||
const routes = [
|
||||
// PMOControl Unified Interface (nouvelle interface unifiée avec onglets)
|
||||
@@ -43,6 +44,13 @@ const routes = [
|
||||
component: MediaServerView,
|
||||
},
|
||||
|
||||
// Debug hub
|
||||
{
|
||||
path: "/debug",
|
||||
name: "Debug",
|
||||
component: DebugView,
|
||||
},
|
||||
|
||||
// Debug menu (anciennes routes déplacées sous /debug)
|
||||
{
|
||||
path: "/debug/generic-player",
|
||||
|
||||
@@ -418,9 +418,21 @@ class PMOControlAPI {
|
||||
async browseContainer(
|
||||
serverId: string,
|
||||
containerId: string,
|
||||
offset = 0,
|
||||
limit = 50,
|
||||
): Promise<BrowseResponse> {
|
||||
return this.request<BrowseResponse>(
|
||||
`/servers/${encodeURIComponent(serverId)}/containers/${encodeURIComponent(containerId)}`,
|
||||
`/servers/${encodeURIComponent(serverId)}/containers/${encodeURIComponent(containerId)}?offset=${offset}&limit=${limit}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche dans un serveur media
|
||||
* GET /api/control/servers/{serverId}/search?q={query}
|
||||
*/
|
||||
async searchServer(serverId: string, query: string): Promise<BrowseResponse> {
|
||||
return this.request<BrowseResponse>(
|
||||
`/servers/${encodeURIComponent(serverId)}/search?q=${encodeURIComponent(query)}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,13 +31,10 @@ export class PMOControlSSE {
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[SSE] Connexion à /api/control/events...')
|
||||
|
||||
try {
|
||||
this.eventSource = new EventSource('/api/control/events')
|
||||
|
||||
this.eventSource.onopen = () => {
|
||||
console.log('[SSE] Connexion établie')
|
||||
this.reconnectAttempts = 0
|
||||
this.isConnected = true
|
||||
this.notifyConnectionCallbacks(true)
|
||||
@@ -75,7 +72,6 @@ export class PMOControlSSE {
|
||||
}
|
||||
|
||||
if (this.eventSource) {
|
||||
console.log('[SSE] Déconnexion')
|
||||
this.eventSource.close()
|
||||
this.eventSource = null
|
||||
this.isConnected = false
|
||||
@@ -99,8 +95,6 @@ export class PMOControlSSE {
|
||||
this.maxReconnectDelay
|
||||
)
|
||||
|
||||
console.log(`[SSE] Reconnexion dans ${delay / 1000}s (tentative ${this.reconnectAttempts})`)
|
||||
|
||||
this.reconnectTimer = window.setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
this.connect()
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface RendererSummary {
|
||||
protocol: RendererProtocolSummary;
|
||||
capabilities: RendererCapabilitiesSummary;
|
||||
online: boolean;
|
||||
server_host?: string;
|
||||
}
|
||||
|
||||
export interface RendererState {
|
||||
@@ -91,6 +92,7 @@ export interface FullRendererSnapshot {
|
||||
state: RendererState;
|
||||
queue: QueueSnapshot;
|
||||
binding: AttachedPlaylistInfo | null;
|
||||
is_stream: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -118,6 +120,8 @@ export interface ContainerEntry {
|
||||
export interface BrowseResponse {
|
||||
container_id: string;
|
||||
entries: ContainerEntry[];
|
||||
total_count: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -193,6 +197,11 @@ export type RendererEventPayload =
|
||||
queue_length: number;
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "queue_refreshing";
|
||||
renderer_id: string;
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "binding_changed";
|
||||
renderer_id: string;
|
||||
@@ -200,6 +209,12 @@ export type RendererEventPayload =
|
||||
container_id: string | null;
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "stream_state_changed";
|
||||
renderer_id: string;
|
||||
is_stream: boolean;
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "timer_started";
|
||||
renderer_id: string;
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
/* Taille de base légèrement réduite — remplace le zoom: 0.85 global */
|
||||
font-size: 15px;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
@@ -68,8 +71,6 @@ button:focus-visible {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
zoom: 0.85;
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
|
||||
49
pmoapp/webapp/src/utils/string.ts
Normal file
49
pmoapp/webapp/src/utils/string.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Utilitaires pour les chaînes de caractères
|
||||
*/
|
||||
|
||||
/**
|
||||
* Génère un hash simple et rapide pour une chaîne
|
||||
* @param str - Chaîne à hasher
|
||||
* @returns Hash sous forme de chaîne hexadécimale positive
|
||||
*/
|
||||
export function simpleHash(str: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
return Math.abs(hash).toString(36);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute un paramètre cache-busting à une URL
|
||||
* @param url - URL originale
|
||||
* @param cacheKey - Clé de cache (hash ou timestamp)
|
||||
* @returns URL avec le paramètre _cb ajouté
|
||||
*/
|
||||
export function addCacheBust(url: string, cacheKey: string): string {
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
return `${url}${separator}_cb=${cacheKey}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoie une URL en supprimant les paramètres de cache-busting
|
||||
* @param url - URL avec possibly _cb params
|
||||
* @returns URL nettoyée
|
||||
*/
|
||||
export function normalizeUrl(url: string): string {
|
||||
return url.replace(/[?&]_cb=[^&]*/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tronque une chaîne à une longueur maximale
|
||||
* @param str - Chaîne à tronquer
|
||||
* @param maxLength - Longueur maximale
|
||||
* @returns Chaîne tronquée avec suffix si nécessaire
|
||||
*/
|
||||
export function truncate(str: string, maxLength: number, suffix = '...'): string {
|
||||
if (str.length <= maxLength) return str;
|
||||
return str.slice(0, maxLength - suffix.length) + suffix;
|
||||
}
|
||||
60
pmoapp/webapp/src/utils/time.ts
Normal file
60
pmoapp/webapp/src/utils/time.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Utilitaires pour les dates et durées
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convertit une durée au format HH:MM:SS en millisecondes
|
||||
* @param time - Durée au format "HH:MM:SS" ou "MM:SS"
|
||||
* @returns Durée en millisecondes, ou null si invalide
|
||||
*/
|
||||
export function parseTimeToMs(time: string | null | undefined): number | null {
|
||||
if (!time) return null;
|
||||
|
||||
const parts = time.split(':').map(Number);
|
||||
|
||||
if (parts.length === 3) {
|
||||
// HH:MM:SS
|
||||
const hours = parts[0] ?? 0;
|
||||
const minutes = parts[1] ?? 0;
|
||||
const seconds = parts[2] ?? 0;
|
||||
if (isNaN(hours) || isNaN(minutes) || isNaN(seconds)) return null;
|
||||
return (hours * 3600 + minutes * 60 + seconds) * 1000;
|
||||
} else if (parts.length === 2) {
|
||||
// MM:SS
|
||||
const minutes = parts[0] ?? 0;
|
||||
const seconds = parts[1] ?? 0;
|
||||
if (isNaN(minutes) || isNaN(seconds)) return null;
|
||||
return (minutes * 60 + seconds) * 1000;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit des millisecondes en format HH:MM:SS
|
||||
* @param ms - Durée en millisecondes
|
||||
* @returns Durée au format "HH:MM:SS" ou "MM:SS"
|
||||
*/
|
||||
export function formatMsToTime(ms: number | null): string {
|
||||
if (ms === null || ms === undefined || ms < 0) return '--:--';
|
||||
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
const h = hours > 0 ? `${hours}:` : '';
|
||||
const m = `${minutes.toString().padStart(2, '0')}:`;
|
||||
const s = seconds.toString().padStart(2, '0');
|
||||
|
||||
return `${h}${m}${s}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit des millisecondes en format court (pour l'affichage progress)
|
||||
* @param ms - Durée en millisecondes
|
||||
* @returns Durée au format "X:XX" ou "X:XX:XX"
|
||||
*/
|
||||
export function formatMsToShortTime(ms: number | null): string {
|
||||
return formatMsToTime(ms);
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRenderers } from '@/composables/useRenderers'
|
||||
import { useMediaServers } from '@/composables/useMediaServers'
|
||||
import RendererCard from '@/components/pmocontrol/RendererCard.vue'
|
||||
import MediaServerCard from '@/components/pmocontrol/MediaServerCard.vue'
|
||||
import { Radio, Server } from 'lucide-vue-next'
|
||||
import { Radio, Server, Loader2 } from 'lucide-vue-next'
|
||||
|
||||
const {
|
||||
allRenderers: renderers,
|
||||
onlineRenderers,
|
||||
getStateById,
|
||||
fetchRenderers,
|
||||
fetchRendererSnapshot
|
||||
fetchBatchSnapshots
|
||||
} = useRenderers()
|
||||
|
||||
const {
|
||||
@@ -20,15 +20,34 @@ const {
|
||||
fetchServers
|
||||
} = useMediaServers()
|
||||
|
||||
// État de chargement pour l'UI
|
||||
const isLoadingSnapshots = ref(false)
|
||||
|
||||
// Charger les données au montage
|
||||
onMounted(async () => {
|
||||
await fetchRenderers()
|
||||
await fetchServers()
|
||||
// Charger les listes de renderers et servers en parallèle
|
||||
await Promise.all([
|
||||
fetchRenderers(),
|
||||
fetchServers()
|
||||
])
|
||||
|
||||
for (const renderer of renderers.value) {
|
||||
fetchRendererSnapshot(renderer.id, { force: true })
|
||||
// Charger les snapshots en batch controlé
|
||||
// On récupère les IDs des renderers découverts
|
||||
const rendererIds = renderers.value.map(r => r.id)
|
||||
|
||||
if (rendererIds.length > 0) {
|
||||
isLoadingSnapshots.value = true
|
||||
|
||||
// Appel batch avec concurrency controlée
|
||||
await fetchBatchSnapshots(rendererIds, {
|
||||
concurrency: 3, // Max 3 requêtes simultanées
|
||||
batchDelay: 100, // 100ms entre chaque batch
|
||||
force: true
|
||||
})
|
||||
|
||||
isLoadingSnapshots.value = false
|
||||
}
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -55,6 +74,10 @@ onMounted(async () => {
|
||||
<Radio :size="24" />
|
||||
<span>Renderers Audio</span>
|
||||
</h2>
|
||||
<span v-if="isLoadingSnapshots" class="loading-indicator">
|
||||
<Loader2 :size="16" class="spin" />
|
||||
Chargement...
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="renderers.length" class="renderers-grid">
|
||||
@@ -175,6 +198,24 @@ onMounted(async () => {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Loading indicator */
|
||||
.loading-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.loading-indicator .spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Grids */
|
||||
.renderers-grid,
|
||||
.servers-grid {
|
||||
|
||||
197
pmoapp/webapp/src/views/DebugView.vue
Normal file
197
pmoapp/webapp/src/views/DebugView.vue
Normal file
@@ -0,0 +1,197 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from "vue-router";
|
||||
import {
|
||||
Music,
|
||||
ScrollText,
|
||||
Image,
|
||||
Database,
|
||||
ListMusic,
|
||||
Network,
|
||||
LayoutDashboard,
|
||||
Radio,
|
||||
ArrowLeft,
|
||||
} from "lucide-vue-next";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const tools = [
|
||||
{
|
||||
path: "/debug/covers-cache",
|
||||
name: "Cache des couvertures",
|
||||
description: "Visualiser et gérer le cache d'images de couverture",
|
||||
icon: Image,
|
||||
},
|
||||
{
|
||||
path: "/debug/audio-cache",
|
||||
name: "Cache audio",
|
||||
description: "Gérer le cache des fichiers audio",
|
||||
icon: Database,
|
||||
},
|
||||
{
|
||||
path: "/debug/playlists",
|
||||
name: "Playlists",
|
||||
description: "Gérer et inspecter les playlists en cache",
|
||||
icon: ListMusic,
|
||||
},
|
||||
{
|
||||
path: "/debug/logs",
|
||||
name: "Logs",
|
||||
description: "Consulter les logs du serveur en temps réel",
|
||||
icon: ScrollText,
|
||||
},
|
||||
{
|
||||
path: "/debug/upnp",
|
||||
name: "Explorateur UPnP",
|
||||
description: "Parcourir les dispositifs et services UPnP",
|
||||
icon: Network,
|
||||
},
|
||||
{
|
||||
path: "/debug/api-dashboard",
|
||||
name: "API Dashboard",
|
||||
description: "Documentation et test des API disponibles",
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{
|
||||
path: "/debug/generic-player",
|
||||
name: "Lecteur générique",
|
||||
description: "Lecteur de test pour les flux audio",
|
||||
icon: Music,
|
||||
},
|
||||
{
|
||||
path: "/debug/radio-paradise",
|
||||
name: "Radio Paradise",
|
||||
description: "Explorateur Radio Paradise",
|
||||
icon: Radio,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="debug-view">
|
||||
<header class="debug-header">
|
||||
<button class="back-btn" @click="router.push('/')" title="Retour">
|
||||
<ArrowLeft :size="20" />
|
||||
</button>
|
||||
<h1 class="debug-title">Outils de debug</h1>
|
||||
</header>
|
||||
|
||||
<div class="debug-grid">
|
||||
<button
|
||||
v-for="tool in tools"
|
||||
:key="tool.path"
|
||||
class="tool-card"
|
||||
@click="router.push(tool.path)"
|
||||
>
|
||||
<div class="tool-icon">
|
||||
<component :is="tool.icon" :size="28" />
|
||||
</div>
|
||||
<div class="tool-info">
|
||||
<div class="tool-name">{{ tool.name }}</div>
|
||||
<div class="tool-desc">{{ tool.description }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.debug-view {
|
||||
min-height: 100vh;
|
||||
padding: var(--spacing-lg);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.debug-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
transition: background var(--transition-fast) ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.debug-title {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.debug-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.tool-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-lg);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--color-text);
|
||||
transition: all var(--transition-fast) ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.tool-card:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.tool-card:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.tool-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(102, 126, 234, 0.15);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.tool-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tool-name {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tool-desc {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from "vue-router";
|
||||
import { useTabs } from "@/composables/useTabs";
|
||||
import { useRenderers } from "@/composables/useRenderers";
|
||||
import { useMediaServers } from "@/composables/useMediaServers";
|
||||
import { useWebRenderer } from "@/composables/useWebRenderer";
|
||||
import { useSwipe } from "@vueuse/core";
|
||||
|
||||
// Import des composants
|
||||
@@ -21,6 +22,11 @@ const { tabs, activeTabId, switchTab, activeTab, syncWithRenderers, isEmpty } =
|
||||
const { allRenderers, fetchRenderers, getStateById } = useRenderers();
|
||||
const { allServers, fetchServers } = useMediaServers();
|
||||
|
||||
// WebRenderer : ce navigateur s'enregistre automatiquement comme renderer UPnP
|
||||
const webRenderer = useWebRenderer();
|
||||
// Rafraîchir la liste des renderers dès que la session WebRenderer est établie
|
||||
webRenderer.onConnected(() => void fetchRenderers(true));
|
||||
|
||||
// État des drawers
|
||||
const drawerOpen = ref(false);
|
||||
const rendererDrawerOpen = ref(false);
|
||||
@@ -97,13 +103,24 @@ function handleRendererSelect(rendererId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Filtre la liste des renderers pour exclure les WebRenderers d'autres navigateurs.
|
||||
// Seul le WebRenderer créé par ce navigateur (identifié par son UDN) reste visible.
|
||||
function filterRenderers(renderers: typeof allRenderers.value) {
|
||||
const myUdn = webRenderer.rendererUdn.value;
|
||||
return renderers.filter((r) => {
|
||||
if (r.model_name !== "WebRenderer") return true; // renderer classique : toujours visible
|
||||
if (myUdn === null) return false; // pas encore de session : masquer tous les WebRenderers
|
||||
return r.id === myUdn; // ne garder que le nôtre
|
||||
});
|
||||
}
|
||||
|
||||
// Sync route query params avec l'état des tabs
|
||||
onMounted(async () => {
|
||||
// Fetch renderers et servers au montage
|
||||
await Promise.all([fetchRenderers(), fetchServers()]);
|
||||
|
||||
// Sync initial des tabs avec les renderers
|
||||
syncWithRenderers(allRenderers.value);
|
||||
// Sync initial des tabs avec les renderers (en excluant les WebRenderers étrangers)
|
||||
syncWithRenderers(filterRenderers(allRenderers.value));
|
||||
|
||||
// Restaurer l'onglet actif depuis l'URL
|
||||
const urlTabId = route.query.tab as string;
|
||||
@@ -116,11 +133,19 @@ onMounted(async () => {
|
||||
watch(
|
||||
() => allRenderers.value,
|
||||
(newRenderers) => {
|
||||
syncWithRenderers(newRenderers);
|
||||
syncWithRenderers(filterRenderers(newRenderers));
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
// Watch l'UDN du WebRenderer local : quand il s'établit, resync pour faire apparaître notre onglet
|
||||
watch(
|
||||
() => webRenderer.rendererUdn.value,
|
||||
() => {
|
||||
syncWithRenderers(filterRenderers(allRenderers.value));
|
||||
},
|
||||
);
|
||||
|
||||
// Watch les changements d'URL pour changer d'onglet
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
@@ -343,4 +368,5 @@ const currentTabProps = computed(() => {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
1
pmoapp/webapp/tsconfig.tsbuildinfo
Normal file
1
pmoapp/webapp/tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"fileNames":[],"fileInfos":[],"root":[],"version":"5.8.3"}
|
||||
@@ -13,6 +13,11 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api/webrenderer/ws': {
|
||||
target: 'ws://localhost:8080',
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
|
||||
@@ -30,10 +30,12 @@ rand = "0.8"
|
||||
bytes = { version = "1.0", optional = true }
|
||||
serde = { workspace = true, optional = true }
|
||||
serde_json = { workspace = true, optional = true }
|
||||
reqwest = { workspace = true, features = ["stream"], optional = true }
|
||||
futures = { version = "0.3", optional = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
cache-sink = ["dep:pmoaudiocache", "dep:pmoflac", "dep:pmometadata", "dep:serde_json"]
|
||||
playlist = ["cache-sink", "dep:pmoplaylist", "dep:pmocache"]
|
||||
http-stream = ["dep:pmoflac", "dep:pmometadata", "dep:bytes", "dep:serde"]
|
||||
http-stream = ["dep:pmoflac", "dep:pmometadata", "dep:bytes", "dep:serde", "dep:reqwest", "dep:futures"]
|
||||
all = ["cache-sink", "playlist", "http-stream"]
|
||||
|
||||
@@ -28,7 +28,7 @@ pub mod sinks;
|
||||
#[cfg(any(feature = "cache-sink", feature = "http-stream"))]
|
||||
pub mod nodes;
|
||||
|
||||
#[cfg(feature = "playlist")]
|
||||
#[cfg(any(feature = "playlist", feature = "http-stream"))]
|
||||
pub mod sources;
|
||||
|
||||
// Re-exports pour faciliter l'utilisation
|
||||
@@ -39,4 +39,10 @@ pub use sinks::*;
|
||||
pub use nodes::*;
|
||||
|
||||
#[cfg(feature = "playlist")]
|
||||
pub use sources::*;
|
||||
pub use sources::PlaylistSource;
|
||||
|
||||
#[cfg(feature = "http-stream")]
|
||||
pub use sources::UriSource;
|
||||
|
||||
#[cfg(feature = "http-stream")]
|
||||
pub use sources::{PlayerCommand, PlayerEvent, PlayerHandle, PlayerSource};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user