Compare commits
49 Commits
6fcecaab00
...
push-zpokn
| Author | SHA1 | Date | |
|---|---|---|---|
| 3401ee839c | |||
| e8e33414f0 | |||
| b0e24c3b3c | |||
| b5b6becb25 | |||
| 353e54af76 | |||
| f2be1b9bc1 | |||
| 6e65558ba9 | |||
| 4c7c99f0a4 | |||
| 5c6e2d43ac | |||
| 6fd11ab450 | |||
| 57c2552247 | |||
| 9935a5e5af | |||
| 3979d346a5 | |||
| 6240179397 | |||
| 934bbaf4b8 | |||
| 35e2e037f0 | |||
| 9dee193947 | |||
| c128120697 | |||
| 4b7c482bf3 | |||
| 4c70a939cd | |||
| 77cfb7b3e3 | |||
| abe8c1a4b2 | |||
| 22b3a67417 | |||
| cba1c01b1b | |||
| 8be250b167 | |||
| 78c37e1732 | |||
| 164e3d9e5f | |||
| ac9cb3ef3f | |||
| 66ba31b12f | |||
| bb06fbe30e | |||
| 19451068f1 | |||
| 23b1ce7661 | |||
| 813fc97fa1 | |||
| 1459958978 | |||
| e055133e4f | |||
| e375d447d4 | |||
| fe2d755b4a | |||
| c13de9f46b | |||
| 32e3a18895 | |||
| 76a1c89137 | |||
| fe9dd9aba6 | |||
| 512dcd1803 | |||
| 623ce44774 | |||
| 6b0bfe86e3 | |||
| d5b1ed5635 | |||
| 546e8a782f | |||
| 340c69cb2b | |||
| 8924552696 | |||
| 33b279ff1e |
151
.kilo/plans/1775302116634-sunny-nebula.md
Normal file
151
.kilo/plans/1775302116634-sunny-nebula.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# Plan: pmowebrenderer - Améliorations et Multi-client avec DSP
|
||||
|
||||
## Objectifs
|
||||
|
||||
1. **Améliorer l'intégration UPnP Control** - Meilleur fonctionnement des commandes Play/Pause/Seek
|
||||
- Contrôle piloté près de la sortie (streaming) plutôt qu'au début du pipeline
|
||||
- Pour Pause: latence actuelle trop importante
|
||||
- Play/Pause/Seek doivent fonctionner simultanément sur tous les clients
|
||||
|
||||
2. **Améliorer la performance - Latence** - Réduire le délai entre l'envoi et la lecture
|
||||
|
||||
3. **Ajouter le support multi-client avec DSP** - Chaque client peut avoir son propre pipeline DSP
|
||||
|
||||
## Comportement UPnP Control
|
||||
|
||||
### Mode Radio (flux infini)
|
||||
- Pas de pause possible, hanya next ou stop
|
||||
- Seek n'a pas de sens
|
||||
|
||||
|
||||
### Architecture
|
||||
```
|
||||
PlayerSource → ResamplingNode → ToI24Node
|
||||
├──→ [DSP Client 1] → StreamingOggFlacSink 1
|
||||
├──→ [DSP Client 2] → StreamingOggFlacSink 2
|
||||
└──→ ... (dynamique)
|
||||
```
|
||||
|
||||
Le control point UPnP voit UN seul Media Renderer. Les commandes Play/Pause/Seek affectent TOUTES les sorties client simultanément.
|
||||
|
||||
## État Actuel
|
||||
|
||||
Le pipeline actuel est linéaire pour un seul client:
|
||||
```
|
||||
PlayerSource → ResamplingNode (96kHz) → ToI24Node → StreamingOggFlacSink
|
||||
```
|
||||
|
||||
**Note importante:** Utiliser les crates pmoaudio et pmoaudio-ext existantes. Il est possible d'avoir plusieurs `StreamingOggFlacSink` consommant le même flux. Après ToI24Node, brancher en étoiles les différents DSP pour les différents clients.
|
||||
|
||||
## Plan d'Implémentation
|
||||
|
||||
### Phase 1: Amélioration UPnP Control
|
||||
|
||||
1. **Analyser les handlers existants** dans `handlers.rs`
|
||||
2. **Identifier les problèmes** avec Play/Pause/Seek:
|
||||
- Timing des transitions d'état
|
||||
- Gestion des erreurs
|
||||
- Synchronisation entre clients HTTP et état UPnP
|
||||
3. **Améliorer la fiabilité** des commandes
|
||||
- Piloter le contrôle près de la sortie (streaming)
|
||||
- Différerencier le comportement radio vs piste finie
|
||||
|
||||
### Phase 2: Amélioration Latence
|
||||
|
||||
1. **Réduire le buffer** dans `StreamingOggFlacSink`
|
||||
2. **Optimiser le pacing** (actuellement max 0.5s ahead)
|
||||
3. **Améliorer la directité** du chemin audio
|
||||
|
||||
### Phase 3: Architecture Multi-client avec DSP
|
||||
|
||||
1. **Refactorer le pipeline** pour supporter plusieurs clients comme décrit ci-dessus
|
||||
|
||||
2. **Créer un système de DSP** dans pmoaudio ou pmoaudio-ext:
|
||||
- Interface commune pour les effets audio
|
||||
- Config des DSP via PMOconfig
|
||||
- Room correction: equalizer, FIR filter, delay, gain
|
||||
|
||||
3. **Gérer le cycle de vie**:
|
||||
- Création du pipeline par client
|
||||
- Nettoyage lors de la déconnexion
|
||||
- Partage de la source commune entre clients
|
||||
|
||||
## Fichiers à Modifier
|
||||
|
||||
- `pipeline.rs` - Refactoring pour multi-client
|
||||
- `handlers.rs` - Amélioration UPnP control
|
||||
- `stream.rs` - Gestion multi-client
|
||||
- `state.rs` - État par client
|
||||
- pmoaudio ou pmoaudio-ext pour les mécanismes DSP
|
||||
|
||||
## Défis Potentiels
|
||||
|
||||
- Performance CPU avec plusieurs clients
|
||||
- Synchronisation des clients avec le même contenu
|
||||
- Gestion du gapless entre les pistes avec multi-client
|
||||
|
||||
### Gestion de la Pause
|
||||
|
||||
**Option recommandée: Silence (zéros)**
|
||||
- Pendant la pause, continuer à envoyer des zéros encodés en FLAC
|
||||
- Le client HTTP maintient sa connexion TCP alive
|
||||
- Pas de reconnexion nécessaire quand on reprend la lecture
|
||||
- Avantage: Seamless pour le client
|
||||
|
||||
**Pourquoi pas réduction du sample rate:**
|
||||
- Le header FLAC définit le sample rate en固定entête
|
||||
- Changer le sample rate en cours de flux invalidate le flux entier
|
||||
- Rebuild du flux serait plus complexe que le gain obtenu
|
||||
- FLAC compresse très bien les zéros de toute façon (beaucoup de répétitions)
|
||||
|
||||
**Autre option envisagée mais non recommandée:**
|
||||
- Suspendre l'envoi: Le client HTTP va timeout et se déconnecter
|
||||
- Segment OGG avec metadata: Complexe à implémenter, nécessite modification du client
|
||||
|
||||
### Phase 0: StreamingOggFlacSink avec contrôle Pause
|
||||
|
||||
**Distinction Radio vs Pistes finies:**
|
||||
|
||||
| Mode | Comportement pendant Pause |
|
||||
|------|---------------------------|
|
||||
| **Radio (flux infini)** | Les chunks qui arrivent sont ignorés/perdus. On envoie du silence. La source continue à produire mais on n'en tient pas compte. |
|
||||
| **Pistes finies** | On bloque la consommation des chunks. Par backpressure, le pipeline en amont s'arrête (TimerBufferNode arrête d'envoyer). La lecture est truly arrêtée. |
|
||||
|
||||
**Architecture actuelle analysée:**
|
||||
```
|
||||
AudioSegment → StreamingOggFlacSink → FLAC encoder → OGG wrapper → timed_broadcast → clients
|
||||
```
|
||||
|
||||
**Implémentation suggérée:**
|
||||
|
||||
1. **État de lecture distingué:**
|
||||
- `PlaybackMode::Radio` - ignore les chunks entrants pendant pause
|
||||
- `PlaybackMode::Track` - bloque la consommation (backpressure)
|
||||
|
||||
2. **Dans SharedSinkContext:**
|
||||
```rust
|
||||
pub enum PlaybackMode {
|
||||
Radio, // Flux infini - ignore chunks pendant pause
|
||||
Track, // Piste finie - block par backpressure
|
||||
}
|
||||
|
||||
pub playback_mode: PlaybackMode,
|
||||
pub is_paused: Arc<AtomicBool>,
|
||||
```
|
||||
|
||||
3. **Traitement différent selon le mode:**
|
||||
- **Radio**: Si `is_paused`, envoyer silence (zéros) mais perdre les chunks entrants
|
||||
- **Track**: Si `is_paused`, ne pas consommer les chunks → backpressure → arrêt du pipeline en amont
|
||||
|
||||
4. **Transition automatique:**
|
||||
- Détecter le type de contenu via les métadonnées du TrackBoundary
|
||||
- **Enrichir TrackBoundary** avec un champ `stream_type`:
|
||||
```rust
|
||||
pub enum StreamType {
|
||||
Continuous, // Radio/webcast - flux infini
|
||||
Finite, // Piste/album - flux avec fin définie
|
||||
}
|
||||
|
||||
pub stream_type: StreamType,
|
||||
```
|
||||
- Si durée inconnue = Radio (Continuous), si durée connue = Track (Finite)
|
||||
58
.kilo/plans/1775382570642-kind-falcon.md
Normal file
58
.kilo/plans/1775382570642-kind-falcon.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Refonte de `pmowebrenderer` – Élimination des redondances
|
||||
|
||||
## Objectif
|
||||
Réduire la duplication de code entre les fonctions de construction de services UPnP (`build_avtransport`, `build_renderingcontrol`, `build_connectionmanager`) et les macros d’ajout d’arguments (`add_arg_in!`, `add_arg_out!`).
|
||||
Cela améliore la maintenabilité, la lisibilité et diminue le risque d’incohérences.
|
||||
|
||||
## Étapes détaillées
|
||||
|
||||
1. **Création d’une fonction générique `build_service`**
|
||||
- Signature proposée:
|
||||
```rust
|
||||
fn build_service(
|
||||
name: &str,
|
||||
variables: Vec<Arc<Variable>>,
|
||||
actions: Vec<Action>,
|
||||
handlers: Vec<Handler>,
|
||||
) -> Result<Service, FactoryError>
|
||||
```
|
||||
- Implémentation unique de l’ajout de variables, d’actions et de handlers.
|
||||
- Chaque fonction existante (`build_avtransport`, `build_renderingcontrol`, `build_connectionmanager`) appelle `build_service` avec les paramètres spécifiques.
|
||||
|
||||
2. **Refactorisation des macros**
|
||||
- Remplacer `macro_rules! add_arg_in!` et `add_arg_out!` par des fonctions如此一来 :
|
||||
```rust
|
||||
fn add_arg_in(action: &mut Action, name: &str, var: Arc<Argument>) -> Result<(), FactoryError>
|
||||
fn add_arg_out(action: &mut Action, name: &str, var: Arc<Argument>) -> Result<(), FactoryError>
|
||||
```
|
||||
- Ces fonctions encapsulent la logique d’ajout d’arguments et centralisent la gestion d’erreur.
|
||||
|
||||
3. **Mise à jour des implémentations**
|
||||
- Modifier `build_avtransport`, `build_renderingcontrol`, `build_connectionmanager` pour déléguer à `build_service` et aux nouvelles fonctions d’argument.
|
||||
- Vérifier que les imports restent cohérents (ajouter `use` nécessaires pour `Variable`, `Handler`, etc.).
|
||||
|
||||
4. **Suppression des ancrés macros**
|
||||
- Retirer les declarations `macro_rules! add_arg_in!` et `macro_rules! add_arg_out!` du fichier `renderer.rs`.
|
||||
- Adapter le code appelant pour utiliser les fonctions concrètes.
|
||||
|
||||
5. **Tests et CI**
|
||||
- Ajouter des tests unitaires couvrant les nouvelles fonctions `build_service`, `add_arg_in`, `add_arg_out`.
|
||||
- Configurer le pipeline CI pour exécuter `cargo test` et `cargo clippy` afin de détecter d’éventuelles regressions.
|
||||
|
||||
6. **Documentation**
|
||||
- Mettre à jour les commentaires pour refléter les nouvelles abstractions.
|
||||
- Ajouter une section « Refactorisation » dans le `README` décrivant les changements.
|
||||
|
||||
## Impact attendu
|
||||
- **Réduction** : ~12 lignes de code redondantes éliminées.
|
||||
- **Maintenabilité** : modification centralisée de la logique de construction de services.
|
||||
- **Robustesse** : baisse du risque d’incohérences et de bugs liés à la duplication.
|
||||
- **Lisibilité** : code plus explicite et plus proche du modèle de domaine.
|
||||
|
||||
## Prochaines actions
|
||||
1. Implémenter les changements proposés dans les fichiers concernés.
|
||||
2. Exécuter la suite de tests pour valider la refonte.
|
||||
3. Commiter les modifications après revue.
|
||||
|
||||
---
|
||||
Plan finalisé.
|
||||
108
.kilo/plans/1775386308232-quick-orchid.md
Normal file
108
.kilo/plans/1775386308232-quick-orchid.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Plan: Suppression des duplications dans `@pmowebrenderer`
|
||||
|
||||
## Objectif
|
||||
Éliminer les redondances de code identifiées lors de l'audit.
|
||||
|
||||
---
|
||||
|
||||
## Duplication 1: Helper functions dupliquées dans `renderer.rs`
|
||||
|
||||
**Fichiers affectés**: `src/renderer.rs`
|
||||
|
||||
**Problème**:
|
||||
- Lignes 17-54: `add_arg_in`, `add_arg_out`, `add_var`, `add_action` définies
|
||||
- Lignes 150-181: Dans `build_avtransport()`, réimplémentation locale avec closures `|svc, var| { ... }`
|
||||
- Répétition de 20+ appels `add_var(&mut svc, &VAR)?` et `add_action(&mut svc, Arc::new(action))?`
|
||||
|
||||
**Solution**:
|
||||
1. Supprimer les closures locales redéclarées (lignes 150-181)
|
||||
2. Utiliser directement les fonctions helpers du haut du fichier
|
||||
3. Créer une macro ou fonction utilitaire pour les appels répétés:
|
||||
```rust
|
||||
macro_rules! add_vars {
|
||||
($svc:expr, $($var:expr),*) => { $({ add_var($svc, &$var)?; })* };
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Duplication 2: Pattern handlers boilerplate dans `handlers.rs`
|
||||
|
||||
**Fichiers affectés**: `src/handlers.rs`
|
||||
|
||||
**Problème**:
|
||||
- `play_handler`, `stop_handler`, `pause_handler` (lignes 23-71): structure identique
|
||||
- `next_handler`, `previous_handler` (lignes 74-95): clones
|
||||
- Handlers GET (lignes 166-317): pattern `state.clone()` + `Box::pin(async move { ... set!() ... })` dupliqué
|
||||
|
||||
**Solution**:
|
||||
1. Créer un helper générique:
|
||||
```rust
|
||||
fn make_state_handler<F>(state: SharedState, f: F) -> ActionHandler
|
||||
where F: FnOnce(&mut ActionData, &RendererState) -> Result<ActionData, ActionError> + Send + 'static
|
||||
```
|
||||
2. Factoriser les closures `let state = state.clone()` dans chaque handler
|
||||
|
||||
---
|
||||
|
||||
## Duplication 3: Extraction metadata dupliquée
|
||||
|
||||
**Fichiers affectés**: `src/handlers.rs`
|
||||
|
||||
**Problème**:
|
||||
- Lignes 118-123: `set_uri_handler` extraction metadata
|
||||
- Lignes 146-151: `set_next_uri_handler` extraction metadata (identique)
|
||||
|
||||
**Solution**:
|
||||
1. Extraire en fonction utilitaire:
|
||||
```rust
|
||||
fn extract_metadata(data: &ActionData, key: &str) -> String { ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Duplication 4: Méthodes pipeline dans `registry.rs`
|
||||
|
||||
**Fichiers affectés**: `src/registry.rs`
|
||||
|
||||
**Problème**:
|
||||
- `send_pipeline_command` (lignes 272-279) appelle `get_pipeline` (lignes 281-283)
|
||||
- `load_uri` (lignes 286-290), `send_play_command` (lignes 293-296), `send_pause_command` (lignes 299-301) sont des wrappers quasi-identiques
|
||||
|
||||
**Solution**:
|
||||
Consolider en méthodes génériques:
|
||||
```rust
|
||||
pub async fn send_command(&self, instance_id: &str, cmd: PipelineControl) {
|
||||
if let Some(pipeline) = self.get_pipeline(instance_id) {
|
||||
pipeline.send(cmd).await;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Duplication 5: Feature flags avec code dupliqué
|
||||
|
||||
**Fichiers affectés**: `src/registry.rs`
|
||||
|
||||
**Problème**:
|
||||
- Lignes 51-68 et 334-395: double impl de `create_instance` selon feature
|
||||
|
||||
**Solution**:
|
||||
- Extraire la logique commune dans une fonction privée
|
||||
- Utiliser `#[cfg]` seulement pour les différences (appel à pmoserver)
|
||||
|
||||
---
|
||||
|
||||
## Ordre de traitement suggéré
|
||||
|
||||
1. **Phase 1**: Helpers dans `renderer.rs` (les plus simples)
|
||||
2. **Phase 2**: Handlers dans `handlers.rs` (plus complexe, nécessite macro)
|
||||
3. **Phase 3**: Méthodes pipeline dans `registry.rs`
|
||||
4. **Phase 4**: Feature flags
|
||||
|
||||
## Vérification
|
||||
Après chaque phase, exécuter:
|
||||
```bash
|
||||
cargo check --package pmowebrenderer
|
||||
```
|
||||
225
.kilo/plans/1775631632458-happy-cactus.md
Normal file
225
.kilo/plans/1775631632458-happy-cactus.md
Normal file
@@ -0,0 +1,225 @@
|
||||
# Audit PMO Control - Optimisation Playlist OpenHome
|
||||
|
||||
## Résumé Exécutif
|
||||
|
||||
L'utilisateur rapporte des lenteurs significatives lors de la manipulation de playlists de ~1000 titres avec les renderers OpenHome. Les renderers Chromecast et UPnP (avec queue interne) ne sont pas affectés.
|
||||
|
||||
## État des Optimisations Deja Implémentées
|
||||
|
||||
Le precedent plan dans `Blackboard/Todo/enorme_playlist.md` a deja été partiellement implémenté:
|
||||
|
||||
| optimisation | Statut | Emplacement |
|
||||
|-------------|-------|------------|
|
||||
| MAX_BATCH = 256 pour ReadList | ✅ FAIT | `openhome.rs:1015` |
|
||||
| Élimination double queue_snapshot() | ✅ FAIT | `replace_queue_with_pivot()` et `replace_queue_standard_lcs()` |
|
||||
| LCS préfixe/suffixe (lcs_flags_optimized) | ✅ FAIT | `openhome.rs:869` |
|
||||
| Polling adaptatif (is_active) | ✅ FAIT | `musicrenderer.rs:325-374` |
|
||||
| Consolidation invalidation caches | ✅ FAIT | `openhome.rs:222-235` |
|
||||
|
||||
## Contraintes Protocolaires Découvertes
|
||||
|
||||
L'action `Insert` **ne supporte PAS l'insertion par lot** - chaque appel prend un seul Uri/Metadata.
|
||||
|
||||
## Nouvelles Optimisations (Contre-propositions Utilisateur)
|
||||
|
||||
### OPT-1: Fast Path pour 99% des cas de sync_queue
|
||||
|
||||
**Observation**: 99% des changements de playlist sont:
|
||||
- Insertion de nouvelles tracks en **fin de queue**
|
||||
- Délétion de tracks en **début de queue**
|
||||
- Rarement des changements nécessitant un vrai alignement LCS
|
||||
|
||||
**Solution**: Ajouter une détection de pattern avant d'appeler LCS:
|
||||
|
||||
```rust
|
||||
fn smart_sync(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||
let current_ids = self.track_ids()?;
|
||||
|
||||
// Cas 1: Append only (insertion en fin)
|
||||
if items.starts_with(¤t_ids) {
|
||||
// Fast path: juste ajouter les nouveaux items
|
||||
return self.append_only(items.skip(current_ids.len()));
|
||||
}
|
||||
|
||||
// Cas 2: Delete from beginning
|
||||
if current_ids.starts_with(&items) {
|
||||
// Fast path: supprimer de la fin
|
||||
return self.delete_from_beginning(current_ids.len() - items.len());
|
||||
}
|
||||
|
||||
// Cas 3: Full LCS only for complex reorderings
|
||||
return self.replace_queue_standard_lcs(items);
|
||||
}
|
||||
```
|
||||
|
||||
**Impact**: 99% des sync_queue passent de O(N²) à O(N)
|
||||
|
||||
---
|
||||
|
||||
### OPT-2: Queue FIFO pour Opérations OpenHome (Thread Background)
|
||||
|
||||
**Concept**: Une file d'attente FIFO des opérations SOAP exécutée dans un thread dédié.
|
||||
|
||||
```rust
|
||||
pub struct OpenHomeOpQueue {
|
||||
queue: Arc<Mutex<Vec<OpenHomeOp>>>,
|
||||
worker_handle: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
pub enum OpenHomeOp {
|
||||
Insert { uri: String, metadata: String, after_id: u32 },
|
||||
Delete { track_id: u32 },
|
||||
DeleteAll,
|
||||
SeekId { id: u32 },
|
||||
Play,
|
||||
Pause,
|
||||
Stop,
|
||||
SetVolume { volume: u16 },
|
||||
// Meta operations
|
||||
UpdateMetadata { track_id: u32, metadata: String },
|
||||
}
|
||||
|
||||
impl OpenHomeOpQueue {
|
||||
/// Push operation to the FIFO queue
|
||||
pub fn push(&self, op: OpenHomeOp) {
|
||||
self.queue.lock().unwrap().push(op);
|
||||
}
|
||||
|
||||
/// Push with priority (volume, play, stop - need fast response)
|
||||
pub fn push_first(&self, op: OpenHomeOp) {
|
||||
self.queue.lock().unwrap().push_front(op);
|
||||
}
|
||||
|
||||
/// Clear all pending operations (client can flush)
|
||||
pub fn clear(&self) {
|
||||
self.queue.lock().unwrap().clear();
|
||||
}
|
||||
|
||||
/// Worker thread consumes operations
|
||||
fn worker_loop(&self) {
|
||||
loop {
|
||||
let op = self.queue.lock().unwrap().pop_front();
|
||||
match op {
|
||||
Some(op) => self.execute(op),
|
||||
None => thread::sleep(Duration::from_millis(10)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- UI non-bloquante (les operations sont lancées et exec en background)
|
||||
- Batching naturel (plusieurs operations sont executes en sequence)
|
||||
- Priorité via `push_first()` pour play/stop/volume
|
||||
- `clear()` permet d'annuler les operations en attente (ex: playlist changée)
|
||||
|
||||
**Implémentation suggérée**:
|
||||
1. Créer `src/queue/openhome_op_queue.rs` avec la structure
|
||||
2. Intégrer dans `OpenHomeQueue` ou `OpenHomeRenderer`
|
||||
3. Thread de worker lancé au démarrage du control point
|
||||
|
||||
---
|
||||
|
||||
### OPT-3: Métadonnées en Tâche de Fond
|
||||
|
||||
**Observation**: L'appli utilise-t-elle vraiment les métadonnées de la queue OpenHome, ou un cache local?
|
||||
|
||||
Si le control-point maintient son propre cache (plus probable):
|
||||
- Les mises à jour de métadonnées peuvent être traitées en background
|
||||
- Pas besoin de sync immédiate des métadonnées
|
||||
|
||||
**Solution**: Queue séparée pour les operations de métadonnées:
|
||||
|
||||
```rust
|
||||
// Haute priorité (opérations critiques)
|
||||
let high_priority_queue: OpenHomeOpQueue;
|
||||
|
||||
// Basse priorité (métadonnées)
|
||||
let metadata_queue: OpenHomeOpQueue;
|
||||
```
|
||||
|
||||
**Implémentation**:
|
||||
1. Séparer les operations critiques (play/stop/seek/volume) de metadata
|
||||
2. Metadata update traités en background avec délais
|
||||
3. Le cache local du control-point est mis à jour indépendamment
|
||||
|
||||
---
|
||||
|
||||
### OPT-4: Connection Pooling HTTP
|
||||
|
||||
Chaque appel SOAP crée une nouvelle connexion. Avec 1000 insertions:
|
||||
- Overhead TCP: ~10-50ms par appel
|
||||
- Total: 10-50 secondes overhead réseau
|
||||
|
||||
**Solution**: Agent HTTP static avec connection reuse:
|
||||
|
||||
```rust
|
||||
// soap_client.rs
|
||||
static HTTP_AGENT: Lazy<ureq::Agent> = Lazy::new(|| {
|
||||
Agent::config_builder()
|
||||
.timeout_global(Some(Duration::from_secs(30)))
|
||||
.build()
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Plan d'Implémentation Proposé
|
||||
|
||||
### Phase 1: Fast Path LCS (Priorité Haute)
|
||||
|
||||
1. Ajouter `detect_sync_pattern()` dans `openhome.rs`
|
||||
2. Implémenter `append_only()` et `delete_from_beginning()`
|
||||
3. Tester avec playlists réelles
|
||||
|
||||
### Phase 2: Queue FIFO Opérations (Priorité Haute)
|
||||
|
||||
1. Créer `src/queue/openhome_op_queue.rs`
|
||||
2. Implémenter `push()`, `push_first()`, `clear()`
|
||||
3. Thread worker avec loop de consommation
|
||||
4. Intégrer dans `OpenHomeRenderer`
|
||||
|
||||
### Phase 3: Séparation Métadonnées (Priorité Moyenne)
|
||||
|
||||
1. Créer queue séparée pour metadata
|
||||
2. Implémenter batch processing
|
||||
|
||||
### Phase 4: Connection Pooling (Priorité Basse)
|
||||
|
||||
1. Modifier `soap_client.rs` pour agent static
|
||||
|
||||
---
|
||||
|
||||
## Questions pour Clarification
|
||||
|
||||
1. **Cache Métadonnées**: Le control-point utilise-t-il vraiment les métadonnées de la queue OpenHome, ou maintient-il son propre cache qui est alimenté indépendamment?
|
||||
|
||||
2. **Priorité des Opérations**: Pour `push_first()`, quelles opérations nécessitent une réponse rapide?
|
||||
- Volume (immédiat)
|
||||
- Play/Pause/Stop (immédiat)
|
||||
- Seek (rapide)
|
||||
- Insert (peut être différé)
|
||||
|
||||
3. **Comportement en cas de conflit**: Si le client fait `clear()` et que le worker est en train d'exécuter une opération:
|
||||
- Annuler l'opération en cours? ( risky - peut laisser le renderer dans un état inconsistent)
|
||||
- Laisser finir l'opération en cours? (plus sur)
|
||||
|
||||
---
|
||||
|
||||
## Tests Recommandés
|
||||
|
||||
```bash
|
||||
# Compiler
|
||||
cargo build -p pmocontrol
|
||||
|
||||
# Benchmark LCS fast paths
|
||||
# - Cas: append 100 tracks to 900 = O(N)
|
||||
# - Cas: delete 100 from 900 = O(N)
|
||||
# - Cas: reorder = O(N²) avec LCS
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Plan mis à jour avec contre-propositions utilisateur*
|
||||
*Date: 2026-04-08*
|
||||
651
Blackboard/Done/Frontend_Review.md
Normal file
651
Blackboard/Done/Frontend_Review.md
Normal file
@@ -0,0 +1,651 @@
|
||||
** 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) **
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
Le frontend de PMOMusic est une application Vue 3 + TypeScript avec Pinia, organisée autour
|
||||
de composables réactifs, d'un client SSE centralisé et d'un cache API à plusieurs niveaux.
|
||||
L'architecture générale est solide : séparation claire composables/services/vues, typage strict
|
||||
(`strict: true` dans `tsconfig.app.json`), reconnexion SSE avec backoff exponentiel.
|
||||
|
||||
Cette revue documente les bugs avérés, les fragilités de conception et les axes d'amélioration
|
||||
relevés lors d'une lecture complète des fichiers `pmoapp/webapp/src/`.
|
||||
|
||||
---
|
||||
|
||||
## Bugs
|
||||
|
||||
### 1. `apiCache.ts:130-133` — Mutation globale du TTL non réentrante
|
||||
|
||||
**Problème** : la méthode `fetch()` accepte un `ttl` optionnel par appel. Pour l'appliquer,
|
||||
elle modifie `this.options.ttl` globalement avant d'appeler `this.set()`, puis le restaure :
|
||||
|
||||
```typescript
|
||||
// apiCache.ts:130-133
|
||||
if (ttl) {
|
||||
const originalTtl = this.options.ttl;
|
||||
this.options.ttl = ttl; // (A) modification globale
|
||||
this.set(endpoint, data, params);
|
||||
this.options.ttl = originalTtl; // (B) restauration
|
||||
}
|
||||
```
|
||||
|
||||
**Cause** : `fetcher()` est `await`-é (ligne 128) avant ce bloc. Pendant cet await, d'autres
|
||||
microtasks peuvent s'intercaler et appeler `isFresh()` ou `set()`, qui lisent `this.options.ttl`.
|
||||
Si deux appels `fetch()` avec des `ttl` différents sont en vol simultanément, la restauration
|
||||
de (B) peut effacer la valeur posée par le second appel concurrent, ou (A) peut lire un TTL
|
||||
modifié par un autre appel.
|
||||
|
||||
**Solution** : passer le `ttl` directement à `set()` comme paramètre, sans modifier l'état
|
||||
partagé :
|
||||
|
||||
```typescript
|
||||
// Dans set() : ajouter un paramètre ttl optionnel
|
||||
set<T>(endpoint: string, data: T, params?: ..., etag?: string, ttl?: number): void {
|
||||
const key = this.makeKey(endpoint, params);
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
ttl: ttl ?? this.options.ttl, // TTL par entrée, pas global
|
||||
etag,
|
||||
});
|
||||
this.notifySubscribers(key, data);
|
||||
}
|
||||
|
||||
// Dans isFresh() : lire le ttl de l'entrée
|
||||
private isFresh(key: string): boolean {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) return false;
|
||||
return Date.now() - entry.timestamp < (entry.ttl ?? this.options.ttl);
|
||||
}
|
||||
|
||||
// Dans fetch() : supprimer le bloc de mutation globale
|
||||
this.set(endpoint, data, params, undefined, ttl);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. `useRenderers.ts:151 + 222` — Réassignation post-switch écrase le nouvel objet
|
||||
|
||||
**Problème** : le handler `onRendererEvent` termine par une ligne inconditionnelle :
|
||||
|
||||
```typescript
|
||||
// useRenderers.ts:222
|
||||
snapshotState.snapshots.set(rendererId, snapshot);
|
||||
```
|
||||
|
||||
Cette ligne s'exécute pour **tous** les types d'événements après le `switch`, y compris pour
|
||||
`position_changed` et `metadata_changed` qui ont déjà créé et stocké un nouvel objet dans le
|
||||
Map à l'intérieur du switch :
|
||||
|
||||
```typescript
|
||||
// position_changed — ligne 151 : stocke newSnapshot
|
||||
snapshotState.snapshots.set(rendererId, newSnapshot);
|
||||
break;
|
||||
// → puis ligne 222 écrase avec snapshot (proxy d'origine)
|
||||
|
||||
// metadata_changed — ligne 176 : stocke un spread
|
||||
snapshotState.snapshots.set(rendererId, { ...snapshot, state: { ...snapshot.state } });
|
||||
break;
|
||||
// → puis ligne 222 écrase avec snapshot (proxy d'origine)
|
||||
```
|
||||
|
||||
**Cause** : `break` sort du `switch` mais pas de la fonction. La ligne 222 est atteinte dans
|
||||
tous les cas.
|
||||
|
||||
**Conséquence** : les objets créés pour forcer la détection de changement par Vue sont
|
||||
immédiatement écrasés. Le mécanisme de réactivité fonctionne malgré tout (le proxy muté est
|
||||
re-stocké), mais la logique est trompeuse et fragile : si Vue venait à optimiser la détection
|
||||
d'identité des objets réactifs, cette redondance deviendrait un bug visible.
|
||||
|
||||
**Solution** : supprimer la ligne 222 et s'assurer que chaque branche du switch stocke
|
||||
explicitement son résultat dans la Map. Les branches `volume_changed`, `mute_changed` et
|
||||
`binding_changed` qui mutent directement `snapshot` doivent aussi créer un nouvel objet :
|
||||
|
||||
```typescript
|
||||
case "volume_changed":
|
||||
snapshotState.snapshots.set(rendererId, {
|
||||
...snapshot,
|
||||
state: { ...snapshot.state, volume: event.volume },
|
||||
});
|
||||
break;
|
||||
|
||||
case "mute_changed":
|
||||
snapshotState.snapshots.set(rendererId, {
|
||||
...snapshot,
|
||||
state: { ...snapshot.state, mute: event.mute },
|
||||
});
|
||||
break;
|
||||
// idem pour binding_changed, stream_state_changed
|
||||
```
|
||||
|
||||
Supprimer la ligne 222. Chaque case devient responsable de son stockage, ce qui élimine aussi
|
||||
le besoin de `toRaw()`.
|
||||
|
||||
---
|
||||
|
||||
### 3. `useRenderers.ts:122` — `as any` sur `transport_state`
|
||||
|
||||
**Problème** :
|
||||
|
||||
```typescript
|
||||
// useRenderers.ts:122
|
||||
snapshot.state.transport_state = event.state as any;
|
||||
```
|
||||
|
||||
**Cause** : `event.state` est typé `string` (type SSE générique), alors que
|
||||
`transport_state` est une union littérale (`"PLAYING" | "PAUSED" | "STOPPED" | ...`).
|
||||
Le cast `as any` contourne la vérification de type.
|
||||
|
||||
**Conséquence** : si le backend envoie une valeur non prévue (ex. `"TRANSITIONING"`), elle
|
||||
sera stockée sans validation. Les composants qui comparent `transport_state === "PLAYING"`
|
||||
ne matcheront pas et l'UI restera muette.
|
||||
|
||||
**Solution** : définir un guard de type ou une assertion dans `types.ts` :
|
||||
|
||||
```typescript
|
||||
// services/pmocontrol/types.ts
|
||||
export type TransportState = "PLAYING" | "PAUSED" | "STOPPED" | "NO_MEDIA" | "TRANSITIONING";
|
||||
|
||||
export function isTransportState(s: string): s is TransportState {
|
||||
return ["PLAYING", "PAUSED", "STOPPED", "NO_MEDIA", "TRANSITIONING"].includes(s);
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// useRenderers.ts — case state_changed
|
||||
case "state_changed":
|
||||
if (isTransportState(event.state)) {
|
||||
snapshot.state.transport_state = event.state;
|
||||
} else {
|
||||
console.warn(`[useRenderers] transport_state inconnu: ${event.state}`);
|
||||
}
|
||||
break;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. `apiCache.ts:181-200` — `invalidate()` supprime silencieusement les subscriptions actives
|
||||
|
||||
**Problème** :
|
||||
|
||||
```typescript
|
||||
// apiCache.ts:197-200
|
||||
keysToDelete.forEach(key => {
|
||||
this.cache.delete(key);
|
||||
this.subscriptions.delete(key); // ← subscriptions perdues sans notification
|
||||
});
|
||||
```
|
||||
|
||||
**Cause** : lors d'une invalidation (ex. après un SSE event), les callbacks enregistrés via
|
||||
`subscribe()` sont supprimés de la Map. Les composants ne sont pas notifiés de la suppression
|
||||
et ne reçoivent plus les futures mises à jour même après un refetch.
|
||||
|
||||
**Conséquence** : un composant qui a appelé `apiCache.subscribe(...)` et qui survit à une
|
||||
invalidation devient « sourd » sans le savoir.
|
||||
|
||||
**Solution** : conserver les subscriptions lors d'une invalidation — seule la donnée en cache
|
||||
est périmée, pas les abonnés :
|
||||
|
||||
```typescript
|
||||
invalidate(pattern: string): void {
|
||||
const keysToDelete: string[] = [];
|
||||
// ... construction de keysToDelete inchangée ...
|
||||
keysToDelete.forEach(key => {
|
||||
this.cache.delete(key);
|
||||
// NE PAS supprimer this.subscriptions.get(key)
|
||||
// Les abonnés seront notifiés lors du prochain set()
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Si l'on veut notifier les abonnés d'une invalidation (pour qu'ils affichent un état de
|
||||
chargement), ajouter un callback optionnel `onInvalidate` dans l'interface de subscription.
|
||||
|
||||
---
|
||||
|
||||
## Fragilités de conception
|
||||
|
||||
### 5. `useRenderers.ts:7,143-151` — `toRaw()` comme contournement de réactivité Vue
|
||||
|
||||
**Problème** : le code utilise `toRaw()` pour extraire l'objet brut d'un proxy Vue avant de
|
||||
faire un spread, afin que la copie ne contienne pas de getters réactifs qui pointent vers
|
||||
l'objet original :
|
||||
|
||||
```typescript
|
||||
// useRenderers.ts:143-151
|
||||
const rawState = toRaw(snapshot.state);
|
||||
const newState = { ...rawState };
|
||||
const newSnapshot = { ...snapshot, state: newState };
|
||||
snapshotState.snapshots.set(rendererId, newSnapshot);
|
||||
```
|
||||
|
||||
**Cause** : les Maps imbriquées dans un objet `reactive()` ont un comportement de réactivité
|
||||
peu prévisible dans Vue 3. Vue ne détecte pas les mutations d'éléments d'une Map réactive si
|
||||
la référence de la Map elle-même ne change pas.
|
||||
|
||||
**Solution recommandée** : remplacer `reactive(new Map())` par `shallowRef(new Map())` pour
|
||||
les Maps qui contiennent des données complexes. La réactivité se déclenche en remplaçant la
|
||||
Map entière (ou en forçant un `triggerRef`) :
|
||||
|
||||
```typescript
|
||||
// Au lieu de :
|
||||
const snapshotState = reactive<RendererSnapshotState>({ snapshots: reactive(new Map()), ... });
|
||||
|
||||
// Utiliser :
|
||||
const snapshots = shallowRef(new Map<string, FullRendererSnapshot>());
|
||||
|
||||
// Pour déclencher la réactivité après mutation :
|
||||
snapshots.value = new Map(snapshots.value); // ou triggerRef(snapshots)
|
||||
```
|
||||
|
||||
Cela rend la propagation de réactivité explicite et élimine le besoin de `toRaw()`.
|
||||
|
||||
---
|
||||
|
||||
### 6. `useRenderers.ts:552-570` — Timer de debounce non nettoyé dans `useRenderer()`
|
||||
|
||||
**Problème** : `useRenderer()` crée un timer de debounce local qui n'est jamais nettoyé si
|
||||
le composant parent est démonté :
|
||||
|
||||
```typescript
|
||||
// useRenderers.ts:553-566
|
||||
let refreshDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const REFRESH_DEBOUNCE_MS = 500;
|
||||
|
||||
async function refresh(force = true) {
|
||||
if (refreshDebounceTimer !== null) return;
|
||||
refreshDebounceTimer = setTimeout(() => {
|
||||
refreshDebounceTimer = null;
|
||||
}, REFRESH_DEBOUNCE_MS);
|
||||
await Promise.all([...]);
|
||||
}
|
||||
```
|
||||
|
||||
**Cause** : pas d'appel à `clearTimeout` dans un `onUnmounted`. Si le composant est démonté
|
||||
pendant les 500 ms du debounce, le timer continue de s'exécuter.
|
||||
|
||||
**Conséquence** : fuite mémoire potentielle ; dans des cas extrêmes (navigation rapide), le
|
||||
callback peut tenter de déclencher un fetch sur un composant déjà démonté.
|
||||
|
||||
**Solution** :
|
||||
|
||||
```typescript
|
||||
import { onUnmounted } from 'vue';
|
||||
|
||||
// Dans useRenderer() :
|
||||
onUnmounted(() => {
|
||||
if (refreshDebounceTimer !== null) {
|
||||
clearTimeout(refreshDebounceTimer);
|
||||
refreshDebounceTimer = null;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. `useRenderers.ts:45-46` — Singleton SSE initialisé par flag de module non réinitialisable
|
||||
|
||||
**Problème** :
|
||||
|
||||
```typescript
|
||||
// useRenderers.ts:45-46
|
||||
let sseInitialized = false;
|
||||
function ensureSSEInitialized() {
|
||||
if (sseInitialized) return;
|
||||
// ...
|
||||
sseInitialized = true;
|
||||
}
|
||||
```
|
||||
|
||||
**Cause** : ce flag de module est persistant pour toute la durée de vie de la page. Si la
|
||||
connexion SSE est perdue puis rétablie avec un nouvel objet `PMOControlSSE`, le handler
|
||||
`onRendererEvent` précédent peut ne plus être actif, mais `sseInitialized` empêche sa
|
||||
re-enregistration.
|
||||
|
||||
**Conséquence** : après une déconnexion et reconnexion SSE, les événements renderer peuvent
|
||||
ne plus être reçus par `useRenderers` jusqu'à un rechargement de page.
|
||||
|
||||
**Solution** : exposer une fonction `resetSSE()` qui remet `sseInitialized = false` et la
|
||||
connecter à l'événement de reconnexion du service SSE. Alternativement, utiliser le pattern
|
||||
`provide/inject` ou un store Pinia pour gérer le cycle de vie SSE explicitement, en lieu et
|
||||
place du flag de module.
|
||||
|
||||
---
|
||||
|
||||
## Qualité du code
|
||||
|
||||
### 8. `useTabs.ts` — Deep watch déclenchant une sérialisation localStorage à chaque mutation
|
||||
|
||||
**Problème** : le watch qui persiste l'état des onglets utilise `{ deep: true }` sur un
|
||||
tableau qui peut contenir jusqu'à 12 entrées avec des métadonnées :
|
||||
|
||||
```typescript
|
||||
// useTabs.ts:349-355
|
||||
watch(
|
||||
() => [state.tabs, state.activeTabId, state.tabHistory],
|
||||
() => { saveToLocalStorage(); },
|
||||
{ deep: true },
|
||||
);
|
||||
```
|
||||
|
||||
**Cause** : `{ deep: true }` traverse récursivement toutes les propriétés observées.
|
||||
`saveToLocalStorage()` appelle `JSON.stringify` sur l'ensemble des tabs à chaque mutation,
|
||||
même mineure (ex. changement de `activeTabId`).
|
||||
|
||||
**Solution** : surveiller les propriétés individuellement et sérialiser uniquement ce qui
|
||||
change, ou utiliser un computed pour construire la clé de changement :
|
||||
|
||||
```typescript
|
||||
// Watch séparés, sans deep
|
||||
watch(() => state.activeTabId, saveToLocalStorage);
|
||||
watch(() => state.tabHistory.length, saveToLocalStorage);
|
||||
watch(
|
||||
() => state.tabs.map(t => t.id + t.type + (t.metadata?.rendererId ?? '')).join('|'),
|
||||
saveToLocalStorage,
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 9. `UnifiedControlView.vue:40-49` — Swipe : `clientX` final au lieu de la position initiale
|
||||
|
||||
**Problème** :
|
||||
|
||||
```typescript
|
||||
// UnifiedControlView.vue:40-49
|
||||
useSwipe(viewRef, {
|
||||
threshold: 50,
|
||||
onSwipeEnd(_e: TouchEvent, swipeDirection: string) {
|
||||
if (swipeDirection === "right" && !drawerOpen.value) {
|
||||
const touch = _e.changedTouches[0];
|
||||
if (touch && touch.clientX < 50) { // ← position finale du doigt
|
||||
drawerOpen.value = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Cause** : `onSwipeEnd` reçoit l'événement `touchend`. Dans `changedTouches`, `clientX`
|
||||
est la position **finale** du doigt (après le swipe), pas la position initiale. Un swipe
|
||||
commençant à `x=30` et terminant à `x=150` a `clientX=150` dans `touchend` — la condition
|
||||
`< 50` ne sera jamais vraie pour un swipe horizontal significatif.
|
||||
|
||||
**Conséquence** : le geste de swipe depuis le bord gauche ne fonctionne probablement pas
|
||||
sur les appareils tactiles.
|
||||
|
||||
**Solution** : capturer la position initiale dans `onSwipeStart` :
|
||||
|
||||
```typescript
|
||||
const swipeStartX = ref(0);
|
||||
|
||||
useSwipe(viewRef, {
|
||||
threshold: 50,
|
||||
onSwipeStart(e: TouchEvent) {
|
||||
swipeStartX.value = e.touches[0]?.clientX ?? 0;
|
||||
},
|
||||
onSwipeEnd(_e: TouchEvent, swipeDirection: string) {
|
||||
if (swipeDirection === "right" && !drawerOpen.value && swipeStartX.value < 50) {
|
||||
drawerOpen.value = true;
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 10. `api.ts:50` — Réponse JSON non validée avant le cast TypeScript
|
||||
|
||||
**Problème** :
|
||||
|
||||
```typescript
|
||||
// api.ts:50
|
||||
return response.json(); // retour typé T par inférence, sans validation
|
||||
```
|
||||
|
||||
**Cause** : `response.json()` retourne `Promise<any>`. TypeScript accepte le retour car la
|
||||
méthode `request<T>` promet `Promise<T>`, mais aucune validation de structure n'est effectuée.
|
||||
|
||||
**Conséquence** : si le backend renvoie un schéma légèrement différent (champ renommé, type
|
||||
changé), le bug se manifestera loin du point d'appel avec un message cryptique. En
|
||||
développement avec plusieurs instances en parallèle (`udn_prefix` différent), une requête
|
||||
dirigée vers la mauvaise instance peut retourner un format inattendu.
|
||||
|
||||
**Solution pragmatique** : ajouter une validation légère avec un type guard pour les réponses
|
||||
critiques, ou au minimum loguer la réponse brute en mode développement :
|
||||
|
||||
```typescript
|
||||
private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
// ...
|
||||
const data = await response.json();
|
||||
if (import.meta.env.DEV && data == null) {
|
||||
console.warn(`[PMOControlAPI] Réponse vide pour ${path}`);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
```
|
||||
|
||||
Pour les endpoints critiques (`getRendererFullSnapshot`, `getRenderers`), envisager un
|
||||
schéma de validation Zod ou une assertion runtime minimale.
|
||||
|
||||
---
|
||||
|
||||
## Accessibilité et feedback utilisateur
|
||||
|
||||
### 11. Absence de labels ARIA sur les contrôles transport
|
||||
|
||||
Les composants `TransportControls.vue` et `VolumeControl.vue` contiennent des boutons
|
||||
iconiques (play, pause, stop, volume) sans attributs `aria-label`. Les lecteurs d'écran
|
||||
ne peuvent pas identifier la fonction de ces contrôles.
|
||||
|
||||
**Correction minimale** :
|
||||
|
||||
```html
|
||||
<!-- TransportControls.vue -->
|
||||
<button @click="play" aria-label="Lecture">
|
||||
<PlayIcon />
|
||||
</button>
|
||||
<button @click="pause" aria-label="Pause">
|
||||
<PauseIcon />
|
||||
</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 12. Erreurs réseau silencieuses sans feedback utilisateur
|
||||
|
||||
Plusieurs appels critiques sont lancés en fire-and-forget sans propagation vers l'UI :
|
||||
|
||||
```typescript
|
||||
// useRenderers.ts:86
|
||||
void fetchRenderers(true); // erreur loggée en console uniquement
|
||||
|
||||
// useRenderers.ts:89
|
||||
void fetchRendererSnapshot(rendererId, { force: true }); // idem
|
||||
```
|
||||
|
||||
Le store `ui.ts` dispose d'un système de notifications toast (`addNotification`). Les erreurs
|
||||
de réseau devraient y être propagées pour informer l'utilisateur :
|
||||
|
||||
```typescript
|
||||
import { useUIStore } from '@/stores/ui';
|
||||
|
||||
const uiStore = useUIStore();
|
||||
|
||||
// Dans le handler SSE :
|
||||
try {
|
||||
await fetchRenderers(true);
|
||||
} catch {
|
||||
uiStore.addNotification({
|
||||
message: 'Impossible de rafraîchir la liste des renderers',
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Points forts à conserver
|
||||
|
||||
Ces patterns sont bien conçus et ne doivent pas être modifiés dans les corrections ci-dessus :
|
||||
|
||||
- **Déduplication des requêtes en vol** (`apiCache.ts:100-112`) : évite les appels réseau
|
||||
redondants quand plusieurs composants demandent la même ressource simultanément.
|
||||
- **Watch sélectif sur les IDs** (`UnifiedControlView.vue:139-150`) : calcule une clé
|
||||
synthétique `ids.join(',')` au lieu d'un deep watch sur le tableau de renderers.
|
||||
- **Backoff exponentiel SSE** (`sse.ts`) : reconnexion progressive 1s→2s→4s→8s→16s→30s
|
||||
avec cap. Implémentation robuste.
|
||||
- **Fetch batch contrôlé** (`useRenderers.ts:351-383`) : `fetchBatchSnapshots()` avec
|
||||
concurrence limitée (défaut 3) et délai inter-batches. Évite de saturer le réseau au
|
||||
démarrage.
|
||||
- **`filterRenderers` par UDN** (`UnifiedControlView.vue:107-120`) : filtre les WebRenderers
|
||||
étrangers en comparant le UDN normalisé. Logique correcte avec gestion du préfixe `uuid:`.
|
||||
|
||||
---
|
||||
|
||||
## État d'avancement — corrections appliquées (commit 2026-04-06)
|
||||
|
||||
Les 12 points de la revue ont été traités. Le tableau ci-dessous récapitule ce qui a été
|
||||
fait et ce qui reste à finir.
|
||||
|
||||
| # | Problème | État |
|
||||
|---|----------|------|
|
||||
| 1 | `apiCache` — TTL mutation globale | ✅ Corrigé (`ttl` par entrée dans `CacheEntry`, `isFresh()` lit `entry.ttl`) |
|
||||
| 2 | `useRenderers` — double réassignation post-switch | ✅ Corrigé (chaque `case` responsable, ligne 222 supprimée) |
|
||||
| 3 | `useRenderers` — `as any` sur `transport_state` | ✅ Corrigé (`isTransportState` guard dans `types.ts`) |
|
||||
| 4 | `apiCache` — `invalidate()` détruisait les subscriptions | ✅ Corrigé (`this.subscriptions.delete` supprimé) |
|
||||
| 5 | `useRenderers` — `toRaw()` / `reactive(Map)` fragile | ✅ Migré vers `shallowRef` + helpers `triggerSnapshotReactivity()` / `triggerLoadingReactivity()` |
|
||||
| 6 | `useRenderer()` — timer debounce non nettoyé | ✅ Corrigé (`onUnmounted` + `clearTimeout`) |
|
||||
| 7 | Singleton SSE non réinitialisable | ✅ Corrigé (`resetSSE()` exposé dans le retour de `useRenderers()`) |
|
||||
| 8 | `useTabs` — deep watch coûteux | ✅ Corrigé (watches séparés sans `deep: true`) |
|
||||
| 9 | Swipe — `clientX` final au lieu d'initial | ✅ Corrigé (`swipeStartX` capturé dans `onSwipeStart`) |
|
||||
| 10 | `api.ts` — JSON non validé | ✅ Corrigé (log dev-mode pour réponse nulle) |
|
||||
| 11 | Absence de labels ARIA | ✅ Corrigé (4 boutons transport + bouton mute + slider volume) |
|
||||
| 12 | Erreurs réseau silencieuses | ✅ Corrigé (`uiStore.notifyError()` dans `fetchRenderers` et `fetchRendererSnapshot`) |
|
||||
|
||||
---
|
||||
|
||||
## Tâches restantes — ✅ Toutes corrigées (commit 2026-04-06)
|
||||
|
||||
| # | Problème résiduel | État |
|
||||
|---|-------------------|------|
|
||||
| A | `state_changed` — mutation directe sans trigger réactivité | ✅ Corrigé (spread + `triggerSnapshotReactivity()`) |
|
||||
| B | `queue_refreshing`/`queue_updated` — `queueRefreshingIds` sans trigger | ✅ Corrigé (`triggerQueueReactivity()` ajoutée et appelée) |
|
||||
| C | `toRaw` import obsolète, `position_changed` simplifié | ✅ Corrigé (`toRaw` supprimé de l'import, spread direct) |
|
||||
|
||||
---
|
||||
|
||||
### A. `useRenderers.ts` — `state_changed` : mutation directe sans déclenchement de réactivité
|
||||
|
||||
**Problème** : avec la migration vers `shallowRef`, les objets dans la Map ne sont plus des
|
||||
proxies Vue. La mutation directe de `snapshot.state.transport_state` ne déclenche aucune
|
||||
réactivité — les composants ne se mettront pas à jour quand l'état de transport change :
|
||||
|
||||
```typescript
|
||||
// useRenderers.ts — case state_changed (code actuel)
|
||||
case "state_changed":
|
||||
if (isTransportState(event.state)) {
|
||||
snapshot.state.transport_state = event.state; // ← mutation directe, pas de trigger
|
||||
}
|
||||
break;
|
||||
```
|
||||
|
||||
**Solution** : créer un nouvel objet, comme pour `volume_changed` et `mute_changed` :
|
||||
|
||||
```typescript
|
||||
case "state_changed":
|
||||
if (isTransportState(event.state)) {
|
||||
snapshots.value.set(rendererId, {
|
||||
...snapshot,
|
||||
state: { ...snapshot.state, transport_state: event.state },
|
||||
});
|
||||
} else {
|
||||
console.warn(`[useRenderers] transport_state inconnu: ${event.state}`);
|
||||
}
|
||||
break;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### B. `useRenderers.ts` — `queue_refreshing` / `queue_updated` : mutations de `queueRefreshingIds` sans trigger
|
||||
|
||||
**Problème** : `queueRefreshingIds` est un `shallowRef<Set>`. Les appels `.add()` et
|
||||
`.delete()` sur `.value` ne déclenchent pas la réactivité de `shallowRef` :
|
||||
|
||||
```typescript
|
||||
// useRenderers.ts — case queue_refreshing (code actuel)
|
||||
case "queue_refreshing":
|
||||
queueRefreshingIds.value.add(rendererId); // ← pas de trigger
|
||||
break;
|
||||
|
||||
case "queue_updated":
|
||||
queueRefreshingIds.value.delete(rendererId); // ← pas de trigger
|
||||
break;
|
||||
```
|
||||
|
||||
Le composable `isQueueRefreshing(id)` retourne `queueRefreshingIds.value.has(id)`. Sans
|
||||
trigger, les templates qui dépendent de cette valeur ne se recalculeront pas.
|
||||
|
||||
**Solution** : ajouter une fonction `triggerQueueReactivity()` analogue à
|
||||
`triggerLoadingReactivity()` et l'appeler après chaque mutation :
|
||||
|
||||
```typescript
|
||||
function triggerQueueReactivity() {
|
||||
queueRefreshingIds.value = new Set(queueRefreshingIds.value);
|
||||
}
|
||||
|
||||
// Dans le switch :
|
||||
case "queue_refreshing":
|
||||
queueRefreshingIds.value.add(rendererId);
|
||||
triggerQueueReactivity();
|
||||
break;
|
||||
|
||||
case "queue_updated":
|
||||
snapshot.state.queue_len = event.queue_length;
|
||||
queueRefreshingIds.value.delete(rendererId);
|
||||
triggerQueueReactivity();
|
||||
void fetchRendererSnapshot(rendererId, { force: true });
|
||||
break;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### C. `useRenderers.ts` — import `toRaw` et commentaire obsolètes
|
||||
|
||||
**Problème** : avec `shallowRef`, les objets stockés dans `snapshots.value` sont de simples
|
||||
objets JavaScript (jamais des proxies Vue). L'appel `toRaw(snapshot.state)` dans
|
||||
`position_changed` est devenu un no-op, et le commentaire qui le justifie est trompeur :
|
||||
|
||||
```typescript
|
||||
// useRenderers.ts:160-163 (commentaire et import obsolètes)
|
||||
// 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 };
|
||||
```
|
||||
|
||||
**Solution** : supprimer le `toRaw()`, simplifier en spread direct, retirer `toRaw` de
|
||||
l'import ligne 7 :
|
||||
|
||||
```typescript
|
||||
// Remplacer :
|
||||
import { ref, shallowRef, computed, toRaw, type Ref, onUnmounted } from "vue";
|
||||
|
||||
// Par :
|
||||
import { ref, shallowRef, computed, type Ref, onUnmounted } from "vue";
|
||||
|
||||
// Dans position_changed :
|
||||
const newSnapshot = {
|
||||
...snapshot,
|
||||
state: {
|
||||
...snapshot.state,
|
||||
position_ms: positionMs ?? 0,
|
||||
duration_ms: durationMs,
|
||||
},
|
||||
};
|
||||
snapshots.value.set(rendererId, newSnapshot);
|
||||
```
|
||||
|
||||
Cette simplification rend aussi le cas `position_changed` cohérent avec les autres cases
|
||||
(`volume_changed`, `mute_changed`, etc.) qui construisent directement l'objet final sans
|
||||
passer par une variable intermédiaire.
|
||||
19
Blackboard/Report/enorme_playlist.md
Normal file
19
Blackboard/Report/enorme_playlist.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Rapport : Optimisation performance OpenHome playlist
|
||||
|
||||
## Résumé
|
||||
Les optimizations implementées réduisent significativement le temps de synchronisation des playlists OpenHome de ~1000 titres. Les principales améliorations : passage du batch ReadList de 64 à 256 (−75% appels SOAP), elimination des doubles appels queue_snapshot() (−50% appels SOAP), et introduction du polling adaptatif avec intervalle long en veille (5s vs 500ms).
|
||||
|
||||
## Fichiers modifies
|
||||
|
||||
1. `pmocontrol/src/queue/openhome.rs`
|
||||
- Batch ReadList augmente de 64 a 256
|
||||
- Signature de replace_queue_with_pivot et replace_queue_standard_lcs modifiee pour accepter snapshot et current_track_ids
|
||||
- Appel a sync_queue mis a jour pour passer les donnees deja disponibles
|
||||
-Nouvelle fonction lcs_flags_optimized avec elimination pre/suffixe communs
|
||||
|
||||
2. `pmocontrol/src/music_renderer/watcher.rs`
|
||||
- Ajout du champ is_active dans WatchedState pour le polling adaptatif
|
||||
|
||||
3. `pmocontrol/src/music_renderer/musicrenderer.rs`
|
||||
- Boucle watcher avec intervalle adaptatif (500ms actif, 5000ms veille)
|
||||
- Marqueurs is_active=true dans play(), stop(), seek_rel_time(), sync_queue()
|
||||
@@ -1,138 +1,443 @@
|
||||
Parfait. Voici un **schéma fonctionnel minimal** pour un **MediaRenderer UPnP privé par navigateur** avec **token**. L’idée est de rester fidèle à ton backend Rust existant et à la webapp Vue.js. En s'appuyant sur l'architecture de PMOMusic, j'aimerais que tu proposes un plan détaillé pour implémenter un tel système de Média Renderer.
|
||||
# Web Media Renderer - Architecture
|
||||
|
||||
- L'application web se trouve dans: [@webapp](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmoapp/webapp)
|
||||
- Tu as un prototype de Média Renderer dans: [@pmomediarenderer](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmomediarenderer)
|
||||
- Le contrôle point est dans : [@pmocontrol](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmocontrol)
|
||||
- Tu implémenteras ce nouveau système de Média Renderer dans la CRATe pmowebrenderer
|
||||
## Vision
|
||||
|
||||
Tu mettras une version du plan en Markdown dans le répertoire [@Architecture](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Architecture) .
|
||||
Système de Media Renderer pilotable à distance via UPnP, exposant un flux audio vers différents types de lecteurs physiques.
|
||||
|
||||
---
|
||||
## Architecture globale en 4 parties
|
||||
|
||||
## 1. Flow général
|
||||
|
||||
```
|
||||
Browser (Vue.js Control Point)
|
||||
┌───────────────┐
|
||||
│ UI / audio │
|
||||
│ WebSocket │
|
||||
└───────▲───────┘
|
||||
│ token
|
||||
│
|
||||
▼
|
||||
Rust backend (UPnP MediaRenderer)
|
||||
┌───────────────────────────┐
|
||||
│ Token → Renderer mapping │
|
||||
│ Device XML / SOAP endpoints│
|
||||
│ Play/Pause/Stop → WS → Browser │
|
||||
└───────────────────────────┘
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Media Server] -->|flux audio| B[Control Point]
|
||||
B -->|commandes| C[Web Media Renderer]
|
||||
C -->|flux + contrôles| D[Device physique]
|
||||
|
||||
subgraph Devices physiques
|
||||
D1[Browser]
|
||||
D2[Android Auto]
|
||||
D3[Apple CarPlay]
|
||||
D4[Sonos multipoint]
|
||||
D5[Chromecast]
|
||||
end
|
||||
|
||||
D --> D1
|
||||
D --> D2
|
||||
D --> D3
|
||||
D --> D4
|
||||
D --> D5
|
||||
```
|
||||
|
||||
---
|
||||
### Rôles
|
||||
|
||||
## 2. Étapes détaillées
|
||||
1. **Media Server** - Source audio (le flux OGG-FLAC existant)
|
||||
2. **Control Point** - Interface UI qui envoie les commandes (pause, play, seek, next, prev)
|
||||
3. **Web Media Renderer** - Hub qui expose le flux et traduit les commandes selon le device
|
||||
4. **Physical Device** - Lecteur final (browser, voiture, Sonos, Chromecast...)
|
||||
|
||||
### a) Création du renderer
|
||||
## Web Media Renderer - Rôle central
|
||||
|
||||
1. Le navigateur se connecte via WebSocket ou HTTP.
|
||||
2. Rust génère un token unique pour ce client :
|
||||
|
||||
```rust
|
||||
use uuid::Uuid;
|
||||
let token = Uuid::new_v4().to_string();
|
||||
```
|
||||
3. Rust crée une instance MediaRenderer **privée**, associée à ce token :
|
||||
|
||||
* Device description XML : `/renderer/<token>/desc.xml`
|
||||
* AVTransport SOAP : `/renderer/<token>/avtransport`
|
||||
* RenderingControl SOAP : `/renderer/<token>/renderingcontrol`
|
||||
|
||||
---
|
||||
|
||||
### b) Control Point
|
||||
|
||||
* La webapp Vue.js reçoit le token et la “déclare” au Control Point :
|
||||
|
||||
```js
|
||||
const renderer = {
|
||||
token: "abcd-1234-efgh",
|
||||
name: "Browser Renderer"
|
||||
};
|
||||
|
||||
// Ajout au control point local
|
||||
controlPoint.addRenderer(renderer);
|
||||
```
|
||||
|
||||
* Toutes les commandes Play/Pause/Stop incluent ce token :
|
||||
|
||||
```js
|
||||
ws.send(JSON.stringify({
|
||||
token: renderer.token,
|
||||
action: "play",
|
||||
uri: "http://localhost:8080/media.mp3"
|
||||
}));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### c) Backend Rust : dispatcher les commandes
|
||||
|
||||
* Rust reçoit le JSON avec le token.
|
||||
* Vérifie que le token correspond à un renderer actif.
|
||||
* Transmet la commande au navigateur via WebSocket (ou HTTP push) :
|
||||
|
||||
```rust
|
||||
match msg.action.as_str() {
|
||||
"play" => send_ws_to_browser(&token, format!("play:{}", msg.uri)),
|
||||
"pause" => send_ws_to_browser(&token, "pause".to_string()),
|
||||
"stop" => send_ws_to_browser(&token, "stop".to_string()),
|
||||
_ => (),
|
||||
```mermaid
|
||||
blockdiag
|
||||
{
|
||||
block = Commandes UPnP
|
||||
block -> "Web Media Renderer" -> Adaptation selon device
|
||||
"Web Media Renderer" -> Device-specific protocols
|
||||
}
|
||||
```
|
||||
|
||||
* Rust met à jour l’état du renderer (AVTransport/RenderingControl) pour le Control Point.
|
||||
### Rôle central: Adaptateur
|
||||
|
||||
---
|
||||
Le Web Media Renderer est un **adaptateur** qui:
|
||||
- **Reçoit le flux** du Media Server (OGG-FLAC)
|
||||
- **Reçoit les commandes** du Control Point (UPnP)
|
||||
- **Les traduit** vers les devices physiques
|
||||
- **Expose une API de contrôle** commune
|
||||
|
||||
### d) Lecture côté navigateur
|
||||
### Ce qui est COMMUN (factorisé)
|
||||
|
||||
* Le navigateur reçoit la commande via WebSocket et pilote `<audio>` :
|
||||
| Layer | Description |
|
||||
|-------|-------------|
|
||||
| **API contrôle** | pause, resume, seek, next, prev, flush, stop |
|
||||
| **Métadonnées** | /nowplaying, /metadata, /state |
|
||||
| **Flux audio** | OGG-FLAC (identique pour tous) |
|
||||
| **StreamType** | Continuous vs Finite |
|
||||
|
||||
```js
|
||||
ws.onmessage = (evt) => {
|
||||
const msg = evt.data;
|
||||
if(msg.startsWith("play:")) {
|
||||
audio.src = msg.split(":")[1];
|
||||
audio.play();
|
||||
} else if(msg === "pause") {
|
||||
audio.pause();
|
||||
} else if(msg === "stop") {
|
||||
audio.pause();
|
||||
audio.currentTime = 0;
|
||||
}
|
||||
};
|
||||
### Ce qui est SPÉCIFIQUE (par device)
|
||||
|
||||
| Device | Transport | Buffer Management | Sync |
|
||||
|--------|-----------|-------------------|------|
|
||||
| Browser | HTTP/WebSocket | JS flush | N/A |
|
||||
| Android Auto | AA API | native | varies |
|
||||
| CarPlay | CP API | native | varies |
|
||||
| Sonos | UPnP | none | UPnP |
|
||||
| Chromecast | Cast API | none | Cast |
|
||||
|
||||
### Problème du buffer (Browser)
|
||||
|
||||
Le browser buffer cause des delais de reaction:
|
||||
- **Pause**: delai de 5 secondes
|
||||
- **Seek**: cherche dans le buffer, pas dans le nouveau flux
|
||||
- **Next/Prev**: changement reporte
|
||||
|
||||
**Solutions**:
|
||||
1. Web Audio API - `audioContext.suspend()/resume()` - plus petit buffer (~50ms)
|
||||
2. Frontend flush buffer
|
||||
3. Chaque client manage son propre buffer, Web Media Renderer juste expose API
|
||||
|
||||
## Implémentation actuelle
|
||||
|
||||
### Faits
|
||||
|
||||
- Flux audio OGG-FLAC ✓
|
||||
- Pause/Resume ✓ (via `OggFlacStreamHandle`)
|
||||
- TrackBoundary pour OGG segments
|
||||
- StreamType (Continuous vs Finite)
|
||||
|
||||
### À faire
|
||||
|
||||
- seek/next/prev API
|
||||
- WebSocket pour temps réel
|
||||
- Metadata endpoint (/nowplaying JSON)
|
||||
- MPV integration (multi-point/multi-room)
|
||||
|
||||
## Code actuel - Pause/Resume
|
||||
|
||||
```rust
|
||||
// OggFlacStreamHandle - méthodes de contrôle
|
||||
pub fn pause(&self) {
|
||||
self.inner.is_paused.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
pub fn resume(&self) {
|
||||
self.inner.is_paused.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
pub fn is_paused(&self) -> bool {
|
||||
self.inner.is_paused.load(Ordering::SeqCst)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
### Différences Continuous vs Finite
|
||||
|
||||
### e) Fermeture / cleanup
|
||||
- **Continuous** (radio): pause -> sends silence, drops incoming chunks
|
||||
- **Finite** (tracks): pause -> don't receive chunks (backpressure), loops sending silence
|
||||
|
||||
* Quand le navigateur se déconnecte :
|
||||
## Schéma d'intégration
|
||||
|
||||
* Rust supprime le renderer associé au token
|
||||
* Émet un **byebye virtuel** pour le Control Point (si nécessaire)
|
||||
* Libère toutes les ressources
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant CP as Control Point
|
||||
participant WMR as Web Media Renderer
|
||||
participant FS as Flux Server
|
||||
participant D as Device
|
||||
|
||||
CP->>WMR: pause()
|
||||
WMR->>FS: commande pause
|
||||
FS->>FS: pause state change
|
||||
FS->>WMR: silence (continuous) / blocked (finite)
|
||||
WMR->>D: flux avec silence
|
||||
D-->>CP: audio joué (avec delay si buffer)
|
||||
```
|
||||
|
||||
---
|
||||
## Le Web Media Renderer - Adaptateur
|
||||
|
||||
## 3. Points clés
|
||||
Le rôle central du Web Media Renderer est de **convertir des ordres UPnP en actions spécifiques** selon le device cible:
|
||||
|
||||
1. **Token unique** = session privée + sécurité
|
||||
2. **Pas besoin de SSDP / annonce** : le renderer est dédié à un navigateur connu
|
||||
3. **Control Point Vue.js** sait exactement quel renderer utiliser
|
||||
4. **Rust backend** reste seul responsable de l’implémentation UPnP
|
||||
5. **Lecture réelle** = navigateur via `<audio>` ou `<video>`
|
||||
```
|
||||
UPnP orders → [Web Media Renderer] → Device-specific actions
|
||||
```
|
||||
|
||||
---
|
||||
## Browser Player - Composant web invisible
|
||||
|
||||
💡 Ce modèle est très proche de ce que font **Mopidy avec Iris**, **Kodi Remote**, ou **Chromecast / local cast** : le device est connu et dédié, pas besoin de découverte réseau.
|
||||
Pour s'entraîner, on peut se focaliser sur un composant web qui:
|
||||
- Est **complètement invisible** (pas de UI)
|
||||
- Est **télécommandable** par le Web Media Renderer
|
||||
- Joue la musique dans le navigateur
|
||||
|
||||
### Specifications
|
||||
|
||||
| Requirement | Description |
|
||||
|-------------|-------------|
|
||||
| Invisible | Pas de UI, pas de controls, pas de visuel |
|
||||
| Remote control | Reçoit commandes via WebSocket/HTTP |
|
||||
| Auto-reconnect | Reconnection si stream coupé |
|
||||
| Buffer management | Flush commandée |
|
||||
| Audio format | OGG-FLAC stream |
|
||||
|
||||
### Architecture en 2 parties
|
||||
|
||||
| Partie | Langage | Rôle |
|
||||
|--------|---------|------|
|
||||
| Backend | Rust (pmoaudio-ext) | Contrôle, flux OGG-FLAC |
|
||||
| Frontend | JavaScript | Player invisible dans le browser |
|
||||
|
||||
#### Backend (Rust)
|
||||
|
||||
- Expose le flux audio (`/stream`)
|
||||
- API contrôle (`/pause`, `/resume`, `/seek`, `/flush`, `/stop`)
|
||||
- WebSocket pour temps réel (`/ws`)
|
||||
- **Reçoit les rapports de position/state**
|
||||
|
||||
```rust
|
||||
// Endpoints existants
|
||||
POST /pause
|
||||
POST /resume
|
||||
POST /seek?t={timestamp}
|
||||
POST /flush
|
||||
POST /stop
|
||||
|
||||
// Stream
|
||||
GET /stream
|
||||
|
||||
// WebSocket messages REÇUS du player:
|
||||
{
|
||||
"type": "position",
|
||||
"position_sec": 125.5,
|
||||
"duration_sec": 240.0,
|
||||
"state": "playing"
|
||||
}
|
||||
{
|
||||
"type": "track",
|
||||
"id": "...",
|
||||
"title": "...",
|
||||
"artist": "..."
|
||||
}
|
||||
{
|
||||
"type": "ready_state",
|
||||
"ready_state": "canplay"
|
||||
}
|
||||
```
|
||||
|
||||
#### Frontend (JavaScript)
|
||||
|
||||
Composant minimal (~100 lignes):
|
||||
|
||||
```javascript
|
||||
class RemotePlayer {
|
||||
constructor(wsUrl) {
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
this.audio = new Audio();
|
||||
this.ac = new AudioContext();
|
||||
|
||||
this.ws.onmessage = (e) => this.handle(e.data);
|
||||
}
|
||||
|
||||
handle(msg) {
|
||||
switch(msg.type) {
|
||||
case 'stream': this.load(msg.url); break;
|
||||
case 'play': this.play(); break;
|
||||
case 'pause': this.pause(); break;
|
||||
case 'seek': this.seek(msg.timestamp); break;
|
||||
case 'flush': this.flush(); break;
|
||||
case 'stop': this.stop(); break;
|
||||
}
|
||||
}
|
||||
|
||||
load(url) {
|
||||
this.audio.src = url;
|
||||
}
|
||||
|
||||
play() {
|
||||
this.audio.play();
|
||||
}
|
||||
|
||||
pause() {
|
||||
this.audio.pause();
|
||||
}
|
||||
|
||||
seek(ts) {
|
||||
this.audio.currentTime = ts;
|
||||
}
|
||||
|
||||
flush() {
|
||||
// Flush buffer immediatement
|
||||
this.audio.pause();
|
||||
this.audio.currentTime = 0;
|
||||
this.audio.src = '';
|
||||
this.ac.suspend();
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.flush();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```html
|
||||
<script src="pmo-player.js"></script>
|
||||
<script>
|
||||
const player = new PMOPlayer('ws://localhost:8080/ws');
|
||||
</script>
|
||||
```
|
||||
|
||||
### Fichier à créer
|
||||
|
||||
`pmoapp/webapp/src/services/PMOPlayer.ts`
|
||||
|
||||
### Endpoints HTTP
|
||||
|
||||
| Endpoint | Methode | Description |
|
||||
|----------|---------|-------------|
|
||||
| `/api/webrenderer/register` | POST | Enregistre instance |
|
||||
| `/api/webrenderer/{id}/stream` | GET | Flux audio OGG-FLAC |
|
||||
| `/api/webrenderer/{id}/position` | POST | Rapporte position |
|
||||
| `/api/webrenderer/{id}/report` | POST | Rapporte etat player |
|
||||
| `/api/webrenderer/{id}/command` | GET | Recupere commande pending |
|
||||
| `/api/webrenderer/{id}` | DELETE | Desenregistre |
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
Player (Browser) <--HTTP--> Backend
|
||||
- Report: position/state via POST /report
|
||||
- Poll: command via GET /command (500ms)
|
||||
- Stream: GET /stream
|
||||
```
|
||||
|
||||
### Réactivité
|
||||
|
||||
Pour maximiser la réactivité:
|
||||
|
||||
| Technique | Impact |
|
||||
|-----------|--------|
|
||||
| WebSocket | Temps réel vs HTTP polling |
|
||||
| AudioContext.suspend() | Buffer ~50ms au lieu de ~5s |
|
||||
| Flush command | Vide le buffer immediatement |
|
||||
| Native HTML5 audio | Le plus simple = le plus stable |
|
||||
|
||||
### Schéma
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant WMR as Web Media Renderer
|
||||
participant BP as Browser Player
|
||||
|
||||
WMR->>BP: stream(url)
|
||||
BP->>BP: audio.src = url; play()
|
||||
|
||||
WMR->>BP: pause()
|
||||
BP->>BP: audio.pause()
|
||||
|
||||
WMR->>BP: seek(timestamp)
|
||||
BP->>BP: audio.currentTime = timestamp
|
||||
|
||||
WMR->>BP: flush()
|
||||
BP->>BP: audioContext.suspend()
|
||||
```
|
||||
|
||||
### Mapping orders → actions par device
|
||||
|
||||
| UPnP order | Browser | Android Auto | CarPlay | Sonos | Chromecast |
|
||||
|-----------|---------|-------------|--------|------|-------------|
|
||||
| Play | `audio.play()` | AA play | CP play | UPnP Play | Cast play |
|
||||
| Pause | `audio.pause()` | AA pause | CP pause | UPnP Pause | Cast pause |
|
||||
| Resume | `audio.play()` | AA play | CP play | UPnP Play | Cast play |
|
||||
| Seek | `audio.currentTime=t` | AA seek | CP seek | UPnP Seek | Cast seek |
|
||||
| Next | fetch new stream | AA next | CP next | UPnP Next | Cast next |
|
||||
| Prev | fetch new stream | AA prev | CP prev | UPnP Prev | Cast prev |
|
||||
| Flush | JS `audioContext.suspend()` | AA flush | CP flush | N/A | Cast load |
|
||||
| Stop | `audio.stop()` | AA stop | CP stop | UPnP Stop | Cast stop |
|
||||
|
||||
### Protocole de contrôle
|
||||
|
||||
Le Web Media Renderer expose une API de contrôle uniforme qui est traduite selon le device:
|
||||
|
||||
### Commandes
|
||||
|
||||
| Commande | Description |
|
||||
|---------|-------------|
|
||||
| `play` | Lecture |
|
||||
| `pause` | Pause (silence ou backpressure) |
|
||||
| `resume` | Reprise |
|
||||
| `seek(t)` | Seek vers timestamp t |
|
||||
| `next` | Track suivante |
|
||||
| `prev` | Track précédente |
|
||||
| `flush` | **Flush buffer** - ordre critique pour reponse rapide |
|
||||
| `stop` | Arrêt total |
|
||||
|
||||
### Métadonnées
|
||||
|
||||
| Endpoint | Description |
|
||||
|----------|-------------|
|
||||
| `/nowplaying` | Track actuelle, timestamp, is_paused |
|
||||
| `/metadata` | TITLE, ARTIST, ALBUM, COVER |
|
||||
| `/state` | État complet (position, duration, volume...) |
|
||||
|
||||
### Ordres spéciaux pour devices avec buffer
|
||||
|
||||
Pour les devices типа Android Auto, CarPlay, Browser:
|
||||
- `flush` = vide le buffer immédiatement
|
||||
- `stop` = arrête + flush
|
||||
- Ces ordres doivent être traités en priorité
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant CP as Control Point
|
||||
participant WMR as Web Media Renderer
|
||||
participant D as Device (Android Auto, Browser...)
|
||||
|
||||
CP->>WMR: flush()
|
||||
Note over WMR: Priorité haute - immédiat
|
||||
WMR->>D: FLUSH order
|
||||
D-->>WMR: ack
|
||||
WMR-->>CP: flushed
|
||||
|
||||
CP->>WMR: pause()
|
||||
Note over WMR: Standard
|
||||
WMR->>D: flux with silence
|
||||
```
|
||||
|
||||
## multipoint/multi-room
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
WMR[Web Media Renderer] -->|flux| R1[Renderer 1]
|
||||
WMR -->|flux| R2[Renderer 2]
|
||||
WMR -->|flux| R3[Renderer N]
|
||||
|
||||
R1 -->|sync| R2
|
||||
R2 -->|sync| R3
|
||||
```
|
||||
|
||||
Possibilités:
|
||||
- UPnP pour renderers UPnP
|
||||
- Cast API pour Chromecast
|
||||
- Serveur temps réel pour sync
|
||||
|
||||
## Notes techniques
|
||||
|
||||
### AudioChunk::silence()
|
||||
|
||||
```rust
|
||||
impl AudioChunk {
|
||||
pub fn silence(frames: usize, sample_rate: u32) -> Self {
|
||||
AudioChunk::I32(AudioChunkData::<i32>::silence(frames, sample_rate))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Sample> AudioChunkData<T> {
|
||||
pub fn silence(frames: usize, sample_rate: u32) -> Arc<Self> {
|
||||
Self::new(vec![[T::ZERO; 2]; frames], sample_rate, 0.0)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### StreamType
|
||||
|
||||
```rust
|
||||
pub enum StreamType {
|
||||
Continuous, // radio - silence pendant pause
|
||||
Finite, // tracks - backpressure pendant pause
|
||||
}
|
||||
```
|
||||
|
||||
### TrackBoundary avec StreamType
|
||||
|
||||
```rust
|
||||
SyncMarker::TrackBoundary {
|
||||
metadata: ...,
|
||||
stream_type: StreamType
|
||||
}
|
||||
```
|
||||
327
Blackboard/Todo/Frontend_Review.md
Normal file
327
Blackboard/Todo/Frontend_Review.md
Normal file
@@ -0,0 +1,327 @@
|
||||
** 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) **
|
||||
|
||||
## Contexte
|
||||
|
||||
Ce document est le résultat d'une revue de code complète du frontend Vue.js/TypeScript du Control Point
|
||||
(`pmoapp/webapp/src/`). L'application est fonctionnelle mais présente plusieurs classes de problèmes
|
||||
qui peuvent causer des fuites mémoire, des incohérences de réactivité Vue 3, et des difficultés de
|
||||
maintenance à mesure que l'app grandit.
|
||||
|
||||
**Périmètre** : uniquement `pmoapp/webapp/src/` (composants, services, composables, stores, utils, CSS).
|
||||
|
||||
---
|
||||
|
||||
## Problèmes identifiés
|
||||
|
||||
### P0 — Fuites mémoire via listeners SSE jamais nettoyés
|
||||
|
||||
**Fichiers** : `src/composables/useSSE.ts`, `src/composables/useRenderers.ts`,
|
||||
`src/composables/useMediaServers.ts`
|
||||
|
||||
Les abonnements aux événements SSE sont créés lors du premier appel de chaque composable, mais
|
||||
jamais nettoyés si le composable est réutilisé ou le composant détruit. Dans `useSSE.ts`, la
|
||||
fonction `onRendererEvent()` retourne une fonction de cleanup, mais `useRendererEvents()` ignore
|
||||
ce retour — l'abonnement reste actif indefiniment.
|
||||
|
||||
De plus, dans `imageCache.ts`, un `setInterval` de cleanup s'exécute toutes les 5 minutes sans
|
||||
jamais être annulé si l'app est détruite.
|
||||
|
||||
### P1 — Réactivité Vue incohérente avec `shallowRef` + Maps
|
||||
|
||||
**Fichier** : `src/composables/useRenderers.ts` (L23-37)
|
||||
|
||||
`snapshots` et `loadingIds` sont déclarés en `shallowRef<Map<...>>()`. Vue ne détecte pas les
|
||||
mutations d'objets à l'intérieur d'un `shallowRef`. La solution actuelle — `triggerSnapshotReactivity()`
|
||||
qui crée une nouvelle Map à chaque appel — force une re-render complète de tous les composants
|
||||
qui dépendent de `snapshots`, même si seul un renderer a changé.
|
||||
|
||||
### P2 — Race condition à l'initialisation (main.ts)
|
||||
|
||||
**Fichier** : `src/main.ts`
|
||||
|
||||
Le UIStore est initialisé après le montage de l'app et l'appel à `sse.connect()`. Des événements
|
||||
SSE peuvent arriver avant que `useUIStore()` soit appelé dans les composants, et les notifications
|
||||
correspondantes peuvent être perdues.
|
||||
|
||||
### P3 — SSE singleton sans garantie formelle
|
||||
|
||||
**Fichiers** : `src/composables/useRenderers.ts`, `src/composables/useMediaServers.ts`
|
||||
|
||||
Chaque composable maintient son propre flag `sseInitialized` pour éviter les double-abonnements.
|
||||
Le mécanisme repose sur une convention implicite fragile : si deux composables s'abonnent au même
|
||||
type d'événement SSE dans des contextes différents, les callbacks s'accumulent sans être
|
||||
dédupliqués.
|
||||
|
||||
Dans `useSSE.ts`, `setupConnectionListener()` vérifie `connectionCallbacks.size === 0` mais
|
||||
sans lock — deux appels simultanés peuvent installer deux listeners.
|
||||
|
||||
### P4 — Pas de timeout ni retry sur les requêtes `fetch`
|
||||
|
||||
**Fichier** : `src/services/pmocontrol/api.ts`
|
||||
|
||||
Toutes les requêtes `fetch()` sont émises sans `AbortController`. Si le serveur ne répond pas,
|
||||
la promesse pend indéfiniment, bloquant potentiellement les composants qui attendent le résultat.
|
||||
Il n'y a ni timeout configurable ni retry automatique au niveau du service.
|
||||
|
||||
### P5 — Validation absente des réponses API
|
||||
|
||||
**Fichiers** : `src/services/audioCache.ts` (L76), `src/services/coverCache.ts`,
|
||||
`src/services/playlists.ts`, `src/services/pmocontrol/api.ts`
|
||||
|
||||
Les réponses JSON sont acceptées sans vérification de structure. Une assertion de type comme
|
||||
`metadata as { origin_url?: unknown }` ne protège pas contre un changement d'API côté Rust. Si
|
||||
l'API retourne une structure inattendue, le crash survient au runtime, pas à la compilation.
|
||||
|
||||
### P6 — Type assertions dangereuses dans PMOPlayer
|
||||
|
||||
**Fichier** : `src/services/pmosource.ts` / PMOPlayer (L197-214)
|
||||
|
||||
Les messages de commande sont typés `Record<string, unknown>`, puis les propriétés sont castées
|
||||
directement : `msg.url as string`, `msg.timestamp as number`. Si une propriété est absente ou
|
||||
d'un type différent, TypeScript ne le détecte pas.
|
||||
|
||||
### P7 — `useTabs` : watch multiples sans debounce, flag de restauration non-réinitialisé
|
||||
|
||||
**Fichier** : `src/composables/useTabs.ts` (L44, L350-361)
|
||||
|
||||
Trois `watch()` séparées écrivent dans `localStorage`. Sans debounce commun, si 3 onglets
|
||||
changent d'état simultanément, `localStorage` est écrit 3 fois de suite.
|
||||
|
||||
Le flag `isRestoringFromStorage` (L44) empêche la boucle de sauvegarde pendant la restauration,
|
||||
mais sans timeout : si `restoreFromLocalStorage()` lance une exception non-catchée, le flag reste
|
||||
`true` et toutes les sauvegardes futures sont silencieusement ignorées.
|
||||
|
||||
### P8 — Routes de debug exposées en production, pas de lazy loading
|
||||
|
||||
**Fichier** : `src/router/index.ts`
|
||||
|
||||
Les routes debug (CoversCache, AudioCache, UPnP Explorer, etc.) sont accessibles en production
|
||||
sans contrôle d'accès. Par ailleurs, tous les composants sont importés statiquement, augmentant
|
||||
le bundle initial inutilement — les vues debug notamment ne sont jamais utilisées en prod.
|
||||
|
||||
### P9 — `formatMsToShortTime` est un alias inutile
|
||||
|
||||
**Fichier** : `src/utils/time.ts` (L58-59)
|
||||
|
||||
```typescript
|
||||
// Actuellement
|
||||
export function formatMsToShortTime(ms: number | null): string {
|
||||
return formatMsToTime(ms);
|
||||
}
|
||||
```
|
||||
|
||||
Fonction identique à `formatMsToTime`. Tous les appelants peuvent utiliser directement
|
||||
`formatMsToTime`.
|
||||
|
||||
### P10 — `truncate()` dans `string.ts` peut dépasser `maxLength`
|
||||
|
||||
**Fichier** : `src/utils/string.ts` (L46-48)
|
||||
|
||||
```typescript
|
||||
// Actuellement
|
||||
export function truncate(str: string, maxLength: number, suffix = '…'): string {
|
||||
return str.length > maxLength ? str.slice(0, maxLength - suffix.length) + suffix : str;
|
||||
}
|
||||
```
|
||||
|
||||
Si `suffix.length >= maxLength`, `str.slice(0, maxLength - suffix.length)` retourne une chaîne
|
||||
de longueur négative (comportement silencieux en JS, retourne `''`), et le résultat final est
|
||||
plus long que `maxLength`.
|
||||
|
||||
### P11 — `DEFAULT_COVER_SVG` inline dans coverCache.ts
|
||||
|
||||
**Fichier** : `src/services/coverCache.ts` (L206-226)
|
||||
|
||||
Un SVG inline de ~20 lignes est inclus dans chaque bundle qui importe `coverCache`. Il devrait
|
||||
être un fichier `src/assets/default-cover.svg` importé nativement par Vite (ce qui permet le
|
||||
tree-shaking et le caching HTTP séparé).
|
||||
|
||||
### P12 — `animations` CSS sans `prefers-reduced-motion`
|
||||
|
||||
**Fichiers** : `src/assets/styles/glass-theme.css` (L384-401),
|
||||
`src/assets/styles/pmocontrol.css` (L82)
|
||||
|
||||
Les animations `glassShimmer` (2s infini) et le `pulse` du badge de statut `Transitioning`
|
||||
s'exécutent sans tenir compte de `prefers-reduced-motion: reduce`. Sur certains systèmes ou
|
||||
pour des utilisateurs sensibles au mouvement, ces animations sont gênantes.
|
||||
|
||||
### P13 — CSS dupliqué dans drawers.css
|
||||
|
||||
**Fichier** : `src/assets/styles/drawers.css` (L105-149)
|
||||
|
||||
`drawer-close-btn` et `drawer-back-btn` partagent 90% des styles. Un TODO présent en L167
|
||||
("remplacer par la classe globale .section-title") confirme cette dette. La variable
|
||||
`var(--opacity-disabled)` est utilisée mais non définie dans `variables.css`.
|
||||
|
||||
### P14 — `browseContainer` : clés de cache fragiles et pas de pagination
|
||||
|
||||
**Fichier** : `src/composables/useMediaServers.ts` (L145, L239)
|
||||
|
||||
Les clés de cache sont construites comme `${serverId}/${containerId}`. Si un `containerId`
|
||||
contient un slash (séparateur d'URL), la clé est ambigüe. Par exemple, `server1/a/b` peut
|
||||
correspondre à serverId=`server1`, containerId=`a/b` ou serverId=`server1/a`, containerId=`b`.
|
||||
|
||||
La pagination n'est pas implémentée côté composable : `browseContainer` charge toujours
|
||||
offset=0, limit=50. Pour les containers avec 500+ items, les items au-delà de 50 ne sont
|
||||
jamais accessibles.
|
||||
|
||||
### P15 — Notifications sans limite de taille dans `ui.ts`
|
||||
|
||||
**Fichier** : `src/stores/ui.ts` (L50, L55-57)
|
||||
|
||||
Un bug ou une boucle d'erreur peut générer des centaines de notifications. Le tableau
|
||||
`notifications` n'est pas limité. Chaque notification crée un `setTimeout` individuel, et
|
||||
si le store est détruit avant l'expiration, ces callbacks persistent (ghosts).
|
||||
|
||||
---
|
||||
|
||||
## Plan d'exécution
|
||||
|
||||
Les corrections sont groupées par effort et impact. Les P0–P3 concernent la fiabilité
|
||||
(fuites mémoire, réactivité), les P4–P8 la robustesse et maintenabilité, les P9–P15 la
|
||||
qualité et la dette technique.
|
||||
|
||||
### Étape 1 — Corriger les fuites mémoire SSE (P0)
|
||||
|
||||
Dans `useSSE.ts`, stocker et appeler les fonctions de cleanup retournées par `onRendererEvent` /
|
||||
`onMediaServerEvent` :
|
||||
|
||||
```typescript
|
||||
// useSSE.ts – useRendererEvents()
|
||||
onMounted(() => {
|
||||
const cleanup = onRendererEvent(rendererId(), handler);
|
||||
onUnmounted(cleanup); // ← actuellement ignoré
|
||||
});
|
||||
```
|
||||
|
||||
Dans `imageCache.ts`, exporter une fonction `destroyImageCache()` qui appelle `clearInterval`
|
||||
sur le timer de cleanup, et l'appeler dans le `onUnmounted` de l'app root.
|
||||
|
||||
### Étape 2 — Stabiliser la réactivité des snapshots (P1)
|
||||
|
||||
Remplacer `shallowRef<Map<...>>` + `triggerSnapshotReactivity` par `reactive(new Map<...>)`.
|
||||
Vue 3 rend les Maps réactives nativement. Les composants qui lisent `snapshots.get(id)`
|
||||
seront notifiés uniquement si ce `id` change.
|
||||
|
||||
```typescript
|
||||
// Avant
|
||||
const snapshots = shallowRef<Map<string, FullRendererSnapshot>>(new Map());
|
||||
function triggerSnapshotReactivity() {
|
||||
snapshots.value = new Map(snapshots.value);
|
||||
}
|
||||
|
||||
// Après
|
||||
const snapshots = reactive(new Map<string, FullRendererSnapshot>());
|
||||
// Les modifications directes (snapshots.set/delete) déclenchent la réactivité
|
||||
```
|
||||
|
||||
### Étape 3 — Timeout fetch + AbortController (P4)
|
||||
|
||||
Ajouter un helper dans `api.ts` :
|
||||
|
||||
```typescript
|
||||
function fetchWithTimeout(url: string, options?: RequestInit, timeoutMs = 10_000): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const id = setTimeout(() => controller.abort(), timeoutMs);
|
||||
return fetch(url, { ...options, signal: controller.signal })
|
||||
.finally(() => clearTimeout(id));
|
||||
}
|
||||
```
|
||||
|
||||
Utiliser `fetchWithTimeout` pour toutes les requêtes dans le service API.
|
||||
|
||||
### Étape 4 — Corriger `useTabs` watchs et flag de restauration (P7)
|
||||
|
||||
Fusionner les trois `watch()` en un seul `watchEffect` avec un debounce unique (100ms).
|
||||
Encadrer `isRestoringFromStorage` dans un bloc `try/finally` :
|
||||
|
||||
```typescript
|
||||
async function restoreFromLocalStorage() {
|
||||
isRestoringFromStorage = true;
|
||||
try {
|
||||
// ... logique de restauration
|
||||
} catch (e) {
|
||||
console.error('Tab restore failed:', e);
|
||||
} finally {
|
||||
isRestoringFromStorage = false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Étape 5 — Limit de notifications et nettoyage timers (P15)
|
||||
|
||||
```typescript
|
||||
const MAX_NOTIFICATIONS = 5;
|
||||
|
||||
function addNotification(notif: Omit<Notification, 'id'>): void {
|
||||
if (notifications.value.length >= MAX_NOTIFICATIONS) {
|
||||
notifications.value.shift(); // supprimer la plus ancienne
|
||||
}
|
||||
const id = nextId++;
|
||||
const timer = setTimeout(() => removeNotification(id), notif.duration ?? 5000);
|
||||
notificationTimers.set(id, timer);
|
||||
notifications.value.push({ ...notif, id });
|
||||
}
|
||||
|
||||
function $dispose() {
|
||||
notificationTimers.forEach(clearTimeout);
|
||||
notificationTimers.clear();
|
||||
}
|
||||
```
|
||||
|
||||
### Étape 6 — Lazy loading des routes et protection debug (P8)
|
||||
|
||||
```typescript
|
||||
// router/index.ts
|
||||
const DebugView = () => import('../views/DebugView.vue');
|
||||
const isDev = import.meta.env.DEV;
|
||||
|
||||
const routes = [
|
||||
// ... routes normales
|
||||
...(isDev ? [{ path: '/debug', component: DebugView }] : []),
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/' }, // wildcard 404
|
||||
];
|
||||
```
|
||||
|
||||
### Étape 7 — Corrections mineures (P9, P10, P11, P12, P13)
|
||||
|
||||
- **P9** : Supprimer `formatMsToShortTime`, remplacer tous les appels par `formatMsToTime`
|
||||
- **P10** : Ajouter un guard dans `truncate` : `if (suffix.length >= maxLength) return str.slice(0, maxLength)`
|
||||
- **P11** : Déplacer le SVG dans `src/assets/default-cover.svg` et l'importer avec `import defaultCover from '../assets/default-cover.svg?raw'`
|
||||
- **P12** : Entourer les animations CSS avec `@media (prefers-reduced-motion: no-preference) { ... }`
|
||||
- **P13** : Factoriser `drawer-close-btn` / `drawer-back-btn` avec une classe `.drawer-icon-btn`. Définir `--opacity-disabled: 0.4` dans `variables.css`
|
||||
|
||||
### Étape 8 — Clés de cache et pagination (P14)
|
||||
|
||||
Encoder les IDs dans les clés de cache :
|
||||
|
||||
```typescript
|
||||
const cacheKey = `${encodeURIComponent(serverId)}:${encodeURIComponent(containerId)}`;
|
||||
```
|
||||
|
||||
Utiliser `:` comme séparateur (absent de l'encoding) pour éviter toute ambigüité.
|
||||
|
||||
Pour la pagination, ajouter une propriété `hasMore: boolean` et `loadMore()` au résultat de
|
||||
`browseContainer`, incrementant offset à chaque appel.
|
||||
|
||||
### Ordre d'exécution
|
||||
|
||||
1. Étape 1 — fuites SSE (P0) — fiabilité critique
|
||||
2. Étape 2 — réactivité Map (P1) — fiabilité
|
||||
3. Étape 3 — timeout fetch (P4) — robustesse réseau
|
||||
4. Étape 4 — useTabs (P7) — fiabilité des onglets
|
||||
5. Étape 5 — notifications (P15) — stabilité UI
|
||||
6. Étape 6 — router (P8) — sécurité + performance bundle
|
||||
7. Étape 7 — corrections mineures (P9–P13)
|
||||
8. Étape 8 — cache keys + pagination (P14)
|
||||
|
||||
## Règle après ces corrections
|
||||
|
||||
**Interdit** : créer un abonnement SSE (`onRendererEvent`, `onMediaServerEvent`) sans stocker
|
||||
et appeler la fonction de cleanup retournée dans `onUnmounted`.
|
||||
|
||||
**Interdit** : utiliser `shallowRef<Map<...>>` avec mutation directe — utiliser `reactive(new Map())`
|
||||
pour les Maps qui doivent déclencher la réactivité Vue sur leurs entrées.
|
||||
|
||||
**Obligatoire** : toute requête `fetch()` dans un service doit utiliser `fetchWithTimeout`
|
||||
avec un AbortController.
|
||||
426
Blackboard/Todo/enorme_playlist.md
Normal file
426
Blackboard/Todo/enorme_playlist.md
Normal file
@@ -0,0 +1,426 @@
|
||||
** 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) **
|
||||
|
||||
## Contexte et symptôme
|
||||
|
||||
Le control point PMOMusic est lent lorsqu'un renderer **OpenHome** manipule des playlists
|
||||
d'environ 1 000 titres. Les renderers Chromecast et UPnP pur ne sont pas affectés : ils
|
||||
utilisent une `InternalQueue` entièrement locale, sans appels SOAP. Le problème est
|
||||
spécifique à `OpenHomeQueue` (`pmocontrol/src/queue/openhome.rs`).
|
||||
|
||||
Le code a été généré par IA : il peut contenir des redondances, mais **chaque comportement
|
||||
est intentionnel**. L'objectif est d'optimiser sans rien supprimer.
|
||||
|
||||
## Causes racines identifiées
|
||||
|
||||
### P0 — Double appel à `queue_snapshot()` dans `sync_queue()`
|
||||
|
||||
**Fichiers** : `pmocontrol/src/queue/openhome.rs`
|
||||
|
||||
`sync_queue()` (ligne 1151) appelle `queue_snapshot()` pour obtenir l'état courant.
|
||||
Puis elle délègue à l'une de ces deux sous-fonctions qui appellent **à nouveau**
|
||||
`queue_snapshot()` :
|
||||
|
||||
- `replace_queue_with_pivot()` (ligne 555) : 2e appel `queue_snapshot()` + 1 appel
|
||||
`track_ids()` séparé (alors que `queue_snapshot()` appelle déjà `track_ids()` en interne)
|
||||
- `replace_queue_standard_lcs()` (ligne 647) : 2e appel `queue_snapshot()`
|
||||
|
||||
Seule `replace_queue_preserve_current()` n'a pas ce défaut (elle appelle uniquement
|
||||
`track_ids()`).
|
||||
|
||||
**Impact pour 1 000 titres :**
|
||||
|
||||
Chaque `queue_snapshot()` exécute :
|
||||
- 1 appel SOAP `IdArray` (liste des IDs)
|
||||
- 16 appels SOAP `ReadList` (lots de 64 items)
|
||||
|
||||
Soit **34 appels SOAP** pour une seule opération `sync_queue()` au lieu de 17.
|
||||
|
||||
Le cache `ReadList` (TTL 500 ms) atténue partiellement mais ne supprime pas le problème
|
||||
car la durée d'un `sync_queue` sur 1 000 titres peut dépasser 500 ms.
|
||||
|
||||
### P1 — Algorithme LCS de complexité quadratique O(m × n)
|
||||
|
||||
**Fichier** : `pmocontrol/src/queue/openhome.rs:851`
|
||||
|
||||
La fonction `lcs_flags()` alloue une table DP de taille `(m+1) × (n+1)` :
|
||||
|
||||
```rust
|
||||
let mut dp = vec![vec![0u32; n + 1]; m + 1];
|
||||
```
|
||||
|
||||
Pour 1 000 titres en entrée : 1 000 × 1 000 = **1 000 000 entrées** (≈ 4 MB), et
|
||||
1 000 000 comparaisons. Elle est appelée **jusqu'à 3 fois** dans un seul `sync_queue` :
|
||||
- 2 fois dans `replace_queue_with_pivot()` (avant et après le pivot, lignes 579 et 582)
|
||||
- 1 fois dans `replace_queue_standard_lcs()` (ligne 661)
|
||||
|
||||
Dans le cas courant (ajout de titres en fin de liste, ou liste déjà synchronisée),
|
||||
la quasi-totalité de la table DP est inutile : les préfixe et suffixe communs
|
||||
représentent souvent 90 % ou plus de la liste.
|
||||
|
||||
### P2 — Taille de lot `ReadList` = 64
|
||||
|
||||
**Fichier** : `pmocontrol/src/queue/openhome.rs:986`
|
||||
|
||||
```rust
|
||||
const MAX_BATCH: usize = 64;
|
||||
```
|
||||
|
||||
Pour 1 000 titres : 1 000 ÷ 64 = **16 appels SOAP `ReadList`** par `queue_snapshot()`.
|
||||
La latence réseau typique par appel SOAP (50–200 ms) implique 0,8 à 3,2 secondes
|
||||
uniquement pour la lecture des métadonnées.
|
||||
|
||||
La norme OpenHome Playlist ne fixe pas de limite de payload. La valeur 64 est
|
||||
conservatrice. Augmenter à 256 réduit à **4 appels** (−75 %).
|
||||
|
||||
Le mécanisme de fallback one-by-one (lignes 1007–1019) assure la rétrocompatibilité
|
||||
avec les devices qui refuseraient un payload plus large.
|
||||
|
||||
### P3 — Polling à 500 ms indépendant de l'activité
|
||||
|
||||
**Fichier** : `pmocontrol/src/music_renderer/watcher.rs`
|
||||
|
||||
Chaque renderer OpenHome tourne un thread watcher toutes les 500 ms, même en veille.
|
||||
Avec plusieurs renderers actifs, les appels de polling et les opérations `sync_queue`
|
||||
se chevauchent sur le même device réseau, créant de la contention.
|
||||
|
||||
### P4 — Redondances de code (nettoyage conservatif)
|
||||
|
||||
**a. Invalidation des caches dupliquée** (`openhome.rs`)
|
||||
|
||||
La séquence d'invalidation apparaît en 3 endroits distincts (lignes 1134–1136,
|
||||
454–456, 634–635) :
|
||||
|
||||
```rust
|
||||
self.track_ids_cache.lock().unwrap().invalidate();
|
||||
self.read_list_cache.lock().unwrap().invalidate();
|
||||
// parfois aussi :
|
||||
self.current_track_id_cache.lock().unwrap().invalidate();
|
||||
```
|
||||
|
||||
**b. Protection durée stream dupliquée** (`openhome.rs` et `interne.rs`)
|
||||
|
||||
La logique de protection de durée pour les flux continus (radio) est implémentée :
|
||||
- Dans `cache_metadata()` de `OpenHomeQueue` (`openhome.rs:250–351`)
|
||||
- Dans `protect_stream_durations()` de `InternalQueue` (`interne.rs:92–143`)
|
||||
- Dans `merge_metadata_protecting_streams()` de `InternalQueue` (`interne.rs:148–215`)
|
||||
|
||||
**c. `parse_duration()` défini 3 fois**
|
||||
|
||||
La conversion `HH:MM:SS` → secondes apparaît dans `openhome.rs`, `interne.rs`,
|
||||
et dans `time_utils::parse_hhmmss_u32()` (déjà publique).
|
||||
|
||||
## Ce qui fonctionne déjà correctement
|
||||
|
||||
**Pagination du Browse** : La boucle de pagination est correctement implémentée dans
|
||||
`control_point.rs:1610–1653` avec `browse_children()` + offset incrémental.
|
||||
|
||||
**Fallback ReadList one-by-one** : Si un batch échoue, le retry unitaire (lignes 1007–1019)
|
||||
assure la robustesse sur les devices stricts.
|
||||
|
||||
**Protection multi-control-point** : `delete_id_if_exists()` gère proprement le cas où
|
||||
un autre control point a déjà supprimé un titre.
|
||||
|
||||
**Stratégie double-LCS avec pivot** : La logique de `replace_queue_with_pivot()` est
|
||||
correcte et importante pour ne pas interrompre la lecture en cours.
|
||||
|
||||
**Cache métadonnées stream** : La protection de durée décroissante pour les flux radio
|
||||
est un comportement essentiel à préserver scrupuleusement.
|
||||
|
||||
## Plan d'exécution
|
||||
|
||||
### Crate concernée : `pmocontrol`
|
||||
|
||||
---
|
||||
|
||||
### Étape 1 — Augmenter le batch `ReadList` à 256
|
||||
|
||||
**Fichier** : `pmocontrol/src/queue/openhome.rs:986`
|
||||
|
||||
```rust
|
||||
// Avant
|
||||
const MAX_BATCH: usize = 64;
|
||||
|
||||
// Après
|
||||
const MAX_BATCH: usize = 256;
|
||||
```
|
||||
|
||||
Le fallback one-by-one (lignes 1007–1019) reste intact. Si un renderer refuse
|
||||
un payload de 256 IDs, il retombe automatiquement sur le mode unitaire.
|
||||
|
||||
---
|
||||
|
||||
### Étape 2 — Éliminer le double appel à `queue_snapshot()`
|
||||
|
||||
**Fichier** : `pmocontrol/src/queue/openhome.rs`
|
||||
|
||||
Le snapshot calculé dans `sync_queue()` contient déjà les items **et** leurs IDs
|
||||
backend (`backend_id: usize`). Il n'est pas nécessaire de le recalculer dans les
|
||||
sous-fonctions.
|
||||
|
||||
#### 2a. Passer le snapshot à `replace_queue_with_pivot()`
|
||||
|
||||
Signature actuelle (ligne 548) :
|
||||
```rust
|
||||
fn replace_queue_with_pivot(
|
||||
&mut self,
|
||||
new_items: Vec<PlaybackItem>,
|
||||
pivot_idx_new: usize,
|
||||
pivot_id: usize,
|
||||
) -> Result<(), ControlPointError>
|
||||
```
|
||||
|
||||
Nouvelle signature :
|
||||
```rust
|
||||
fn replace_queue_with_pivot(
|
||||
&mut self,
|
||||
new_items: Vec<PlaybackItem>,
|
||||
pivot_idx_new: usize,
|
||||
pivot_id: usize,
|
||||
snapshot: &QueueSnapshot, // ← ajouté
|
||||
current_track_ids: &[u32], // ← ajouté (évite aussi le 2e appel track_ids())
|
||||
) -> Result<(), ControlPointError>
|
||||
```
|
||||
|
||||
À l'intérieur de `replace_queue_with_pivot()`, supprimer :
|
||||
```rust
|
||||
// Supprimer ces deux lignes (ligne 555–556)
|
||||
let snapshot = self.queue_snapshot()?;
|
||||
let current_track_ids = self.track_ids()?;
|
||||
```
|
||||
|
||||
Et utiliser directement les paramètres `snapshot` et `current_track_ids`.
|
||||
|
||||
Appel depuis `sync_queue()` (ligne 1221) :
|
||||
```rust
|
||||
// Avant
|
||||
self.replace_queue_with_pivot(items, pivot_idx, playing_id)?;
|
||||
|
||||
// Après — passer le snapshot et les IDs déjà disponibles
|
||||
let current_ids_for_pivot: Vec<u32> = snapshot.items
|
||||
.iter()
|
||||
.map(|i| i.backend_id as u32)
|
||||
.collect();
|
||||
self.replace_queue_with_pivot(items, pivot_idx, playing_id, &snapshot, ¤t_ids_for_pivot)?;
|
||||
```
|
||||
|
||||
**Note importante** : dans `sync_queue()`, le snapshot est pris APRÈS
|
||||
`ensure_playlist_source_selected()` (ligne 1115) et APRÈS la résolution du `playing_info`.
|
||||
Cet ordre est correct et doit être conservé.
|
||||
|
||||
#### 2b. Passer le snapshot à `replace_queue_standard_lcs()`
|
||||
|
||||
Signature actuelle (ligne 641) :
|
||||
```rust
|
||||
fn replace_queue_standard_lcs(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
_current_index: Option<usize>,
|
||||
) -> Result<(), ControlPointError>
|
||||
```
|
||||
|
||||
Nouvelle signature :
|
||||
```rust
|
||||
fn replace_queue_standard_lcs(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
snapshot: &QueueSnapshot, // ← ajouté
|
||||
current_track_ids: &[u32], // ← ajouté
|
||||
) -> Result<(), ControlPointError>
|
||||
```
|
||||
|
||||
À l'intérieur, supprimer :
|
||||
```rust
|
||||
// Supprimer ces deux lignes (lignes 647–648)
|
||||
let snapshot = self.queue_snapshot()?;
|
||||
let current_track_ids = self.track_ids()?;
|
||||
```
|
||||
|
||||
Appel depuis `sync_queue()` (ligne 1253) :
|
||||
```rust
|
||||
// Avant
|
||||
self.replace_queue_standard_lcs(items, Some(0))?;
|
||||
|
||||
// Après
|
||||
let current_ids_for_lcs: Vec<u32> = snapshot.items
|
||||
.iter()
|
||||
.map(|i| i.backend_id as u32)
|
||||
.collect();
|
||||
self.replace_queue_standard_lcs(items, &snapshot, ¤t_ids_for_lcs)?;
|
||||
```
|
||||
|
||||
**Cas particulier à préserver** (ligne 1237–1246) : le guard sur `snapshot.items.is_empty()`
|
||||
dans `sync_queue()` est exécuté **avant** l'appel à `replace_queue_standard_lcs`, donc
|
||||
le snapshot vide ne peut pas atteindre la sous-fonction — le comportement est préservé.
|
||||
|
||||
---
|
||||
|
||||
### Étape 3 — Optimiser LCS par élagage du préfixe/suffixe communs
|
||||
|
||||
**Fichier** : `pmocontrol/src/queue/openhome.rs`
|
||||
|
||||
La fonction `lcs_flags()` (ligne 851) reste inchangée. L'optimisation s'applique
|
||||
**aux appels** dans `replace_queue_with_pivot()` et `replace_queue_standard_lcs()`.
|
||||
|
||||
#### Principe
|
||||
|
||||
Avant de calculer le LCS DP, éliminer les éléments identiques en tête et en queue :
|
||||
|
||||
```rust
|
||||
/// Wrapper autour de lcs_flags() qui élimine préfixe et suffixe communs
|
||||
/// avant d'appeler l'algorithme DP O(m×n).
|
||||
///
|
||||
/// Cas optimisés : ajout en fin de liste → O(n), liste déjà synchro → O(n),
|
||||
/// suppression en fin → O(n). LCS complet uniquement pour les vrais réordonnements.
|
||||
fn lcs_flags_optimized(
|
||||
current: &[PlaybackItem],
|
||||
desired: &[PlaybackItem],
|
||||
) -> (Vec<bool>, Vec<bool>) {
|
||||
// Préfixe commun
|
||||
let leading = current
|
||||
.iter()
|
||||
.zip(desired.iter())
|
||||
.take_while(|(c, d)| items_match(c, d))
|
||||
.count();
|
||||
|
||||
// Suffixe commun (sur les portions restantes uniquement)
|
||||
let c_tail = ¤t[leading..];
|
||||
let d_tail = &desired[leading..];
|
||||
let trailing = c_tail
|
||||
.iter()
|
||||
.rev()
|
||||
.zip(d_tail.iter().rev())
|
||||
.take_while(|(c, d)| items_match(c, d))
|
||||
.count();
|
||||
|
||||
let c_mid = &c_tail[..c_tail.len() - trailing];
|
||||
let d_mid = &d_tail[..d_tail.len() - trailing];
|
||||
|
||||
// Si rien à faire (listes identiques ou préfixe/suffixe couvrent tout)
|
||||
if c_mid.is_empty() && d_mid.is_empty() {
|
||||
return (vec![true; current.len()], vec![true; desired.len()]);
|
||||
}
|
||||
|
||||
// LCS DP sur le delta central uniquement
|
||||
let (keep_c_mid, keep_d_mid) = lcs_flags(c_mid, d_mid);
|
||||
|
||||
// Reconstituer les vecteurs complets
|
||||
let mut keep_current = vec![true; leading];
|
||||
keep_current.extend(keep_c_mid);
|
||||
keep_current.extend(vec![true; trailing]);
|
||||
|
||||
let mut keep_desired = vec![true; leading];
|
||||
keep_desired.extend(keep_d_mid);
|
||||
keep_desired.extend(vec![true; trailing]);
|
||||
|
||||
(keep_current, keep_desired)
|
||||
}
|
||||
```
|
||||
|
||||
Remplacer les 3 appels à `lcs_flags()` (lignes 579, 582, 661) par `lcs_flags_optimized()`.
|
||||
|
||||
La fonction `lcs_flags()` originale est **conservée** (utilisée en interne par
|
||||
`lcs_flags_optimized()`).
|
||||
|
||||
---
|
||||
|
||||
### Étape 4 — Polling adaptatif selon l'activité
|
||||
|
||||
**Fichier** : `pmocontrol/src/music_renderer/watcher.rs`
|
||||
|
||||
Ajouter un flag partagé `is_active` dans `MusicRenderer` (ou `WatchedState`) pour
|
||||
signaler si le renderer est en activité récente.
|
||||
|
||||
Le renderer met `is_active = true` lors de chaque opération (play, sync, seek, stop).
|
||||
Le watcher revient à l'intervalle long (5 000 ms) après 10 s sans activité.
|
||||
|
||||
```rust
|
||||
// Dans la boucle du watcher :
|
||||
let interval = if is_active.load(Ordering::Relaxed) {
|
||||
Duration::from_millis(500)
|
||||
} else {
|
||||
Duration::from_millis(5_000)
|
||||
};
|
||||
thread::sleep(interval);
|
||||
```
|
||||
|
||||
**Fonctionnalités à préserver** :
|
||||
- Détection de fin de piste (auto-advance) : délai max 5 s en idle — acceptable
|
||||
- Sleep timer countdown : reste actif au polling suivant
|
||||
- Synchronisation auto sur mise à jour de playlist : déclenchée par événement externe,
|
||||
pas par le polling — non affectée
|
||||
|
||||
---
|
||||
|
||||
### Étape 5 — Consolider les redondances (nettoyage conservatif)
|
||||
|
||||
**À réaliser uniquement après validation fonctionnelle des étapes 1–4.**
|
||||
|
||||
#### 5a. Méthode `invalidate_all_caches()` sur `OpenHomeQueue`
|
||||
|
||||
```rust
|
||||
fn invalidate_all_caches(&self) {
|
||||
self.track_ids_cache.lock().unwrap().invalidate();
|
||||
self.read_list_cache.lock().unwrap().invalidate();
|
||||
self.current_track_id_cache.lock().unwrap().invalidate();
|
||||
}
|
||||
|
||||
fn invalidate_track_caches(&self) {
|
||||
self.track_ids_cache.lock().unwrap().invalidate();
|
||||
self.read_list_cache.lock().unwrap().invalidate();
|
||||
}
|
||||
```
|
||||
|
||||
Remplacer les séquences d'invalidation en 3 endroits (lignes 1134–1136, 454–456, 634–635).
|
||||
Garder les appels sélectifs là où seulement 2 caches sont invalidés.
|
||||
|
||||
#### 5b. Factoriser `parse_duration()`
|
||||
|
||||
Supprimer les définitions locales de `parse_duration` dans `openhome.rs` et `interne.rs`.
|
||||
Utiliser `crate::music_renderer::time_utils::parse_hhmmss_u32()` (déjà publique).
|
||||
La sémantique est identique : conversion `HH:MM:SS` → u64 secondes.
|
||||
|
||||
#### 5c. Factoriser la protection durée stream
|
||||
|
||||
Extraire la logique commune de protection (« ne jamais diminuer la durée d'un flux
|
||||
continu pour le même titre/artiste ») dans une fonction privée dans `openhome.rs`,
|
||||
et y référencer depuis `interne.rs` via le module `queue`.
|
||||
|
||||
**Règle absolue** : ne pas modifier la sémantique de détection de stream continu
|
||||
(`is_continuous_stream_url()`) ni la logique de comparaison titre/artiste. Uniquement
|
||||
factoriser le code existant.
|
||||
|
||||
---
|
||||
|
||||
## Ordre d'exécution
|
||||
|
||||
1. **Étape 1** — Batch ReadList 256 (changement trivial, gain immédiat −75 % appels)
|
||||
2. **Étape 2** — Élimination double `queue_snapshot()` (−50 % appels SOAP totaux)
|
||||
3. **Étape 3** — Optimisation LCS préfixe/suffixe (gain CPU, cas courants en O(n))
|
||||
4. **Étape 4** — Polling adaptatif (réduction contention réseau en veille)
|
||||
5. **Étape 5** — Consolidation redondances (nettoyage, après validation)
|
||||
|
||||
## Périmètre : ce qui ne change pas
|
||||
|
||||
- La logique à 3 cas de `sync_queue()` (avec pivot, préserver courant, LCS standard)
|
||||
- La protection durée décroissante pour les flux radio (cache stream)
|
||||
- Le mécanisme `delete_id_if_exists()` pour la robustesse multi-control-point
|
||||
- Le fallback `ReadList` one-by-one en cas d'erreur batch
|
||||
- La pagination Browse dans `control_point.rs` (déjà correcte)
|
||||
- Le comportement des queues `InternalQueue` (Chromecast, UPnP) — non affectées
|
||||
- Les TTL des caches existants (1 s, 500 ms, 250 ms)
|
||||
- Tous les logs de diagnostic (`tracing::warn!`, `debug!`) — à conserver
|
||||
|
||||
## Tests recommandés
|
||||
|
||||
Demander à l'humain de compiler et tester :
|
||||
|
||||
```
|
||||
cargo build -p pmocontrol
|
||||
```
|
||||
|
||||
Puis tester avec un renderer OpenHome physique :
|
||||
- Playlist de 1 000 titres : mesurer le temps de `sync_queue` avant/après
|
||||
- Ajout de titres en fin de liste : vérifier que LCS optimisé ne fait que des insertions
|
||||
- Lecture en cours + refresh playlist : vérifier que la piste courante n'est pas interrompue
|
||||
- Flux radio : vérifier que la durée ne régresse pas pour un même titre/artiste
|
||||
- Renderer Chromecast : vérifier l'absence de régression (queue interne)
|
||||
235
Blackboard/Todo/enorme_playlist_step2.md
Normal file
235
Blackboard/Todo/enorme_playlist_step2.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# Optimisation Playlist OpenHome — Étape 2
|
||||
|
||||
**Contexte**: Suite de `enorme_playlist.md`. Les optimisations de base (MAX_BATCH=256, LCS prefixe/suffixe, polling adaptatif, caches TTL) sont faites. Les lenteurs persistent sur les grandes playlists (~1000 titres). Les renderers Chromecast et UPnP sont moins affectés mais bénéficieront également de certaines optimisations.
|
||||
|
||||
**Contrainte architecturale fondamentale**: La queue OpenHome est la **source de vérité unique**. Un miroir local persistent a été tenté et abandonné — impossible à maintenir en sync quand d'autres control points (BubbleUPnP, Linn, etc.) modifient la queue. Toute optimisation doit respecter cette contrainte.
|
||||
|
||||
**Note sur le LCS**: `lcs_flags_optimized` (openhome.rs:869) gère déjà le cas dominant (préfixe/suffixe communs). Pour un append de 100 tracks à 900 existants, le LCS est O(1) — ce n'est pas le goulot. Le coût réel est les appels SOAP : connexions TCP × (RTT + handshake) et les `ReadList` pour reconstruire le snapshot.
|
||||
|
||||
---
|
||||
|
||||
## Analyse des Goulots Réels
|
||||
|
||||
Pour un `sync_queue` "append 100 tracks à 900 existants" aujourd'hui :
|
||||
|
||||
| Étape | Appels SOAP | Coût estimé |
|
||||
|-------|------------|-------------|
|
||||
| `id_array()` | 1 | ~5ms |
|
||||
| `read_list()` — 4 batches × 256 | 4 | ~20ms |
|
||||
| 100 × `insert()` | 100 | ~100 × (RTT + **TCP handshake**) |
|
||||
| **Total TCP handshakes** | 105 | **105 × 10-50ms = 1-5 secondes** |
|
||||
|
||||
Les deux leviers : (1) éliminer les handshakes TCP, (2) éliminer les `ReadList` quand inutiles.
|
||||
|
||||
---
|
||||
|
||||
## Plan d'Implémentation
|
||||
|
||||
### Phase 1 — Connection Pooling (1-2h, PRIORITÉ MAXIMALE)
|
||||
|
||||
**Fichier**: `pmocontrol/src/soap_client.rs`
|
||||
|
||||
**Problème**: lignes 65-72 créent un nouvel `ureq::Agent` à chaque appel SOAP = nouvelle connexion TCP à chaque fois.
|
||||
|
||||
**Solution**: Agent statique partagé via `OnceLock`.
|
||||
|
||||
```rust
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static SOAP_AGENT: OnceLock<ureq::Agent> = OnceLock::new();
|
||||
|
||||
fn get_soap_agent() -> &'static ureq::Agent {
|
||||
SOAP_AGENT.get_or_init(|| {
|
||||
ureq::Agent::config_builder()
|
||||
.http_status_as_error(false)
|
||||
.timeout_global(Some(Duration::from_secs(30)))
|
||||
.build()
|
||||
.into()
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Dans `invoke_upnp_action_with_timeout()`, remplacer la construction de l'agent par :
|
||||
|
||||
```rust
|
||||
// Cas normal : réutiliser l'agent partagé (keep-alive, connection pooling)
|
||||
// Cas custom timeout : agent dédié (rare — timeout global suffit en pratique)
|
||||
let agent_owned;
|
||||
let agent: &ureq::Agent = if timeout.is_some() {
|
||||
agent_owned = ureq::Agent::config_builder()
|
||||
.http_status_as_error(false)
|
||||
.timeout_global(timeout)
|
||||
.build()
|
||||
.into();
|
||||
&agent_owned
|
||||
} else {
|
||||
get_soap_agent()
|
||||
};
|
||||
```
|
||||
|
||||
**Vérifier** que `ureq::Agent` maintient bien un pool de connexions HTTP/1.1 keep-alive entre les appels (comportement documenté de ureq v3 — l'agent est conçu pour être réutilisé).
|
||||
|
||||
**Impact**: Élimine les TCP handshakes répétés pour tous les renderers (OpenHome, UPnP, Chromecast). Pour 100 inserts : 99 handshakes économisés × 10-50ms = **1-5 secondes récupérées**.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 — Fast Path Session (2-3h, PRIORITÉ HAUTE)
|
||||
|
||||
**Concept**: Sans miroir persistant (abandonné), on peut quand même éviter les `ReadList` dans le cas dominant en maintenant un état **éphémère de session** — valable uniquement entre deux `sync_queue` consécutifs, et invalidé dès qu'on détecte une incohérence.
|
||||
|
||||
**Principe**: Après chaque `sync_queue`, mémoriser :
|
||||
- la liste d'IDs résultante (déjà dans `track_ids_cache` TTL 1s)
|
||||
- le `after_id` du dernier insert (pour pouvoir appender sans `id_array`)
|
||||
|
||||
Au prochain `sync_queue`, tenter de détecter le pattern sans `ReadList` :
|
||||
|
||||
```rust
|
||||
fn try_fast_path(&self, new_items: &[PlaybackItem]) -> FastPathResult {
|
||||
// Récupérer les IDs actuels (cache ou 1 appel id_array)
|
||||
let current_ids = self.track_ids()?;
|
||||
let current_len = current_ids.len();
|
||||
let new_len = new_items.len();
|
||||
|
||||
// Fast path 1: append only
|
||||
// Condition: new_items a plus d'items, et les current_len premiers de new_items
|
||||
// ont les mêmes didl_id que les items actuels (vérifiable depuis id_array + metadata_cache local)
|
||||
if new_len > current_len {
|
||||
let prefix_matches = self.check_prefix_matches(¤t_ids, &new_items[..current_len]);
|
||||
if prefix_matches {
|
||||
return FastPathResult::AppendOnly { items: &new_items[current_len..] };
|
||||
}
|
||||
}
|
||||
|
||||
// Fast path 2: delete from end
|
||||
if new_len < current_len {
|
||||
let prefix_matches = self.check_prefix_matches(¤t_ids[..new_len], new_items);
|
||||
if prefix_matches {
|
||||
let to_delete = ¤t_ids[new_len..];
|
||||
return FastPathResult::DeleteFromEnd { ids: to_delete };
|
||||
}
|
||||
}
|
||||
|
||||
// Cas général: fallback ReadList + LCS
|
||||
FastPathResult::NeedFullSync
|
||||
}
|
||||
```
|
||||
|
||||
**`check_prefix_matches`** : compare `current_ids[i]` avec `new_items[i]` en utilisant le `metadata_cache` local (déjà en mémoire) pour résoudre les URIs/didl_ids des IDs connus. Si un ID n'est pas en cache → fast path impossible → fallback.
|
||||
|
||||
**Clé**: Cette vérification utilise uniquement le `metadata_cache` local (HashMap en mémoire, nano-secondes) et `id_array()` (déjà caché TTL 1s). Zéro appel `ReadList` dans le cas heureux. Si la vérification échoue (incohérence détectée, cache manquant) → fallback propre vers `ReadList` + LCS, source de vérité OpenHome préservée.
|
||||
|
||||
**Fichier**: `pmocontrol/src/queue/openhome.rs`, ajouter `try_fast_path()` et l'intégrer en début de `sync_queue()`.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 — Queue FIFO Async (8-12h, PRIORITÉ MOYENNE)
|
||||
|
||||
**Prérequis**: Phases 1 et 2 complétées.
|
||||
|
||||
**Concept**: Exécuter les opérations SOAP dans un thread dédié pour rendre `sync_queue()` non-bloquant du point de vue de l'appelant.
|
||||
|
||||
**Contrainte technique**: Les `insert()` sont chaînés — chaque appel retourne un `new_id` utilisé comme `after_id` du suivant. Le worker doit maintenir cet état interne.
|
||||
|
||||
**Fichier à créer**: `pmocontrol/src/queue/openhome_op_queue.rs`
|
||||
|
||||
```rust
|
||||
pub enum OpenHomeOp {
|
||||
/// Insert séquentiel — after_id géré en interne (last_inserted_id)
|
||||
InsertAtEnd { uri: String, metadata: String, didl_id: String },
|
||||
/// Insert après un ID connu (ex: après le pivot)
|
||||
InsertAfter { after_id: u32, uri: String, metadata: String, didl_id: String },
|
||||
DeleteId { track_id: u32 },
|
||||
DeleteAll,
|
||||
SeekId { id: u32 },
|
||||
Play,
|
||||
Pause,
|
||||
Stop,
|
||||
SetVolume { volume: u16 },
|
||||
}
|
||||
|
||||
pub struct OpenHomeOpQueue {
|
||||
sender: mpsc::Sender<OpenHomeOp>,
|
||||
last_error: Arc<Mutex<Option<ControlPointError>>>,
|
||||
completion: Arc<(Mutex<bool>, Condvar)>,
|
||||
}
|
||||
|
||||
impl OpenHomeOpQueue {
|
||||
pub fn push(&self, op: OpenHomeOp) { ... }
|
||||
/// Opérations critiques passent devant (play/stop/volume)
|
||||
pub fn push_priority(&self, op: OpenHomeOp) { ... }
|
||||
/// Vider la file d'attente (ex: nouvelle playlist demandée avant fin de sync)
|
||||
pub fn clear_pending(&self) { ... }
|
||||
/// Attendre que toutes les opérations soient exécutées
|
||||
pub fn wait_completion(&self) { ... }
|
||||
/// Récupérer la dernière erreur (non-bloquant)
|
||||
pub fn take_error(&self) -> Option<ControlPointError> { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**Comportement en cas d'erreur**: vider la file d'attente, signaler l'erreur via `last_error`, invalider les caches OpenHome (forcer re-sync depuis source de vérité au prochain appel).
|
||||
|
||||
**Comportement en cas de `clear_pending()` pendant exécution**: laisser l'opération en cours se terminer (plus sûr — évite de laisser le renderer dans un état inconsistant), vider le reste.
|
||||
|
||||
**Intégration dans `OpenHomeQueue`**: remplacer les appels directs `playlist_client.insert()` / `playlist_client.delete_id()` par des `op_queue.push()`. Les opérations qui ont besoin d'une réponse synchrone (ex: `current_track()`, `queue_snapshot()`) continuent d'appeler directement le `playlist_client` — mais doivent d'abord attendre la complétion de la file (`wait_completion()`).
|
||||
|
||||
---
|
||||
|
||||
### Phase 4 — Throttle des replace_item (2-3h, PRIORITÉ BASSE)
|
||||
|
||||
**Contexte**: `replace_item()` (openhome.rs:1315) fait `delete_id` + `insert` pour mettre à jour une piste. Sur un stream radio qui change de morceau, la durée est mise à jour fréquemment → paires SOAP inutiles car le `metadata_cache` local est déjà la source pour l'UI.
|
||||
|
||||
**Solution**: Ne pas envoyer le `delete_id` + `insert` OpenHome si une mise à jour pour ce `track_id` a déjà été envoyée dans les N dernières secondes. Le `metadata_cache` local est mis à jour immédiatement (pour l'UI), et l'opération OpenHome est différée ou ignorée.
|
||||
|
||||
```rust
|
||||
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
|
||||
let track_id = self.track_ids()?[index];
|
||||
|
||||
// Toujours mettre à jour le cache local immédiatement (pour l'UI)
|
||||
self.cache_metadata(track_id, item.metadata.clone());
|
||||
|
||||
// Throttle: si replace OpenHome récent pour ce track, sauter l'opération SOAP
|
||||
if self.is_recent_replace(track_id, Duration::from_secs(5)) {
|
||||
return Ok(());
|
||||
}
|
||||
self.mark_replace_time(track_id);
|
||||
|
||||
// Opération SOAP (delete + insert)
|
||||
// ... code existant ...
|
||||
}
|
||||
```
|
||||
|
||||
Ajouter `last_replace_times: Mutex<HashMap<u32, SystemTime>>` dans `OpenHomeQueue`.
|
||||
|
||||
---
|
||||
|
||||
## Ordre d'Implémentation
|
||||
|
||||
```
|
||||
Phase 1 → mesurer gain TCP → Phase 2 → mesurer gain ReadList → Phase 3 → Phase 4
|
||||
```
|
||||
|
||||
Tester chaque phase avec des playlists réelles (~1000 tracks) avant de poursuivre. Ne pas combiner les phases pour pouvoir isoler les régressions.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
# Phase 1 — vérifier connexions TCP réutilisées
|
||||
# tcpdump -i lo port 60000 -c 200 (ou port du renderer)
|
||||
# Avant: SYN à chaque appel SOAP
|
||||
# Après: SYN unique, flux keep-alive
|
||||
|
||||
# Phase 2 — vérifier fast path activé
|
||||
# RUST_LOG=debug cargo run ... 2>&1 | grep "fast path"
|
||||
# Cas append 100 → "fast path: AppendOnly, 100 inserts"
|
||||
# Cas delete 100 → "fast path: DeleteFromEnd, 100 deletes"
|
||||
# Cas reorder → "fast path: NeedFullSync, falling back to ReadList+LCS"
|
||||
|
||||
# Phase 3 — vérifier non-blocage
|
||||
# sync_queue() doit retourner en <1ms (les 100 inserts continuent en background)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Contrainte architecturale intégrée: miroir local abandonné (désynchronisation avec autres control points). Toutes les phases respectent OpenHome comme source de vérité unique.*
|
||||
|
||||
*Date: 2026-04-08*
|
||||
642
Blackboard/Todo/enorme_playlist_step3.md
Normal file
642
Blackboard/Todo/enorme_playlist_step3.md
Normal file
@@ -0,0 +1,642 @@
|
||||
** 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) **
|
||||
|
||||
# Async Queue Refresh — Étape 3
|
||||
|
||||
**Contexte**: `refresh_attached_queue_for()` dans `control_point.rs` est appelé de 3 endroits
|
||||
et bloque son thread pendant toute la synchronisation (browse media server + 100+ opérations
|
||||
SOAP/mémoire). L'objectif est de factoriser le mécanisme async au niveau de la couche queue
|
||||
(`MusicQueue`), qui est déjà l'abstraction agnostique du backend. `control_point.rs` ne doit
|
||||
plus connaître les threads ni les tokens d'annulation.
|
||||
|
||||
**Principe architectural**: la couche queue sait *comment* syncer (mémoire ou SOAP) et donc
|
||||
aussi *comment* annuler et *quand* signaler que la lecture peut démarrer. `control_point.rs`
|
||||
sait seulement *quoi* syncer (browse + conversion PlaybackItem). Les deux responsabilités
|
||||
restent séparées.
|
||||
|
||||
---
|
||||
|
||||
## Vue d'ensemble des changements
|
||||
|
||||
```
|
||||
AVANT
|
||||
control_point.rs
|
||||
refresh_attached_queue_for()
|
||||
→ browse()
|
||||
→ sync_queue(items) ← bloquant 1-5s
|
||||
|
||||
APRÈS
|
||||
control_point.rs
|
||||
do_queue_refresh_work() ← interne, fait le browse + conversion
|
||||
MusicQueue (couche queue)
|
||||
schedule_sync(items, callbacks) ← non-bloquant, retourne immédiatement
|
||||
→ thread "queue-sync-{renderer_id}"
|
||||
→ QueueBackend::sync_queue(items, cancel_token, on_ready)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fichiers à modifier / créer
|
||||
|
||||
| Fichier | Action |
|
||||
|---------|--------|
|
||||
| `pmocontrol/src/errors.rs` | Ajouter variante `SyncCancelled` |
|
||||
| `pmocontrol/src/queue/backend.rs` | Modifier signature `sync_queue()` |
|
||||
| `pmocontrol/src/queue/interne.rs` | Adapter signature `sync_queue()` |
|
||||
| `pmocontrol/src/queue/openhome.rs` | Adapter + points de vérification cancel + on_ready |
|
||||
| `pmocontrol/src/queue/music_queue.rs` | Ajouter champs async + méthode `schedule_sync()` |
|
||||
| `pmocontrol/src/queue/mod.rs` | Exporter `SyncScheduleOutcome` |
|
||||
| `pmocontrol/src/model.rs` | Ajouter événements `QueueReadyToPlay`, `QueueSyncCancelled` |
|
||||
| `pmocontrol/src/sse.rs` | Sérialiser les deux nouveaux événements |
|
||||
| `pmocontrol/src/control_point.rs` | Remplacer les 3 call sites bloquants |
|
||||
|
||||
---
|
||||
|
||||
## Étape 1 — Nouvelle variante d'erreur (`errors.rs`)
|
||||
|
||||
**Fichier**: `pmocontrol/src/errors.rs`
|
||||
|
||||
Ajouter après la ligne 51 (`ControlPoint`) :
|
||||
|
||||
```rust
|
||||
#[error("Queue sync cancelled (superseded by a newer request)")]
|
||||
SyncCancelled,
|
||||
```
|
||||
|
||||
Cette variante est retournée par `sync_queue()` quand le `cancel_token` passe à `true`.
|
||||
Elle est **non-fatale** — le coordinator la traite comme un comportement normal, pas une
|
||||
erreur à logger en `warn!`.
|
||||
|
||||
---
|
||||
|
||||
## Étape 2 — Modifier la signature de `sync_queue()` dans le trait (`backend.rs`)
|
||||
|
||||
**Fichier**: `pmocontrol/src/queue/backend.rs`, ligne 110
|
||||
|
||||
```rust
|
||||
// AVANT
|
||||
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError>;
|
||||
|
||||
// APRÈS
|
||||
use std::sync::{Arc, atomic::AtomicBool};
|
||||
|
||||
fn sync_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError>;
|
||||
```
|
||||
|
||||
**Sémantique des paramètres** :
|
||||
- `cancel_token` : si `true` au moment d'une opération, retourner `Err(SyncCancelled)` immédiatement
|
||||
- `on_ready` : callback one-shot appelé quand la lecture peut démarrer (voir logique ci-dessous)
|
||||
|
||||
---
|
||||
|
||||
## Étape 3 — Adapter `InternalQueue::sync_queue()` (`interne.rs`)
|
||||
|
||||
**Fichier**: `pmocontrol/src/queue/interne.rs`
|
||||
|
||||
Trouver la méthode `sync_queue()` et adapter la signature. Le corps reste identique,
|
||||
avec deux ajouts :
|
||||
|
||||
**1. Vérification du pivot (early start)** : si une piste est en cours de lecture
|
||||
(un `current_index` est défini dans le snapshot courant), appeler `on_ready` immédiatement
|
||||
avant toute opération — la piste courante sera préservée.
|
||||
|
||||
**2. Si pas de pivot** (queue vide ou aucune piste en cours) : appeler `on_ready` après
|
||||
avoir inséré le premier item.
|
||||
|
||||
**3. Vérification cancel** : après chaque item inséré/supprimé (en pratique `InternalQueue`
|
||||
est rapide mais le principe doit être cohérent) :
|
||||
|
||||
```rust
|
||||
fn sync_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
mut on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
|
||||
// Early start si pivot présent
|
||||
let has_current = self.current_index()?.is_some();
|
||||
if has_current {
|
||||
if let Some(f) = on_ready.take() { f(); }
|
||||
}
|
||||
|
||||
// ... logique existante de sync_queue() ...
|
||||
// Dans la boucle d'insertions, après le 1er insert :
|
||||
if on_ready.is_some() {
|
||||
if let Some(f) = on_ready.take() { f(); }
|
||||
}
|
||||
// Après chaque opération :
|
||||
if cancel_token.load(SeqCst) {
|
||||
return Err(ControlPointError::SyncCancelled);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Étape 4 — Adapter `OpenHomeQueue::sync_queue()` (`openhome.rs`)
|
||||
|
||||
**Fichier**: `pmocontrol/src/queue/openhome.rs`
|
||||
|
||||
### 4.1 Signature (ligne ~1277)
|
||||
|
||||
```rust
|
||||
fn sync_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
mut on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError>
|
||||
```
|
||||
|
||||
### 4.2 Early start — logique pivot
|
||||
|
||||
Au début de `sync_queue()`, **avant** toute opération SOAP, détecter si un pivot est présent :
|
||||
|
||||
```rust
|
||||
// Après la récupération du snapshot (ligne ~1323), avant les branches if/else :
|
||||
let has_pivot = playing_info.is_some();
|
||||
if has_pivot {
|
||||
// Le pivot sera préservé — on peut démarrer la lecture immédiatement
|
||||
if let Some(f) = on_ready.take() { f(); }
|
||||
}
|
||||
```
|
||||
|
||||
Si pas de pivot (nouvelle playlist via `delete_all` + inserts depuis 0) : appeler `on_ready`
|
||||
après le **1er insert réussi** dans `replace_queue()` et dans `replace_queue_standard_lcs()`.
|
||||
|
||||
### 4.3 Points de vérification cancel
|
||||
|
||||
Ajouter `if cancel_token.load(SeqCst) { return Err(SyncCancelled); }` aux endroits suivants :
|
||||
|
||||
- Dans `delete_marked_items()` (ligne ~458) : après chaque `delete_id_if_exists()`
|
||||
- Dans `rebuild_playlist_section()` (ligne ~494) : après chaque `insert()`
|
||||
- Dans `replace_queue_preserve_current()` (ligne ~419) : après chaque `delete_id_if_exists()` et `insert()`
|
||||
- Dans `replace_queue_standard_lcs()` (ligne ~693-745) : après chaque delete et insert
|
||||
- Dans `replace_queue()` (ligne ~1118) : après chaque insert dans la boucle
|
||||
- Dans le fast path `AppendOnly` (ligne ~1318) : après chaque insert
|
||||
- Dans le fast path `DeleteFromEnd` (ligne ~1336) : après chaque delete
|
||||
|
||||
### 4.4 Propagation du cancel_token aux helpers
|
||||
|
||||
Les méthodes helper privées qui font des boucles doivent recevoir le token :
|
||||
|
||||
```rust
|
||||
fn delete_marked_items(
|
||||
&mut self,
|
||||
old_ids: &[u32],
|
||||
keep_flags: &[bool],
|
||||
position_label: &str,
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
) -> Result<(), ControlPointError>
|
||||
|
||||
fn rebuild_playlist_section(
|
||||
&mut self,
|
||||
// ... params existants ...
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
on_ready: &mut Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<u32, ControlPointError>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Étape 5 — Adapter `MusicQueue` (dispatch enum) (`music_queue.rs`)
|
||||
|
||||
**Fichier**: `pmocontrol/src/queue/music_queue.rs`
|
||||
|
||||
### 5.1 Adapter le dispatch `sync_queue()` (ligne ~98)
|
||||
|
||||
```rust
|
||||
fn sync_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.sync_queue(items, cancel_token, on_ready),
|
||||
MusicQueue::OpenHome(q) => q.sync_queue(items, cancel_token, on_ready),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Ajouter l'état async dans `MusicQueue`
|
||||
|
||||
`MusicQueue` passe de simple enum de dispatch à une struct qui **encapsule** l'enum backend
|
||||
et l'état de synchronisation async :
|
||||
|
||||
```rust
|
||||
// AVANT
|
||||
pub enum MusicQueue {
|
||||
Internal(InternalQueue),
|
||||
OpenHome(OpenHomeQueue),
|
||||
}
|
||||
|
||||
// APRÈS
|
||||
pub struct MusicQueue {
|
||||
backend: MusicQueueBackend,
|
||||
// État async de synchronisation
|
||||
sync_in_progress: Arc<AtomicBool>,
|
||||
sync_pending: Arc<AtomicBool>,
|
||||
sync_cancel_token: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
// L'enum devient privée
|
||||
enum MusicQueueBackend {
|
||||
Internal(InternalQueue),
|
||||
OpenHome(OpenHomeQueue),
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: le changement de `enum` en `struct` implique de mettre à jour toutes les
|
||||
utilisations de `MusicQueue::Internal(...)` et `MusicQueue::OpenHome(...)` dans le reste
|
||||
du code (essentiellement `music_queue.rs` lui-même et `mod.rs`). Les callers externes
|
||||
utilisent `MusicQueue` via `QueueBackend` et `QueueFromRendererInfo` — ils ne sont pas
|
||||
impactés si l'API publique est préservée.
|
||||
|
||||
### 5.3 Enum résultat et méthode `schedule_sync()`
|
||||
|
||||
```rust
|
||||
pub enum SyncScheduleOutcome {
|
||||
/// Thread spawné, sync en cours.
|
||||
Scheduled,
|
||||
/// Sync déjà en cours — annulée et nouvelle sync programmée en pending.
|
||||
AlreadyRunning,
|
||||
}
|
||||
```
|
||||
|
||||
```rust
|
||||
impl MusicQueue {
|
||||
/// Lance une synchronisation asynchrone de la queue.
|
||||
///
|
||||
/// - Si aucune sync n'est en cours : spawne un thread, retourne `Scheduled`.
|
||||
/// - Si une sync est en cours : l'annule, note une sync pending, retourne `AlreadyRunning`.
|
||||
/// Le thread en cours finira l'opération courante, détectera le cancel, puis
|
||||
/// relancera la sync avec les nouveaux items via `pending_items_fn`.
|
||||
///
|
||||
/// `pending_items_fn` : closure appelée dans le worker pour re-fetcher les items
|
||||
/// en cas de pending. Elle doit être Send + 'static car elle s'exécute dans un thread.
|
||||
///
|
||||
/// `on_ready` : appelé dès que la lecture peut démarrer (pivot préservé ou 1er insert).
|
||||
///
|
||||
/// `renderer_id` : utilisé uniquement pour nommer le thread de travail.
|
||||
pub fn schedule_sync(
|
||||
&self, // &self car l'état async est derrière Arc<AtomicBool>
|
||||
renderer_id: &str,
|
||||
items: Vec<PlaybackItem>,
|
||||
pending_items_fn: Box<dyn Fn() -> Result<Vec<PlaybackItem>, ControlPointError> + Send + 'static>,
|
||||
on_ready: Option<Box<dyn FnOnce() + Send + 'static>>,
|
||||
) -> SyncScheduleOutcome
|
||||
```
|
||||
|
||||
**Problème de `&mut self` vs `&self`** : `sync_queue()` dans le trait prend `&mut self`
|
||||
car les backends mutent leur état. Mais `schedule_sync()` veut spawner un thread qui détient
|
||||
le backend. Solution : le backend est déjà derrière `Arc<Mutex<MusicQueue>>` dans
|
||||
`MusicRenderer` (ligne 124 de musicrenderer.rs — c'est le `queue` field). Le thread worker
|
||||
clone cet `Arc` et acquiert le lock pour appeler `sync_queue()`.
|
||||
|
||||
**Signature révisée** :
|
||||
|
||||
```rust
|
||||
/// Doit être appelé avec un Arc<Mutex<Self>> pour permettre le spawn du thread worker.
|
||||
pub fn schedule_sync(
|
||||
queue_arc: &Arc<Mutex<MusicQueue>>,
|
||||
renderer_id: &str,
|
||||
items: Vec<PlaybackItem>,
|
||||
pending_items_fn: Box<dyn Fn() -> Result<Vec<PlaybackItem>, ControlPointError> + Send + 'static>,
|
||||
on_ready: Option<Box<dyn FnOnce() + Send + 'static>>,
|
||||
) -> SyncScheduleOutcome {
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
|
||||
let (sync_in_progress, sync_pending, sync_cancel_token) = {
|
||||
let q = queue_arc.lock().unwrap();
|
||||
(
|
||||
Arc::clone(&q.sync_in_progress),
|
||||
Arc::clone(&q.sync_pending),
|
||||
Arc::clone(&q.sync_cancel_token),
|
||||
)
|
||||
};
|
||||
|
||||
if sync_in_progress.swap(true, SeqCst) {
|
||||
// Sync en cours : annuler et noter pending
|
||||
sync_cancel_token.store(true, SeqCst);
|
||||
sync_pending.store(true, SeqCst);
|
||||
return SyncScheduleOutcome::AlreadyRunning;
|
||||
}
|
||||
|
||||
// Pas de sync en cours : initialiser et spawner
|
||||
sync_cancel_token.store(false, SeqCst);
|
||||
sync_pending.store(false, SeqCst);
|
||||
|
||||
let queue_arc = Arc::clone(queue_arc);
|
||||
let thread_name = format!("queue-sync-{}", renderer_id);
|
||||
|
||||
thread::Builder::new()
|
||||
.name(thread_name)
|
||||
.spawn(move || {
|
||||
// Guard: libère in_progress à la sortie même en cas de panic
|
||||
struct Guard(Arc<AtomicBool>);
|
||||
impl Drop for Guard {
|
||||
fn drop(&mut self) { self.0.store(false, SeqCst); }
|
||||
}
|
||||
let _guard = Guard(Arc::clone(&sync_in_progress));
|
||||
|
||||
let mut current_items = items;
|
||||
let mut current_on_ready = Some(on_ready);
|
||||
|
||||
loop {
|
||||
sync_pending.store(false, SeqCst);
|
||||
sync_cancel_token.store(false, SeqCst);
|
||||
|
||||
let result = {
|
||||
let mut q = queue_arc.lock().unwrap();
|
||||
q.backend.sync_queue(
|
||||
current_items,
|
||||
&sync_cancel_token,
|
||||
current_on_ready.take().flatten(),
|
||||
)
|
||||
};
|
||||
|
||||
match result {
|
||||
Err(ControlPointError::SyncCancelled) => {
|
||||
// Annulé normalement — vérifier si pending
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("queue-sync error: {}", e);
|
||||
}
|
||||
Ok(()) => {}
|
||||
}
|
||||
|
||||
if !sync_pending.load(SeqCst) {
|
||||
break; // Pas de nouvelle sync en attente → terminer
|
||||
}
|
||||
|
||||
// Nouvelle sync demandée pendant l'exécution → re-fetcher et relancer
|
||||
match pending_items_fn() {
|
||||
Ok(new_items) => {
|
||||
current_items = new_items;
|
||||
current_on_ready = Some(None); // pas de on_ready pour les re-syncs
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("queue-sync pending re-fetch error: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// _guard libère sync_in_progress = false
|
||||
})
|
||||
.expect("Failed to spawn queue-sync thread");
|
||||
|
||||
SyncScheduleOutcome::Scheduled
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Étape 6 — Nouveaux événements SSE
|
||||
|
||||
### 6.1 `model.rs`
|
||||
|
||||
Localiser l'enum `RendererEvent` et ajouter :
|
||||
|
||||
```rust
|
||||
/// Émis dès que la queue peut être lue (pivot préservé ou 1er track inséré).
|
||||
QueueReadyToPlay {
|
||||
id: DeviceId,
|
||||
},
|
||||
/// Émis quand une sync est annulée car une nouvelle a été demandée.
|
||||
QueueSyncCancelled {
|
||||
id: DeviceId,
|
||||
},
|
||||
```
|
||||
|
||||
### 6.2 `sse.rs`
|
||||
|
||||
Localiser le match de sérialisation des `RendererEvent` et ajouter les deux variantes.
|
||||
Suivre le pattern existant de `QueueRefreshing` (ligne ~152) :
|
||||
|
||||
```rust
|
||||
RendererEvent::QueueReadyToPlay { id } => {
|
||||
// sérialiser avec type = "queue_ready_to_play"
|
||||
}
|
||||
RendererEvent::QueueSyncCancelled { id } => {
|
||||
// sérialiser avec type = "queue_sync_cancelled"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Étape 7 — Modifier `control_point.rs`
|
||||
|
||||
### 7.1 Extraire la logique de browse
|
||||
|
||||
Renommer `refresh_attached_queue_for()` en deux fonctions :
|
||||
|
||||
**`fetch_queue_items_for()`** (nouvelle, interne) : fait le browse + conversion, retourne
|
||||
`Vec<PlaybackItem>`. C'est la `pending_items_fn` passée à `schedule_sync()`.
|
||||
|
||||
**`schedule_queue_refresh_for()`** (remplace l'ancienne) : appelle `fetch_queue_items_for()`,
|
||||
puis `MusicQueue::schedule_sync()`.
|
||||
|
||||
```rust
|
||||
fn fetch_queue_items_for(
|
||||
registry: &Arc<RwLock<DeviceRegistry>>,
|
||||
renderer_id: &DeviceId,
|
||||
) -> Result<Vec<PlaybackItem>, ControlPointError> {
|
||||
// Browse media server + conversion PlaybackItem
|
||||
// (logique actuellement dans refresh_attached_queue_for() lignes ~1593-1670)
|
||||
}
|
||||
|
||||
fn schedule_queue_refresh_for(
|
||||
registry: &Arc<RwLock<DeviceRegistry>>,
|
||||
renderer_id: &DeviceId,
|
||||
event_bus: &RendererEventBus,
|
||||
auto_play_cb: Option<Box<dyn FnOnce(&DeviceId) -> Result<(), ControlPointError> + Send + 'static>>,
|
||||
) -> SyncScheduleOutcome {
|
||||
let items = match fetch_queue_items_for(registry, renderer_id) {
|
||||
Ok(items) => items,
|
||||
Err(e) => { warn!(...); return SyncScheduleOutcome::Scheduled; /* ou erreur */ }
|
||||
};
|
||||
|
||||
// on_ready : déclenche auto_play si demandé + émet QueueReadyToPlay SSE
|
||||
let rid = renderer_id.clone();
|
||||
let bus = event_bus.clone();
|
||||
let on_ready: Option<Box<dyn FnOnce() + Send + 'static>> = Some(Box::new(move || {
|
||||
bus.broadcast(RendererEvent::QueueReadyToPlay { id: rid.clone() });
|
||||
if let Some(cb) = auto_play_cb {
|
||||
if let Err(e) = cb(&rid) {
|
||||
warn!("auto-play callback failed: {}", e);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// pending_items_fn : re-fetcher depuis le media server si pending
|
||||
let registry2 = Arc::clone(registry);
|
||||
let rid2 = renderer_id.clone();
|
||||
let pending_fn = Box::new(move || fetch_queue_items_for(®istry2, &rid2));
|
||||
|
||||
// Récupérer l'Arc<Mutex<MusicQueue>> du renderer depuis le registry
|
||||
let queue_arc = {
|
||||
let reg = registry.read().unwrap();
|
||||
reg.get_renderer_queue_arc(renderer_id)? // méthode à ajouter dans DeviceRegistry
|
||||
};
|
||||
|
||||
// Émettre QueueRefreshing avant de lancer
|
||||
event_bus.broadcast(RendererEvent::QueueRefreshing { id: renderer_id.clone() });
|
||||
|
||||
let outcome = MusicQueue::schedule_sync(&queue_arc, &renderer_id.0, items, pending_fn, on_ready);
|
||||
|
||||
if matches!(outcome, SyncScheduleOutcome::AlreadyRunning) {
|
||||
event_bus.broadcast(RendererEvent::QueueSyncCancelled { id: renderer_id.clone() });
|
||||
}
|
||||
|
||||
outcome
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 Call site 1 — thread "cp-media-server-event-worker" (l.270)
|
||||
|
||||
```rust
|
||||
// AVANT
|
||||
let _ = refresh_attached_queue_for(®istry, &renderer_id, &event_bus, None);
|
||||
// APRÈS
|
||||
schedule_queue_refresh_for(®istry, &renderer_id, &event_bus, None);
|
||||
```
|
||||
|
||||
### 7.3 Call site 2 — thread "cp-playlist-periodic-refresh" (l.340)
|
||||
|
||||
```rust
|
||||
// AVANT
|
||||
let _ = refresh_attached_queue_for(®istry_for_periodic, &renderer_id, &event_bus_for_periodic, None);
|
||||
// APRÈS
|
||||
schedule_queue_refresh_for(®istry_for_periodic, &renderer_id, &event_bus_for_periodic, None);
|
||||
```
|
||||
|
||||
### 7.4 Call site 3 — `attach_queue_to_playlist_async()` (l.1264)
|
||||
|
||||
```rust
|
||||
pub async fn attach_queue_to_playlist_async(
|
||||
&self, renderer_id: &DeviceId, server_id: &DeviceId, container_id: &str, auto_play: bool,
|
||||
) -> Result<(), ControlPointError> {
|
||||
// 1. Enregistrer la liaison (synchrone, ~1ms)
|
||||
self.registry.write().unwrap()
|
||||
.set_playlist_binding(renderer_id, server_id, container_id);
|
||||
|
||||
// 2. Construire le callback auto-play si demandé
|
||||
let cp = self.clone();
|
||||
let rid = renderer_id.clone();
|
||||
let auto_play_cb: Option<Box<dyn FnOnce(&DeviceId) -> Result<(), ControlPointError> + Send + 'static>> =
|
||||
if auto_play {
|
||||
Some(Box::new(move |id| cp.play_current_from_queue(id)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// 3. Lancer le refresh async — retourne immédiatement
|
||||
schedule_queue_refresh_for(&self.registry, renderer_id, &self.event_bus, auto_play_cb);
|
||||
|
||||
Ok(()) // La webapp sera notifiée via SSE (QueueRefreshing → QueueReadyToPlay → QueueUpdated)
|
||||
}
|
||||
```
|
||||
|
||||
### 7.5 Fin de sync — émettre `QueueUpdated`
|
||||
|
||||
L'événement `QueueUpdated` (avec `queue_length`) est actuellement émis à la ligne ~1718
|
||||
dans `refresh_attached_queue_for()`. Il doit être émis à la fin du worker thread dans
|
||||
`MusicQueue::schedule_sync()`, après le `sync_queue()` réussi.
|
||||
|
||||
Passer un `on_complete` callback à `schedule_sync()` (en plus de `on_ready`) :
|
||||
|
||||
```rust
|
||||
pub fn schedule_sync(
|
||||
queue_arc: &Arc<Mutex<MusicQueue>>,
|
||||
renderer_id: &str,
|
||||
items: Vec<PlaybackItem>,
|
||||
pending_items_fn: Box<dyn Fn() -> Result<Vec<PlaybackItem>, ControlPointError> + Send + 'static>,
|
||||
on_ready: Option<Box<dyn FnOnce() + Send + 'static>>,
|
||||
on_complete: Box<dyn Fn(usize) + Send + 'static>, // NOUVEAU — reçoit queue_length
|
||||
) -> SyncScheduleOutcome
|
||||
```
|
||||
|
||||
Dans le worker, après `Ok(())` du `sync_queue()` :
|
||||
|
||||
```rust
|
||||
Ok(()) => {
|
||||
let queue_len = queue_arc.lock().unwrap().len().unwrap_or(0);
|
||||
on_complete(queue_len);
|
||||
}
|
||||
```
|
||||
|
||||
Dans `schedule_queue_refresh_for()` :
|
||||
|
||||
```rust
|
||||
let bus3 = event_bus.clone();
|
||||
let rid3 = renderer_id.clone();
|
||||
let on_complete = Box::new(move |queue_len: usize| {
|
||||
bus3.broadcast(RendererEvent::QueueUpdated {
|
||||
id: rid3.clone(),
|
||||
queue_length: queue_len,
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Étape 8 — Accès à `Arc<Mutex<MusicQueue>>` depuis le registry
|
||||
|
||||
`schedule_queue_refresh_for()` a besoin d'accéder à l'`Arc<Mutex<MusicQueue>>` du renderer.
|
||||
Localiser dans `DeviceRegistry` comment les renderers et leurs queues sont stockés, et ajouter
|
||||
une méthode :
|
||||
|
||||
```rust
|
||||
pub fn get_renderer_queue_arc(
|
||||
&self,
|
||||
renderer_id: &DeviceId,
|
||||
) -> Result<Arc<Mutex<MusicQueue>>, ControlPointError>
|
||||
```
|
||||
|
||||
(Ou équivalent selon la structure réelle du registry.)
|
||||
|
||||
---
|
||||
|
||||
## Ordre d'implémentation
|
||||
|
||||
```
|
||||
Étape 1 (errors.rs) ← 5 min
|
||||
Étape 2 (backend.rs) ← 10 min, casse la compilation → à faire avant les autres
|
||||
Étape 3 (interne.rs) ← 30 min
|
||||
Étape 4 (openhome.rs) ← 1-2h (nombreux points de vérification cancel)
|
||||
Étape 5 (music_queue.rs) ← 2-3h (changement struct + schedule_sync)
|
||||
Étape 6 (model.rs + sse.rs) ← 30 min
|
||||
Étape 7 (control_point.rs) ← 1h
|
||||
Étape 8 (registry) ← 30 min selon la structure
|
||||
```
|
||||
|
||||
Après l'étape 2, `cargo build` cassera jusqu'à l'étape 5 incluse — c'est attendu.
|
||||
Faire les étapes 3, 4, 5 dans la même session sans interrompre.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
cargo build -p pmocontrol
|
||||
|
||||
# Vérifier les scénarios :
|
||||
# 1. Attach playlist → QueueRefreshing SSE immédiat, QueueReadyToPlay après 1er insert,
|
||||
# QueueUpdated après fin complète
|
||||
# 2. Attach 2e playlist pendant sync en cours → QueueSyncCancelled + nouveau refresh repart
|
||||
# 3. Piste en cours de lecture pendant sync → QueueReadyToPlay immédiat (pivot préservé)
|
||||
# 4. Refresh périodique (60s) ne bloque plus son thread
|
||||
|
||||
RUST_LOG=debug cargo run 2>&1 | grep -E "queue.sync|SyncCancelled|Scheduled|AlreadyRunning|on_ready|on_complete"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Date: 2026-04-09*
|
||||
338
Blackboard/Todo/frontend_enorme_playlist.md
Normal file
338
Blackboard/Todo/frontend_enorme_playlist.md
Normal file
@@ -0,0 +1,338 @@
|
||||
** 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) **
|
||||
|
||||
## Contexte et symptôme
|
||||
|
||||
Le frontend Vue.js du control point PMOMusic (`pmoapp/webapp`) présente des problèmes de
|
||||
performance significatifs avec des playlists de ~1000 titres. La plupart des lenteurs sont
|
||||
côté UI, indépendamment des optimisations déjà réalisées côté Rust/backend.
|
||||
|
||||
Les trois manifestations observées :
|
||||
- **Freeze au scroll** dans la file d'attente (QueueViewer) dès ~200 items
|
||||
- **Blocage UI temporaire** à l'ouverture d'une playlist dans PlayListManager
|
||||
- **Refetches JSON répétés** déclenchés par les événements SSE `queue_updated`
|
||||
|
||||
## Causes racines identifiées
|
||||
|
||||
### P0 — Pas de virtualisation dans `QueueViewer.vue`
|
||||
|
||||
**Fichier** : `src/components/pmocontrol/QueueViewer.vue:85-93`
|
||||
|
||||
```vue
|
||||
<!-- Tous les items rendus en DOM simultanément -->
|
||||
<div v-if="queue?.items.length" class="queue-list" ref="queueContainer">
|
||||
<QueueItem
|
||||
v-for="item in queue.items"
|
||||
:key="item.index"
|
||||
...
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
Pour 1 000 titres : 1 000 nœuds DOM permanents, chacun contenant une image, un composable
|
||||
réactif (`useCoverImage`), et des computed properties. Le scroll devient impossible.
|
||||
|
||||
**Ironie** : `vue-virtual-scroller@^2.0.0-beta.8` est présent dans `package.json` mais
|
||||
**n'est utilisé nulle part** dans la codebase — manifestement prévu puis abandonné.
|
||||
|
||||
### P1 — Pas de virtualisation dans `PlayListManager.vue`
|
||||
|
||||
**Fichier** : `src/components/PlayListManager.vue:562-724`
|
||||
|
||||
```vue
|
||||
<div class="track-grid" v-if="sortedTracks.length > 0">
|
||||
<article v-for="track in sortedTracks" :key="..." class="track-card">
|
||||
<!-- image + title + artist + album + durée + bitrate + samplerate -->
|
||||
</article>
|
||||
</div>
|
||||
```
|
||||
|
||||
Même problème : grid CSS avec 1 000 articles et leurs images (`loading="lazy"`).
|
||||
|
||||
Aggravé par la computed `sortedTracks` (ligne 883-890) :
|
||||
```typescript
|
||||
const sortedTracks = computed(() => {
|
||||
return [...detail.tracks].sort( // copie complète du tableau
|
||||
(a, b) => new Date(b.added_at).getTime() - new Date(a.added_at).getTime()
|
||||
);
|
||||
});
|
||||
```
|
||||
Et `lazyTracksCount` (ligne 892-897) qui filtre les 1 000 items à chaque re-render.
|
||||
|
||||
### P2 — `queue_updated` force un refetch JSON complet
|
||||
|
||||
**Fichier** : `src/composables/useRenderers.ts:200-206`
|
||||
|
||||
```typescript
|
||||
case "queue_updated":
|
||||
snapshot.state.queue_len = event.queue_length;
|
||||
queueRefreshingIds.delete(rendererId);
|
||||
// Pour la queue complète, on doit refetch
|
||||
void fetchRendererSnapshot(rendererId, { force: true });
|
||||
break;
|
||||
```
|
||||
|
||||
`fetchRendererSnapshot()` appelle `api.getRendererFullSnapshot(rendererId)` qui retourne
|
||||
le snapshot complet incluant **tous les items de la queue avec leurs métadonnées**.
|
||||
|
||||
Pour 1 000 titres, le payload JSON peut atteindre plusieurs centaines de Ko. Si l'utilisateur
|
||||
charge une playlist de 1 000 titres depuis un serveur qui émet les items par batch (ex. 64
|
||||
par 64 côté Rust), l'événement `queue_updated` est émis plusieurs fois de suite, déclenchant
|
||||
autant de refetches consécutifs du même JSON complet.
|
||||
|
||||
**Note** : `loadingIds.has(rendererId)` (ligne 354) déduplique les requêtes simultanées,
|
||||
mais pas les requêtes consécutives rapprochées.
|
||||
|
||||
### P3 — Infinite scroll accumule toutes les pages en mémoire (`MediaBrowser`)
|
||||
|
||||
**Fichier** : `src/composables/useMediaServers.ts:200-204`
|
||||
|
||||
```typescript
|
||||
// Accumuler les nouvelles entrées — jamais purgées
|
||||
state.entries.push(...data.entries)
|
||||
```
|
||||
|
||||
En parcourant un serveur contenant 1 000 titres, toutes les pages de 50 items
|
||||
s'accumulent dans `browseCache` sans jamais être libérées. Résultat : après un scroll
|
||||
complet, 1 000 entrées sont en mémoire ET en DOM simultanément.
|
||||
|
||||
### P4 — `scrollIntoView` sur 1 000 nœuds DOM non virtualisés
|
||||
|
||||
**Fichier** : `src/components/pmocontrol/QueueViewer.vue:27-48`
|
||||
|
||||
```typescript
|
||||
watch(() => queue.value?.current_index, async (currentIndex) => {
|
||||
await nextTick();
|
||||
const currentItem = queueContainer.value.querySelector(".queue-item.current");
|
||||
if (currentItem) {
|
||||
currentItem.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
}
|
||||
}, { immediate: true });
|
||||
```
|
||||
|
||||
`querySelector` sur un conteneur de 1 000 nœuds + animation CSS `smooth` provoque un
|
||||
layout thrashing. Ce watcher est aussi déclenché au montage (`immediate: true`), ce qui
|
||||
peut provoquer un re-layout au chargement initial de la page.
|
||||
|
||||
## Ce qui fonctionne déjà correctement
|
||||
|
||||
- **Déduplication des snapshots simultanés** : `loadingIds.has(rendererId)` évite les
|
||||
requêtes parallèles pour le même renderer — à préserver.
|
||||
- **`loading="lazy"` sur les images** dans PlayListManager — efficace une fois que le DOM
|
||||
est virtualisé.
|
||||
- **Cache du browse** avec invalidation par conteneur SSE — architecture correcte.
|
||||
- **`useCoverImage`** avec retry exponentiel et cleanup — à conserver tel quel dans
|
||||
`QueueItem.vue`.
|
||||
- **Connexion SSE unique** partagée entre tous les composants — bonne architecture.
|
||||
|
||||
## Plan d'exécution
|
||||
|
||||
### Répertoire concerné : `pmoapp/webapp`
|
||||
|
||||
---
|
||||
|
||||
### Étape 1 — Virtualiser la file d'attente dans `QueueViewer.vue`
|
||||
|
||||
**Fichier** : `src/components/pmocontrol/QueueViewer.vue`
|
||||
|
||||
`vue-virtual-scroller` est déjà installé. Remplacer le `v-for` nu par `<RecycleScroller>` :
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { RecycleScroller } from 'vue-virtual-scroller';
|
||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
|
||||
// ... imports existants inchangés
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Remplacer le div.queue-list + v-for par : -->
|
||||
<RecycleScroller
|
||||
v-if="queue?.items.length"
|
||||
class="queue-list"
|
||||
:items="queue.items"
|
||||
:item-size="64"
|
||||
key-field="index"
|
||||
v-slot="{ item }"
|
||||
ref="queueContainer"
|
||||
>
|
||||
<QueueItem
|
||||
:item="item"
|
||||
:is-current="item.index === queue.current_index"
|
||||
@click="handleItemClick"
|
||||
/>
|
||||
</RecycleScroller>
|
||||
</template>
|
||||
```
|
||||
|
||||
`item-size="64"` correspond à la hauteur CSS actuelle de `.queue-item` (padding +
|
||||
cover 48px + gap). À ajuster si le CSS change.
|
||||
|
||||
**Adapter `scrollIntoView`** : `RecycleScroller` expose une méthode `scrollToItem(index)`.
|
||||
Remplacer le `querySelector` + `scrollIntoView` par :
|
||||
|
||||
```typescript
|
||||
watch(() => queue.value?.current_index, async (currentIndex) => {
|
||||
if (currentIndex !== null && currentIndex !== undefined && queueContainer.value) {
|
||||
await nextTick();
|
||||
queueContainer.value.scrollToItem(currentIndex);
|
||||
}
|
||||
}, { immediate: true });
|
||||
```
|
||||
|
||||
**Fonctionnalité préservée** : `QueueItem.vue` reste inchangé — `RecycleScroller` recycle
|
||||
les nœuds DOM au lieu de les créer tous, mais les props passées à chaque item sont
|
||||
identiques.
|
||||
|
||||
---
|
||||
|
||||
### Étape 2 — Débouncer les refetches `queue_updated`
|
||||
|
||||
**Fichier** : `src/composables/useRenderers.ts:200-206`
|
||||
|
||||
Le problème : `queue_updated` arrive N fois de suite pendant le chargement d'une grande
|
||||
playlist, déclenchant N refetches.
|
||||
|
||||
Ajouter un debounce par renderer sur l'appel à `fetchRendererSnapshot` :
|
||||
|
||||
```typescript
|
||||
// Map des timers de debounce par renderer (à déclarer en module scope)
|
||||
const queueUpdateDebounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const QUEUE_UPDATE_DEBOUNCE_MS = 300;
|
||||
|
||||
// Dans le case "queue_updated" :
|
||||
case "queue_updated":
|
||||
snapshot.state.queue_len = event.queue_length;
|
||||
queueRefreshingIds.delete(rendererId);
|
||||
|
||||
// Annuler le timer précédent pour ce renderer
|
||||
const existingTimer = queueUpdateDebounceTimers.get(rendererId);
|
||||
if (existingTimer) clearTimeout(existingTimer);
|
||||
|
||||
// Programmer un seul fetch après stabilisation
|
||||
queueUpdateDebounceTimers.set(rendererId, setTimeout(() => {
|
||||
queueUpdateDebounceTimers.delete(rendererId);
|
||||
void fetchRendererSnapshot(rendererId, { force: true });
|
||||
}, QUEUE_UPDATE_DEBOUNCE_MS));
|
||||
break;
|
||||
```
|
||||
|
||||
**Fonctionnalité préservée** : Si un seul `queue_updated` arrive (cas normal), le refetch
|
||||
est simplement retardé de 300 ms — imperceptible. Si N arrivent en rafale (chargement
|
||||
d'une grande playlist), un seul refetch est déclenché à la fin.
|
||||
|
||||
**Contrainte** : Ne pas dépasser 500 ms de debounce — l'indicateur `queueRefreshing` dans
|
||||
l'UI doit se désactiver rapidement après la fin du chargement.
|
||||
|
||||
---
|
||||
|
||||
### Étape 3 — Virtualiser la grille dans `PlayListManager.vue`
|
||||
|
||||
**Fichier** : `src/components/PlayListManager.vue`
|
||||
|
||||
La grille CSS ne peut pas être virtualisée directement avec `RecycleScroller` (liste 1D).
|
||||
Remplacer la grid par une liste virtualisée, ou introduire une pagination côté client :
|
||||
|
||||
```typescript
|
||||
const PAGE_SIZE = 100;
|
||||
const currentPage = ref(0);
|
||||
|
||||
const paginatedTracks = computed(() =>
|
||||
sortedTracks.value.slice(
|
||||
currentPage.value * PAGE_SIZE,
|
||||
(currentPage.value + 1) * PAGE_SIZE
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
Avec des boutons de navigation Précédent / Suivant et un indicateur de page.
|
||||
|
||||
**Optimiser `sortedTracks`** : mémoriser le résultat par `playlist.id` pour éviter
|
||||
la copie+tri à chaque re-render non lié à la playlist :
|
||||
|
||||
```typescript
|
||||
const sortedTracksCache = new Map<string, TrackEntry[]>();
|
||||
|
||||
const sortedTracks = computed(() => {
|
||||
const detail = selectedPlaylist.value;
|
||||
if (!detail) return [];
|
||||
const cached = sortedTracksCache.get(detail.id);
|
||||
if (cached && cached.length === detail.tracks.length) return cached;
|
||||
const sorted = [...detail.tracks].sort(
|
||||
(a, b) => new Date(b.added_at).getTime() - new Date(a.added_at).getTime()
|
||||
);
|
||||
sortedTracksCache.set(detail.id, sorted);
|
||||
return sorted;
|
||||
});
|
||||
```
|
||||
|
||||
**Simplifier `lazyTracksCount`** : le dériver de `sortedTracks` pour ne pas parcourir
|
||||
le tableau original en parallèle :
|
||||
|
||||
```typescript
|
||||
const lazyTracksCount = computed(() =>
|
||||
sortedTracks.value.filter(isLazyTrack).length
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Étape 4 — Limiter l'accumulation dans le browse infini (`MediaBrowser`)
|
||||
|
||||
**Fichier** : `src/composables/useMediaServers.ts:183-212`
|
||||
|
||||
Implémenter une fenêtre glissante dans `browseCache` : conserver seulement les 200
|
||||
derniers items en mémoire :
|
||||
|
||||
```typescript
|
||||
const BROWSE_WINDOW_SIZE = 200;
|
||||
|
||||
async function loadMoreBrowse(serverId: string, containerId: string) {
|
||||
// ... code existant jusqu'à la récupération de data ...
|
||||
|
||||
// Remplacer : state.entries.push(...data.entries)
|
||||
// Par :
|
||||
const combined = [...state.entries, ...data.entries];
|
||||
state.entries = combined.slice(-BROWSE_WINDOW_SIZE);
|
||||
state.total_count = data.total_count;
|
||||
state.currentOffset = (state.currentOffset ?? 0) + data.entries.length;
|
||||
state.hasMore = state.currentOffset < state.total_count;
|
||||
browseCache.value.set(key, { ...state });
|
||||
}
|
||||
```
|
||||
|
||||
**Invariant à préserver** : `state.currentOffset` et `state.hasMore` doivent continuer
|
||||
de refléter la position réelle dans la liste serveur, indépendamment de ce qui est
|
||||
affiché — leur logique ne change pas.
|
||||
|
||||
---
|
||||
|
||||
## Ordre d'exécution
|
||||
|
||||
1. **Étape 1** — Virtualisation `QueueViewer` (impact le plus visible, composant le plus simple)
|
||||
2. **Étape 2** — Debounce `queue_updated` (élimine les refetches en cascade, changement minimal)
|
||||
3. **Étape 3** — Optimisation `PlayListManager` (plus complexe, composant de 2039 lignes)
|
||||
4. **Étape 4** — Fenêtre glissante `MediaBrowser` (amélioration mémoire, moins critique)
|
||||
|
||||
## Périmètre : ce qui ne change pas
|
||||
|
||||
- `QueueItem.vue` : aucune modification (recycling géré par le parent)
|
||||
- `useCoverImage.ts` : aucune modification (lazy loading + retry déjà corrects)
|
||||
- `useSSE.ts` : aucune modification (connexion unique, bonne architecture)
|
||||
- `loadingIds` dans `fetchRendererSnapshot` : déduplication conservée
|
||||
- `api.getRendererFullSnapshot` : le payload reste complet, pas de pagination API
|
||||
- Tous les événements SSE autres que `queue_updated` : aucune modification
|
||||
|
||||
## Tests recommandés
|
||||
|
||||
Demander à l'humain de :
|
||||
|
||||
```bash
|
||||
cd pmoapp/webapp
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Puis tester manuellement :
|
||||
- Ouvrir la file d'attente d'un renderer OpenHome avec 1 000 titres : scroll fluide ?
|
||||
- Vérifier que la piste courante est visible au changement de piste (`scrollToItem`)
|
||||
- Charger une playlist de 1 000 titres via PlayListManager : absence de blocage ?
|
||||
- Observer les requêtes réseau dans DevTools lors du chargement d'une grande playlist :
|
||||
un seul `GET /renderers/{id}/full` doit être émis après la fin du chargement
|
||||
306
Blackboard/Todo/mdns_sd_migration.md
Normal file
306
Blackboard/Todo/mdns_sd_migration.md
Normal file
@@ -0,0 +1,306 @@
|
||||
# Migration `mdns` → `mdns-sd`
|
||||
|
||||
## Contexte
|
||||
|
||||
La crate `mdns 3.0.0` (dernière version, 2021, abandonnée) s'appuie sur `dns-parser 0.8.0`
|
||||
qui rejette les labels DNS non-ASCII avec `LabelIsNotAscii`. Les appareils Apple utilisent
|
||||
U+2019 (RIGHT SINGLE QUOTATION MARK) dans leurs noms (ex : "Sophie's MacBook Air"), ce qui
|
||||
spamme les logs en WARN à chaque paquet mDNS reçu.
|
||||
|
||||
`mdns-sd 0.19` (avril 2026, activement maintenu) gère l'UTF-8 correctement, fournit une API
|
||||
de plus haut niveau (service pré-assemblé), et ne nécessite pas `async-std`.
|
||||
|
||||
## Périmètre
|
||||
|
||||
Deux fichiers à modifier, un fichier à nettoyer :
|
||||
|
||||
| Fichier | Rôle |
|
||||
|---|---|
|
||||
| `pmocontrol/Cargo.toml` | Dépendances |
|
||||
| `pmocontrol/src/control_point.rs` | Thread de découverte mDNS (lignes 145–195) |
|
||||
| `pmocontrol/src/discovery/chromecast_discovery.rs` | Parsing des réponses mDNS |
|
||||
| `pmoserver/src/logs/mod.rs` | Filtre de bruit `mdns=error` devenu inutile |
|
||||
|
||||
---
|
||||
|
||||
## Étape 1 — `pmocontrol/Cargo.toml`
|
||||
|
||||
### Supprimer
|
||||
```toml
|
||||
mdns = "3.0"
|
||||
```
|
||||
|
||||
Vérifier si `async-std` et `futures-util` sont utilisés **ailleurs** que dans le thread mDNS.
|
||||
D'après l'analyse :
|
||||
- `async_std` : uniquement `control_point.rs:161` → **supprimer**
|
||||
- `futures-util` : uniquement `control_point.rs:150,167` → **supprimer**
|
||||
|
||||
### Ajouter
|
||||
```toml
|
||||
mdns-sd = "0.19"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Étape 2 — `pmocontrol/src/control_point.rs`
|
||||
|
||||
### Code actuel (lignes 145–195) à remplacer intégralement
|
||||
|
||||
```rust
|
||||
// Thread de découverte mDNS pour Chromecast
|
||||
let registry_for_mdns = Arc::clone(®istry);
|
||||
let udn_cache_for_mdns = Arc::clone(&udn_cache);
|
||||
thread::spawn(move || {
|
||||
use crate::discovery::ChromecastDiscoveryManager;
|
||||
use futures_util::StreamExt;
|
||||
|
||||
let mut discovery_manager =
|
||||
ChromecastDiscoveryManager::new(registry_for_mdns, udn_cache_for_mdns);
|
||||
|
||||
debug!("Starting mDNS discovery thread for Chromecast devices");
|
||||
|
||||
const SERVICE_NAME: &str = "_googlecast._tcp.local";
|
||||
|
||||
async_std::task::block_on(async {
|
||||
match mdns::discover::all(SERVICE_NAME, Duration::from_secs(15)) {
|
||||
Ok(discovery) => {
|
||||
let stream = discovery.listen();
|
||||
futures_util::pin_mut!(stream);
|
||||
debug!("mDNS discovery stream started for Chromecast devices");
|
||||
while let Some(result) = stream.next().await {
|
||||
match result {
|
||||
Ok(response) => {
|
||||
debug!("Received mDNS response with {} records", response.records().count());
|
||||
discovery_manager.handle_mdns_response(response);
|
||||
}
|
||||
Err(e) => { warn!("mDNS discovery error: {}", e); }
|
||||
}
|
||||
}
|
||||
warn!("mDNS discovery stream ended unexpectedly");
|
||||
}
|
||||
Err(e) => { error!("Failed to start mDNS discovery: {}", e); }
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Nouveau code
|
||||
|
||||
```rust
|
||||
// Thread de découverte mDNS pour Chromecast
|
||||
let registry_for_mdns = Arc::clone(®istry);
|
||||
let udn_cache_for_mdns = Arc::clone(&udn_cache);
|
||||
thread::spawn(move || {
|
||||
use crate::discovery::ChromecastDiscoveryManager;
|
||||
use mdns_sd::{ServiceDaemon, ServiceEvent};
|
||||
|
||||
let mut discovery_manager =
|
||||
ChromecastDiscoveryManager::new(registry_for_mdns, udn_cache_for_mdns);
|
||||
|
||||
debug!("Starting mDNS discovery thread for Chromecast devices");
|
||||
|
||||
// Note: mdns-sd requires the trailing dot in the service type
|
||||
const SERVICE_TYPE: &str = "_googlecast._tcp.local.";
|
||||
|
||||
let daemon = match ServiceDaemon::new() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
error!("Failed to create mDNS daemon: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let receiver = match daemon.browse(SERVICE_TYPE) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
error!("Failed to start mDNS browse for {}: {}", SERVICE_TYPE, e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
debug!("mDNS discovery started for Chromecast devices");
|
||||
|
||||
while let Ok(event) = receiver.recv() {
|
||||
match event {
|
||||
ServiceEvent::ServiceResolved(info) => {
|
||||
debug!(
|
||||
fullname = info.get_fullname(),
|
||||
host = info.get_hostname(),
|
||||
port = info.get_port(),
|
||||
"mDNS Chromecast service resolved"
|
||||
);
|
||||
discovery_manager.handle_service_resolved(&info);
|
||||
}
|
||||
ServiceEvent::ServiceRemoved(_service_type, fullname) => {
|
||||
debug!(fullname = %fullname, "mDNS Chromecast service removed");
|
||||
// Pas de retrait actif du registre : le timeout habituel s'en charge
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
warn!("mDNS discovery receiver closed");
|
||||
});
|
||||
```
|
||||
|
||||
### Points d'attention
|
||||
- Le point final `.` dans `"_googlecast._tcp.local."` est **obligatoire** pour `mdns-sd`.
|
||||
- `receiver.recv()` est bloquant synchrone — pas besoin d'async runtime.
|
||||
- `ServiceDaemon` gère son propre thread interne ; inutile de relancer manuellement.
|
||||
|
||||
---
|
||||
|
||||
## Étape 3 — `pmocontrol/src/discovery/chromecast_discovery.rs`
|
||||
|
||||
### Supprimer l'import `mdns`
|
||||
|
||||
```rust
|
||||
// Supprimer ces uses implicites via le type dans la signature
|
||||
use std::collections::HashMap; // <- plus nécessaire si on passe par TxtProperties
|
||||
```
|
||||
|
||||
### Ajouter l'import `mdns-sd`
|
||||
|
||||
```rust
|
||||
use mdns_sd::ServiceInfo;
|
||||
```
|
||||
|
||||
### Remplacer `handle_mdns_response` par `handle_service_resolved`
|
||||
|
||||
#### Code actuel (lignes 39–179) — ~80 lignes de parsing manuel
|
||||
|
||||
Toute la logique d'extraction PTR / A / AAAA / SRV / TXT disparaît.
|
||||
|
||||
#### Nouveau code
|
||||
|
||||
```rust
|
||||
/// Traite un service Chromecast résolu par mDNS-SD.
|
||||
///
|
||||
/// `ServiceInfo` arrive pré-assemblé : plus besoin de jointure manuelle
|
||||
/// des enregistrements PTR / A / SRV / TXT.
|
||||
pub fn handle_service_resolved(&mut self, info: &ServiceInfo) {
|
||||
let fullname = info.get_fullname().to_string();
|
||||
|
||||
debug!("Processing resolved Chromecast service: {}", fullname);
|
||||
|
||||
// Adresse IP : préférer IPv4
|
||||
let host = match info
|
||||
.get_addresses()
|
||||
.iter()
|
||||
.find(|a| a.is_ipv4())
|
||||
.or_else(|| info.get_addresses().iter().next())
|
||||
{
|
||||
Some(addr) => addr.to_string(),
|
||||
None => {
|
||||
warn!("No IP address for Chromecast service: {}", fullname);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let port = info.get_port();
|
||||
|
||||
// TXT records — API directe par clé
|
||||
let uuid = info
|
||||
.get_property_val_str("id")
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let uuid = if uuid.is_empty() {
|
||||
format!("chromecast-{}-{}", host, port)
|
||||
} else {
|
||||
uuid
|
||||
};
|
||||
|
||||
let model = info.get_property_val_str("md").map(|s| s.to_string());
|
||||
|
||||
let friendly_name = info
|
||||
.get_property_val_str("fn")
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| {
|
||||
// Fallback : extraire depuis le fullname, supprimer le suffixe de service
|
||||
fullname
|
||||
.split("._googlecast._tcp.local")
|
||||
.next()
|
||||
.unwrap_or("Unknown Chromecast")
|
||||
.split('-')
|
||||
.take_while(|part| part.len() != 32)
|
||||
.collect::<Vec<_>>()
|
||||
.join("-")
|
||||
.trim()
|
||||
.to_string()
|
||||
});
|
||||
|
||||
debug!(
|
||||
"Discovered Chromecast: {} at {}:{} (UUID: {}, Model: {:?})",
|
||||
friendly_name, host, port, uuid, model
|
||||
);
|
||||
|
||||
let udn = format!("uuid:{}", uuid);
|
||||
let default_max_age = 1800u64;
|
||||
|
||||
if !UDNRegistry::should_fetch(self.udn_cache.clone(), &udn, default_max_age) {
|
||||
debug!("Chromecast {} recently seen, skipping", udn);
|
||||
return;
|
||||
}
|
||||
|
||||
let renderer_info = build_renderer_info(
|
||||
&uuid,
|
||||
&friendly_name,
|
||||
&host,
|
||||
port,
|
||||
model.as_deref(),
|
||||
Some("Google Inc."),
|
||||
);
|
||||
|
||||
self.device_registry
|
||||
.write()
|
||||
.expect("DeviceRegistry mutex lock failed")
|
||||
.push_renderer(&renderer_info, default_max_age as u32);
|
||||
}
|
||||
```
|
||||
|
||||
### Supprimer
|
||||
- L'import `use std::collections::HashMap` (plus utilisé)
|
||||
- Tout le bloc `handle_mdns_response` (lignes 39–179)
|
||||
|
||||
### Conserver sans modification
|
||||
- `build_renderer_info` (lignes 182–234)
|
||||
- `extract_host_from_location` / `extract_port_from_location` (lignes 239–258)
|
||||
- Les tests (lignes 260–287)
|
||||
|
||||
---
|
||||
|
||||
## Étape 4 — `pmoserver/src/logs/mod.rs`
|
||||
|
||||
Le filtre de bruit `mdns=error` injecté dans `build_filter_with_noise_suppressions` n'est
|
||||
plus nécessaire. Deux options :
|
||||
|
||||
**Option A (recommandée)** — Supprimer l'entrée du tableau :
|
||||
```rust
|
||||
const NOISE_FILTERS: &[(&str, &str)] = &[
|
||||
// ("mdns", "mdns=error"), // supprimé : migration vers mdns-sd
|
||||
];
|
||||
```
|
||||
Ou supprimer `build_filter_with_noise_suppressions` entièrement si aucun autre bruit n'est
|
||||
à filtrer, et revenir à `EnvFilter::try_new(base)` direct.
|
||||
|
||||
**Option B** — Laisser en place. La directive `mdns=error` ne cause aucun dommage si la
|
||||
crate `mdns` n'est plus dans le build (elle sera simplement ignorée).
|
||||
|
||||
---
|
||||
|
||||
## Résumé des diffs
|
||||
|
||||
| Fichier | Lignes supprimées | Lignes ajoutées |
|
||||
|---|---|---|
|
||||
| `Cargo.toml` | `mdns`, `async-std`, `futures-util` | `mdns-sd` |
|
||||
| `control_point.rs` | ~50 (async block) | ~35 (sync recv loop) |
|
||||
| `chromecast_discovery.rs` | ~80 (parsing manuel) | ~50 (lecture ServiceInfo) |
|
||||
| `logs/mod.rs` | ~5 (filtre bruit) | 0 |
|
||||
|
||||
## Vérification
|
||||
|
||||
Après implémentation :
|
||||
1. `cargo check -p pmocontrol` sans erreurs ni `use of undeclared crate mdns`
|
||||
2. `cargo check -p pmoserver` sans erreurs
|
||||
3. Tester la découverte d'un Chromecast en réseau local
|
||||
4. Vérifier l'absence de `LabelIsNotAscii` dans les logs avec des appareils Apple présents
|
||||
1156
Blackboard/Todo/webrenderer_architecture_evolution.md
Normal file
1156
Blackboard/Todo/webrenderer_architecture_evolution.md
Normal file
File diff suppressed because it is too large
Load Diff
495
Cargo.lock
generated
495
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "PMOMusic"
|
||||
version = "0.3.33"
|
||||
version = "0.3.48"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use pmoapp::{WebAppExt, Webapp};
|
||||
use pmocontrol::ControlPointExt;
|
||||
use pmomediarenderer::MEDIA_RENDERER;
|
||||
use pmomediaserver::{
|
||||
MEDIA_SERVER, MediaServerDeviceExt, ParadiseStreamingExt, sources::SourcesExt,
|
||||
};
|
||||
@@ -71,17 +70,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Enregistrer les devices UPnP (HTTP + SSDP automatique)
|
||||
info!("📡 Registering UPnP devices...");
|
||||
|
||||
let renderer_instance = server
|
||||
.write()
|
||||
.await
|
||||
.register_device(MEDIA_RENDERER.clone(), true)
|
||||
.await
|
||||
.expect("Failed to register MediaRenderer");
|
||||
|
||||
let base_url = renderer_instance.base_url();
|
||||
let desc_route = renderer_instance.description_route();
|
||||
info!("✅ MediaRenderer ready at {}{}", base_url, desc_route);
|
||||
|
||||
let server_instance = server
|
||||
.write()
|
||||
.await
|
||||
|
||||
20
Report/frontend_enorme_playlist.md
Normal file
20
Report/frontend_enorme_playlist.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Rapport : Optimisation performances frontend playlists ~1000 titres
|
||||
|
||||
## Résumé
|
||||
Implémentation de quatre optimisations pour traiter les lenteurs UI observées avec de grandes playlists : virtualisation de la file d'attente avec RecycleScroller, debounce des refetches queue_updated, pagination + cache dans PlayListManager, et fenêtre glissante dans MediaBrowser. Ces changements éliminent les freezes au scroll, bloquages UI et refetches JSON répétés.
|
||||
|
||||
## Fichiers modifiés
|
||||
1. `pmoapp/webapp/src/components/pmocontrol/QueueViewer.vue`
|
||||
- Remplacement du v-for natif par RecycleScroller de vue-virtual-scroller
|
||||
- Migration de querySelector+scrollIntoView vers scrollToItem() exposé par RecycleScroller
|
||||
|
||||
2. `pmoapp/webapp/src/composables/useRenderers.ts`
|
||||
- Ajout d'un debounce de 300ms sur les refetches queue_updated pour éviter les refetches en cascade
|
||||
|
||||
3. `pmoapp/webapp/src/components/PlayListManager.vue`
|
||||
- Ajout pagination client (100 items/page) avec navigation Précédent/Suivant
|
||||
-Ajout cache mémorisé pour sortedTracks par playlist ID
|
||||
- Simplification lazyTracksCount derivé de sortedTracks
|
||||
|
||||
4. `pmoapp/webapp/src/composables/useMediaServers.ts`
|
||||
- Ajout fenêtre glissante limitant le cache browse aux 200 derniers items
|
||||
24
Report/webrenderer_architecture_evolution.md
Normal file
24
Report/webrenderer_architecture_evolution.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Rapport : Évolution architecture webrenderer
|
||||
|
||||
## Résumé
|
||||
Implémentation des phases 0-4 du plan d'évolution : correction du bug P0 (play_handler sans URI), introduction du typage fort avec DeviceCommand/VegearAdapter, integration flac_handle pause/resume, ajout AudioContext avec reconnexion automatique dans PMOPlayer.ts, correction du format de position UPnP, et endpoints JSON nowplaying/state.
|
||||
|
||||
## Fichiers modifiés
|
||||
1. `pmowebrenderer/src/handlers.rs` - Fix P0 + pause/resume flac_handle
|
||||
2. `pmowebrenderer/src/adapter.rs` - Nouveau fichier avec DeviceCommand et DeviceAdapter
|
||||
3. `pmowebrenderer/src/state.rs` - VecDeque<DeviceCommand> au lieu de Option<Value>
|
||||
4. `pmowebrenderer/src/pipeline.rs` - Ajout flac_handle dans PipelineHandle
|
||||
5. `pmowebrenderer/src/registry.rs` - Format position + get_state + get_state_and_udn
|
||||
6. `pmowebrenderer/src/register.rs` - Handlers nowplaying et state
|
||||
7. `pmowebrenderer/src/config.rs` - Nouvelles routes
|
||||
8. `pmowebrenderer/src/lib.rs` - Exports adapter
|
||||
9. `pmoapp/webapp/src/services/PMOPlayer.ts` - AudioContext + reconnexion
|
||||
|
||||
## Modifications sémantiques
|
||||
- **P0** : play_handler vérifie URI avant de changement d'état (plus de Transitioning bloqué)
|
||||
- **P1-P2** : DeviceCommand typé compile-time (plus de serde_json Value non typé)
|
||||
- **P3** : pause_handler/stop_handler/play_handler appellent flac_handle.pause()/resume()
|
||||
- **P5** : AudioContext avec createMediaElementSource() et suspend() dans flush()
|
||||
- **P6** : Reconnexion automatique avec backoff exponentiel
|
||||
- **P7** : Format position统一 en HH:MM:SS (seconds_to_upnp_time)
|
||||
- **P8** : Endpoints /nowplaying et /state en JSON
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "pmoapp"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
rust-embed = "8.5.0"
|
||||
|
||||
6
pmoapp/webapp/package-lock.json
generated
6
pmoapp/webapp/package-lock.json
generated
@@ -1609,9 +1609,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"version": "7.3.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
21
pmoapp/webapp/src/assets/default-cover.svg
Normal file
21
pmoapp/webapp/src/assets/default-cover.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400">
|
||||
<defs>
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#764ba2;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="400" fill="url(#bgGrad)"/>
|
||||
<g transform="translate(200, 200)">
|
||||
<rect x="-60" y="-80" width="120" height="100" rx="8" fill="white" opacity="0.9"/>
|
||||
<circle cx="0" cy="-30" r="18" fill="rgba(102, 126, 234, 0.3)"/>
|
||||
<rect x="-40" y="10" width="80" height="8" rx="4" fill="rgba(102, 126, 234, 0.3)"/>
|
||||
<rect x="-30" y="30" width="60" height="6" rx="3" fill="rgba(102, 126, 234, 0.2)"/>
|
||||
<rect x="-35" y="50" width="70" height="6" rx="3" fill="rgba(102, 126, 234, 0.2)"/>
|
||||
</g>
|
||||
<text x="200" y="360" text-anchor="middle"
|
||||
font-family="system-ui, -apple-system, sans-serif"
|
||||
font-size="20" fill="white" opacity="0.6">
|
||||
No Image Available
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -101,13 +101,15 @@
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
/* Bouton fermer */
|
||||
.drawer-close-btn {
|
||||
/* ========================================
|
||||
ICONS COMMUNS POUR DRAWERS
|
||||
======================================== */
|
||||
|
||||
/* Classe de base pour les boutons icônes des drawers */
|
||||
.drawer-icon-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);
|
||||
@@ -118,34 +120,31 @@
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.drawer-close-btn:hover {
|
||||
.drawer-icon-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.drawer-close-btn:active {
|
||||
.drawer-icon-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Bouton fermer */
|
||||
.drawer-close-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.drawer-close-btn:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -381,24 +381,27 @@
|
||||
ANIMATIONS
|
||||
======================================== */
|
||||
|
||||
@keyframes glassShimmer {
|
||||
0% {
|
||||
background-position: -200% center;
|
||||
/* Respecte prefers-reduced-motion */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
@keyframes glassShimmer {
|
||||
0% {
|
||||
background-position: -200% center;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% center;
|
||||
}
|
||||
}
|
||||
100% {
|
||||
background-position: 200% center;
|
||||
}
|
||||
}
|
||||
|
||||
.glass-shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 255, 255, 0.1) 50%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: glassShimmer 2s ease-in-out infinite;
|
||||
.glass-shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 255, 255, 0.1) 50%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: glassShimmer 2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
|
||||
@@ -79,7 +79,14 @@ body {
|
||||
background-color: var(--status-transitioning-bg);
|
||||
color: var(--status-transitioning);
|
||||
border: 1px solid var(--status-transitioning);
|
||||
animation: pulse 2s infinite;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Respect prefers-reduced-motion */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.status-badge.transitioning {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
@@ -226,52 +233,81 @@ body {
|
||||
/* ========================================
|
||||
Animations
|
||||
======================================== */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
|
||||
/* Respecte prefers-reduced-motion */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-opacity {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.status-badge.transitioning {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.shuffle-button.loading {
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
.event-badge {
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn var(--transition-base);
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
/* Default state (animations disabled) */
|
||||
.status-badge.transitioning,
|
||||
.shuffle-button.loading,
|
||||
.event-badge,
|
||||
.loading-state {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@@ -546,10 +582,49 @@ input[type="range"]::-moz-range-thumb:hover {
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
ANIMATIONS
|
||||
ANIMATIONS (Keyframes for no-preference media query)
|
||||
======================================== */
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn var(--transition-base);
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-opacity {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
}
|
||||
@@ -89,13 +89,18 @@
|
||||
--transition-base: 300ms ease-in-out;
|
||||
--transition-slow: 500ms ease-in-out;
|
||||
|
||||
/* ========================================
|
||||
Z-index layers
|
||||
======================================== */
|
||||
/* ========================================
|
||||
Z-index layers
|
||||
======================================== */
|
||||
--z-dropdown: 100;
|
||||
--z-modal: 200;
|
||||
--z-toast: 300;
|
||||
--z-tooltip: 400;
|
||||
|
||||
/* ========================================
|
||||
Opacity states
|
||||
======================================== */
|
||||
--opacity-disabled: 0.4;
|
||||
}
|
||||
|
||||
/* Dark mode support (optionnel pour l'avenir) */
|
||||
|
||||
@@ -981,7 +981,14 @@ button.active {
|
||||
padding: 3rem;
|
||||
color: #569cd6;
|
||||
font-size: 1.1rem;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Respect prefers-reduced-motion */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.loading-state {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
@@ -559,12 +559,24 @@
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
<!-- Pagination controls -->
|
||||
<div v-if="totalPages > 1" class="pagination-controls">
|
||||
<button @click="prevPage" :disabled="currentPage === 0">
|
||||
← Previous
|
||||
</button>
|
||||
<span class="page-info">
|
||||
Page {{ currentPage + 1 }} of {{ totalPages }}
|
||||
</span>
|
||||
<button @click="nextPage" :disabled="currentPage >= totalPages - 1">
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="track-grid"
|
||||
v-if="sortedTracks.length > 0"
|
||||
v-if="paginatedTracks.length > 0"
|
||||
>
|
||||
<article
|
||||
v-for="track in sortedTracks"
|
||||
v-for="track in paginatedTracks"
|
||||
:key="`${track.cache_pk}-${track.added_at}`"
|
||||
class="track-card"
|
||||
:class="{ lazy: isLazyTrack(track) }"
|
||||
@@ -880,20 +892,51 @@ const sortedPlaylists = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
const currentPage = ref(0);
|
||||
const sortedTracksCache = new Map<string, PlaylistTrack[]>();
|
||||
|
||||
const sortedTracks = computed(() => {
|
||||
const detail = selectedPlaylist.value;
|
||||
if (!detail) return [];
|
||||
return [...detail.tracks].sort(
|
||||
const playlistId = detail.summary.id;
|
||||
const cached = sortedTracksCache.get(playlistId);
|
||||
if (cached && cached.length === detail.tracks.length) return cached;
|
||||
const sorted = [...detail.tracks].sort(
|
||||
(a, b) =>
|
||||
new Date(b.added_at).getTime() - new Date(a.added_at).getTime(),
|
||||
);
|
||||
sortedTracksCache.set(playlistId, sorted);
|
||||
return sorted;
|
||||
});
|
||||
|
||||
const paginatedTracks = computed(() =>
|
||||
sortedTracks.value.slice(
|
||||
currentPage.value * PAGE_SIZE,
|
||||
(currentPage.value + 1) * PAGE_SIZE
|
||||
)
|
||||
);
|
||||
|
||||
const totalPages = computed(() => Math.ceil(sortedTracks.value.length / PAGE_SIZE));
|
||||
|
||||
function nextPage() {
|
||||
if (currentPage.value < totalPages.value - 1) {
|
||||
currentPage.value++;
|
||||
}
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (currentPage.value > 0) {
|
||||
currentPage.value--;
|
||||
}
|
||||
}
|
||||
|
||||
watch(sortedTracks, () => {
|
||||
currentPage.value = 0;
|
||||
});
|
||||
|
||||
const lazyTracksCount = computed(() =>
|
||||
selectedPlaylist.value
|
||||
? selectedPlaylist.value.tracks.filter((track) => isLazyTrack(track))
|
||||
.length
|
||||
: 0,
|
||||
sortedTracks.value.filter((track) => isLazyTrack(track)).length
|
||||
);
|
||||
|
||||
const updateCoverPreview = computed(
|
||||
@@ -1881,6 +1924,40 @@ button:disabled {
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.pagination-controls button {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pagination-controls button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pagination-controls button:hover:not(:disabled) {
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.track-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -13,6 +13,7 @@ const props = defineProps<{
|
||||
const { state } = useRenderer(toRef(props, "rendererId"));
|
||||
const uiStore = useUIStore();
|
||||
const metadata = computed(() => state.value?.current_track);
|
||||
const rendererName = computed(() => state.value?.friendly_name ?? props.rendererId);
|
||||
const isSeeking = ref(false);
|
||||
const isDragging = ref(false);
|
||||
const dragProgress = ref(0);
|
||||
@@ -149,7 +150,7 @@ function handleProgressBarMouseDown(event: MouseEvent) {
|
||||
await api.seekTo(props.rendererId, newPositionSeconds);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible de seek: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
`« ${rendererName.value} » — impossible de changer la position: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
// En cas d'erreur, réinitialiser
|
||||
seekTargetMs.value = null;
|
||||
@@ -188,7 +189,7 @@ async function handleProgressBarClick(event: MouseEvent) {
|
||||
await api.seekTo(props.rendererId, newPositionSeconds);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible de seek: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
`« ${rendererName.value} » — impossible de changer la position: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
// En cas d'erreur, réinitialiser
|
||||
seekTargetMs.value = null;
|
||||
@@ -251,7 +252,7 @@ function handleOverlayProgressBarTouchStart(event: TouchEvent) {
|
||||
await api.seekTo(props.rendererId, newPositionSeconds);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible de seek: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
`« ${rendererName.value} » — impossible de changer la position: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
seekTargetMs.value = null;
|
||||
dragProgress.value = 0;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, nextTick, toRef } from "vue";
|
||||
import { RecycleScroller } from "vue-virtual-scroller";
|
||||
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
|
||||
import { useRenderer } from "@/composables/useRenderers";
|
||||
import QueueItem from "./QueueItem.vue";
|
||||
import { Link, Radio, RefreshCw } from "lucide-vue-next";
|
||||
@@ -17,7 +19,7 @@ const { queue, binding, isStream, queueRefreshing } = useRenderer(toRef(props, "
|
||||
|
||||
const isAttached = computed(() => !!binding.value);
|
||||
|
||||
const queueContainer = ref<HTMLElement | null>(null);
|
||||
const queueContainer = ref<any>(null);
|
||||
|
||||
function handleItemClick(item: QueueItemType) {
|
||||
emit("clickItem", item);
|
||||
@@ -33,15 +35,7 @@ watch(
|
||||
queueContainer.value
|
||||
) {
|
||||
await nextTick();
|
||||
const currentItem = queueContainer.value.querySelector(
|
||||
".queue-item.current",
|
||||
);
|
||||
if (currentItem) {
|
||||
currentItem.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
});
|
||||
}
|
||||
queueContainer.value.scrollToItem(currentIndex);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -81,16 +75,23 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Liste des items -->
|
||||
<div v-if="queue?.items.length" class="queue-list" ref="queueContainer">
|
||||
<!-- Liste des items virtualisée -->
|
||||
<RecycleScroller
|
||||
v-if="queue?.items.length"
|
||||
class="queue-list"
|
||||
:items="queue.items"
|
||||
:item-size="64"
|
||||
key-field="index"
|
||||
v-slot="{ item }"
|
||||
ref="queueContainer"
|
||||
:min-item-size="64"
|
||||
>
|
||||
<QueueItem
|
||||
v-for="item in queue.items"
|
||||
:key="item.index"
|
||||
:item="item"
|
||||
:is-current="item.index === queue.current_index"
|
||||
@click="handleItemClick"
|
||||
/>
|
||||
</div>
|
||||
</RecycleScroller>
|
||||
|
||||
<!-- État vide -->
|
||||
<div v-else class="queue-empty">
|
||||
@@ -105,6 +106,8 @@ watch(
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.queue-header {
|
||||
@@ -196,20 +199,11 @@ watch(
|
||||
}
|
||||
|
||||
.queue-list {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
padding-right: var(--spacing-xs);
|
||||
}
|
||||
|
||||
/* Ajoute un espace de scroll en bas pour ne pas cacher les derniers items sous la barre */
|
||||
.queue-list::after {
|
||||
content: "";
|
||||
display: block;
|
||||
height: 80px; /* Espace pour la barre fixe en bas */
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
|
||||
@@ -76,7 +76,14 @@ async function handleShuffle() {
|
||||
}
|
||||
|
||||
.shuffle-button.loading {
|
||||
animation: pulse 1s infinite;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Respect prefers-reduced-motion */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.shuffle-button.loading {
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
@@ -20,12 +20,14 @@ const isStopped = computed(
|
||||
state.value?.transport_state === "NO_MEDIA",
|
||||
);
|
||||
|
||||
const rendererName = computed(() => state.value?.friendly_name ?? props.rendererId);
|
||||
|
||||
async function handlePlay() {
|
||||
try {
|
||||
await resumeOrPlayFromQueue(props.rendererId);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible de démarrer la lecture: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
`« ${rendererName.value} » — impossible de démarrer la lecture: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -35,7 +37,7 @@ async function handlePause() {
|
||||
await pause(props.rendererId);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible de mettre en pause: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
`« ${rendererName.value} » — impossible de mettre en pause: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -45,7 +47,7 @@ async function handleStop() {
|
||||
await stop(props.rendererId);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible d'arrêter la lecture: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
`« ${rendererName.value} » — impossible d'arrêter la lecture: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -55,7 +57,7 @@ async function handleNext() {
|
||||
await next(props.rendererId);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible de passer au morceau suivant: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
`« ${rendererName.value} » — impossible de passer au morceau suivant: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -68,6 +70,7 @@ async function handleNext() {
|
||||
:disabled="isPlaying"
|
||||
@click="handlePlay"
|
||||
title="Lecture"
|
||||
aria-label="Lecture"
|
||||
>
|
||||
<Play :size="20" />
|
||||
</button>
|
||||
@@ -77,6 +80,7 @@ async function handleNext() {
|
||||
:disabled="isPaused || isStopped"
|
||||
@click="handlePause"
|
||||
title="Pause"
|
||||
aria-label="Pause"
|
||||
>
|
||||
<Pause :size="20" />
|
||||
</button>
|
||||
@@ -86,6 +90,7 @@ async function handleNext() {
|
||||
:disabled="isStopped"
|
||||
@click="handleStop"
|
||||
title="Stop"
|
||||
aria-label="Arrêter"
|
||||
>
|
||||
<Square :size="20" />
|
||||
</button>
|
||||
@@ -95,6 +100,7 @@ async function handleNext() {
|
||||
:disabled="!state?.queue_len"
|
||||
@click="handleNext"
|
||||
title="Suivant"
|
||||
aria-label="Morceau suivant"
|
||||
>
|
||||
<SkipForward :size="20" />
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, toRef } from "vue";
|
||||
import { ref, watch, computed, toRef } from "vue";
|
||||
import { useRenderer, useRenderers } from "@/composables/useRenderers";
|
||||
import { useUIStore } from "@/stores/ui";
|
||||
import { Volume2, VolumeX } from "lucide-vue-next";
|
||||
@@ -12,6 +12,7 @@ const { state } = useRenderer(toRef(props, "rendererId"));
|
||||
const { setVolume, toggleMute } = useRenderers();
|
||||
const uiStore = useUIStore();
|
||||
|
||||
const rendererName = computed(() => state.value?.friendly_name ?? props.rendererId);
|
||||
const localVolume = ref(state.value?.volume ?? 50);
|
||||
|
||||
// Synchroniser localVolume avec le state
|
||||
@@ -41,7 +42,7 @@ function handleVolumeChange(event: Event) {
|
||||
await setVolume(props.rendererId, localVolume.value);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible de régler le volume: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
`« ${rendererName.value} » — impossible de régler le volume: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
debounceTimer = null;
|
||||
@@ -53,7 +54,7 @@ async function handleToggleMute() {
|
||||
await toggleMute(props.rendererId);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible de basculer le mode muet: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
`« ${rendererName.value} » — impossible de couper/rétablir le son: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -65,6 +66,7 @@ async function handleToggleMute() {
|
||||
class="btn btn-icon"
|
||||
@click="handleToggleMute"
|
||||
:title="state?.mute ? 'Réactiver le son' : 'Couper le son'"
|
||||
:aria-label="state?.mute ? 'Réactiver le son' : 'Couper le son'"
|
||||
>
|
||||
<VolumeX v-if="state?.mute" :size="20" />
|
||||
<Volume2 v-else :size="20" />
|
||||
@@ -78,6 +80,7 @@ async function handleToggleMute() {
|
||||
@input="handleVolumeChange"
|
||||
class="volume-slider"
|
||||
:disabled="state?.mute ?? false"
|
||||
aria-label="Volume"
|
||||
/>
|
||||
|
||||
<span class="volume-value">{{ localVolume }}</span>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ArrowRightLeft,
|
||||
} from "lucide-vue-next";
|
||||
import { useRenderers } from "@/composables/useRenderers";
|
||||
import { useWebRenderer } from "@/composables/useWebRenderer";
|
||||
import { useRouter } from "vue-router";
|
||||
import StatusBadge from "@/components/pmocontrol/StatusBadge.vue";
|
||||
import type { RendererSummary } from "@/services/pmocontrol/types";
|
||||
@@ -27,8 +28,27 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const { allRenderers, fetchRenderers, getStateById } = useRenderers();
|
||||
const webRenderer = useWebRenderer();
|
||||
const router = useRouter();
|
||||
|
||||
// Filtre pour n'afficher que notre WebRenderer (pas les autres onglets/navigateurs)
|
||||
const myUdn = computed(() => webRenderer.rendererUdn.value);
|
||||
|
||||
const filteredRenderers = computed(() => {
|
||||
const udn = myUdn.value;
|
||||
const normalizedUdn = udn?.replace(/^uuid:/, '') ?? null;
|
||||
return allRenderers.value.filter((r: RendererSummary) => {
|
||||
// WebRenderers have "Web Audio – " as friendly_name prefix
|
||||
const isWebRenderer = r.friendly_name?.startsWith("Web Audio – ") ?? false;
|
||||
if (isWebRenderer) {
|
||||
if (normalizedUdn === null) return false;
|
||||
const normalizedId = r.id.replace(/^uuid:/, '');
|
||||
return normalizedId === normalizedUdn;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
// Gestion du menu déroulant
|
||||
const openMenuId = ref<string | null>(null);
|
||||
|
||||
@@ -110,16 +130,16 @@ watch(
|
||||
);
|
||||
|
||||
const onlineRenderers = computed(() =>
|
||||
allRenderers.value.filter((r: RendererSummary) => r.online),
|
||||
filteredRenderers.value.filter((r: RendererSummary) => r.online),
|
||||
);
|
||||
const offlineRenderers = computed(() =>
|
||||
allRenderers.value.filter((r: RendererSummary) => !r.online),
|
||||
filteredRenderers.value.filter((r: RendererSummary) => !r.online),
|
||||
);
|
||||
|
||||
// Noms de renderers qui apparaissent plus d'une fois (online + offline)
|
||||
const duplicateNames = computed(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const r of allRenderers.value) {
|
||||
for (const r of filteredRenderers.value) {
|
||||
counts.set(r.friendly_name, (counts.get(r.friendly_name) ?? 0) + 1);
|
||||
}
|
||||
return new Set([...counts.entries()].filter(([, n]) => n > 1).map(([name]) => name));
|
||||
|
||||
@@ -19,6 +19,7 @@ const { renderer, state, queue, refresh } = useRenderer(
|
||||
toRef(props, "rendererId"),
|
||||
);
|
||||
const uiStore = useUIStore();
|
||||
const rendererName = computed(() => state.value?.friendly_name ?? props.rendererId);
|
||||
|
||||
// Détection mobile portrait pour afficher le drawer au lieu de la colonne
|
||||
const isMobilePortrait = useMediaQuery(
|
||||
@@ -59,7 +60,7 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
error,
|
||||
);
|
||||
uiStore.notifyError(
|
||||
`Erreur: ${error instanceof Error ? error.message : "Impossible de sauter à cet item"}`,
|
||||
`« ${rendererName.value} » — impossible de lire ce morceau: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -142,6 +143,7 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Layout principal - 800x600 landscape (2 colonnes) */
|
||||
@@ -153,6 +155,7 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
/* padding-right: 0 pour coller la scrollbar au bord */
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Colonne gauche - Contrôles */
|
||||
@@ -185,11 +188,14 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding-bottom: 80px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.queue-viewer {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Queue drawer - masqué sur desktop, visible uniquement sur mobile portrait */
|
||||
@@ -349,8 +355,6 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
@media (min-width: 1200px) {
|
||||
.renderer-layout {
|
||||
grid-template-columns: 350px 1fr;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ const searchInput = ref('');
|
||||
const isSearchMode = computed(() => searchQuery.value !== '');
|
||||
|
||||
async function handleSearch() {
|
||||
console.log('[ServerDrawer] handleSearch called, currentServer:', currentServer.value?.id, 'searchInput:', searchInput.value);
|
||||
if (!currentServer.value || !searchInput.value.trim()) return;
|
||||
await searchServer(currentServer.value.id, searchInput.value.trim());
|
||||
}
|
||||
|
||||
@@ -187,8 +187,6 @@ onMounted(async () => {
|
||||
/* Large desktop */
|
||||
@media (min-width: 1200px) {
|
||||
.browser-section {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,7 +372,14 @@ watch(editingVar, (newVar) => {
|
||||
|
||||
.event-badge {
|
||||
font-size: 1rem;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Respect prefers-reduced-motion */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.event-badge {
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
export interface CacheEntry<T> {
|
||||
data: T;
|
||||
timestamp: number;
|
||||
ttl?: number;
|
||||
etag?: string;
|
||||
}
|
||||
|
||||
@@ -51,7 +52,9 @@ class ApiCacheService {
|
||||
private isFresh(key: string): boolean {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) return false;
|
||||
return Date.now() - entry.timestamp < this.options.ttl;
|
||||
// Lire le TTL de l'entrée, sinon utiliser le TTL global par défaut
|
||||
const ttl = entry.ttl ?? this.options.ttl;
|
||||
return Date.now() - entry.timestamp < ttl;
|
||||
}
|
||||
|
||||
get<T>(endpoint: string, params?: Record<string, string | number | boolean>): T | null {
|
||||
@@ -66,12 +69,13 @@ class ApiCacheService {
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
set<T>(endpoint: string, data: T, params?: Record<string, string | number | boolean>, etag?: string): void {
|
||||
set<T>(endpoint: string, data: T, params?: Record<string, string | number | boolean>, etag?: string, ttl?: number): void {
|
||||
const key = this.makeKey(endpoint, params);
|
||||
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
ttl,
|
||||
etag,
|
||||
});
|
||||
|
||||
@@ -92,20 +96,34 @@ class ApiCacheService {
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
const existing = this.pendingRequests.get(key);
|
||||
if (existing) {
|
||||
return existing.promise as Promise<T>;
|
||||
// Utiliser une clé unique pour éviter les problèmes de race condition
|
||||
// avec les requêtes en cours qui peuvent être supprimées avant résolution
|
||||
const requestKey = `request:${key}`;
|
||||
|
||||
// Récupérer ou créer la requête
|
||||
let pendingRequest = this.pendingRequests.get(requestKey);
|
||||
|
||||
// Si une requête est en cours et sa promesse n'a pas encore été resolved/rejected
|
||||
// on retourne directement cette promesse
|
||||
if (pendingRequest) {
|
||||
try {
|
||||
// Attendre la résolution pour s'assurer que c'est toujours valide
|
||||
return await pendingRequest.promise as T;
|
||||
} catch (e) {
|
||||
// La requête a échoué, on continue pour faire une nouvelle requête
|
||||
this.pendingRequests.delete(requestKey);
|
||||
}
|
||||
}
|
||||
|
||||
let resolvePromise!: (value: unknown) => void;
|
||||
let resolvePromise!: (value: T) => void;
|
||||
let rejectPromise!: (reason: unknown) => void;
|
||||
|
||||
const promise = new Promise<unknown>((resolve, reject) => {
|
||||
const promise = new Promise<T>((resolve, reject) => {
|
||||
resolvePromise = resolve;
|
||||
rejectPromise = reject;
|
||||
});
|
||||
|
||||
this.pendingRequests.set(key, {
|
||||
this.pendingRequests.set(requestKey, {
|
||||
promise,
|
||||
subscribers: new Set(),
|
||||
});
|
||||
@@ -113,18 +131,13 @@ class ApiCacheService {
|
||||
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);
|
||||
}
|
||||
// Passer le TTL directement à set() pour éviter les problèmes de race condition
|
||||
// avec la modification globale de this.options.ttl
|
||||
this.set(endpoint, data, params, undefined, ttl);
|
||||
|
||||
resolvePromise(data);
|
||||
|
||||
const pending = this.pendingRequests.get(key);
|
||||
const pending = this.pendingRequests.get(requestKey);
|
||||
if (pending) {
|
||||
pending.subscribers.forEach(cb => cb(data));
|
||||
}
|
||||
@@ -133,10 +146,10 @@ class ApiCacheService {
|
||||
rejectPromise(error);
|
||||
throw error;
|
||||
} finally {
|
||||
this.pendingRequests.delete(key);
|
||||
this.pendingRequests.delete(requestKey);
|
||||
}
|
||||
|
||||
return Promise.reject(new Error('Unreachable'));
|
||||
return promise;
|
||||
}
|
||||
|
||||
subscribe<T>(endpoint: string, params: Record<string, string | number | boolean>, callback: (data: T) => void): () => void {
|
||||
@@ -182,7 +195,8 @@ class ApiCacheService {
|
||||
|
||||
keysToDelete.forEach(key => {
|
||||
this.cache.delete(key);
|
||||
this.subscriptions.delete(key);
|
||||
// NE PAS supprimer this.subscriptions.get(key)
|
||||
// Les abonnés seront notifiés lors du prochain set() après un refetch
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -37,9 +37,20 @@ class ImageCacheService {
|
||||
};
|
||||
|
||||
private readonly CACHE_CLEANUP_MS = 5 * 60 * 1000;
|
||||
private cleanupIntervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor() {
|
||||
setInterval(() => this.cleanup(), this.CACHE_CLEANUP_MS);
|
||||
this.cleanupIntervalId = setInterval(() => this.cleanup(), this.CACHE_CLEANUP_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoie le timer de cleanup. À appeler lors de la destruction de l'application.
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.cleanupIntervalId !== null) {
|
||||
clearInterval(this.cleanupIntervalId);
|
||||
this.cleanupIntervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
configure(options: Partial<ImageCacheOptions>) {
|
||||
|
||||
@@ -20,16 +20,23 @@ export interface BrowseState {
|
||||
container_id: string
|
||||
entries: ContainerEntry[]
|
||||
total_count: number
|
||||
hasMore?: boolean
|
||||
currentOffset?: number
|
||||
}
|
||||
|
||||
// Cache global partagé
|
||||
const serversCache = ref<Map<string, MediaServerSummary>>(new Map())
|
||||
const browseCache = ref<Map<string, BrowseState>>(new Map())
|
||||
|
||||
function browseCacheKey(serverId: string, containerId: string): string {
|
||||
return `${encodeURIComponent(serverId)}:${encodeURIComponent(containerId)}`
|
||||
}
|
||||
const currentPath = ref<BreadcrumbItem[]>([])
|
||||
const searchResults = ref<BrowseState | null>(null)
|
||||
const searchQuery = ref<string>('')
|
||||
|
||||
const CACHE_DURATION_MS = 2000
|
||||
const BROWSE_WINDOW_SIZE = 200
|
||||
|
||||
// Initialiser SSE une seule fois via le composable centralisé
|
||||
let sseInitialized = false
|
||||
@@ -69,9 +76,10 @@ function ensureSSEInitialized() {
|
||||
}
|
||||
|
||||
// Invalider tout le cache browse de ce serveur
|
||||
const encodedServerId = encodeURIComponent(serverId)
|
||||
const keysToDelete: string[] = []
|
||||
browseCache.value.forEach((_, key) => {
|
||||
if (key.startsWith(serverId + '/')) {
|
||||
if (key.startsWith(encodedServerId + ':')) {
|
||||
keysToDelete.push(key)
|
||||
}
|
||||
})
|
||||
@@ -80,9 +88,10 @@ function ensureSSEInitialized() {
|
||||
|
||||
case 'global_updated':
|
||||
// Invalider tout le cache de ce serveur
|
||||
const encodedServerIdGlobal = encodeURIComponent(serverId)
|
||||
const globalKeysToDelete: string[] = []
|
||||
browseCache.value.forEach((_, key) => {
|
||||
if (key.startsWith(serverId + '/')) {
|
||||
if (key.startsWith(encodedServerIdGlobal + ':')) {
|
||||
globalKeysToDelete.push(key)
|
||||
}
|
||||
})
|
||||
@@ -92,7 +101,7 @@ function ensureSSEInitialized() {
|
||||
case 'containers_updated':
|
||||
// Invalider les containers spécifiques
|
||||
event.container_ids.forEach(containerId => {
|
||||
const key = `${serverId}/${containerId}`
|
||||
const key = browseCacheKey(serverId, containerId)
|
||||
browseCache.value.delete(key)
|
||||
})
|
||||
break
|
||||
@@ -142,7 +151,7 @@ export function useMediaServers() {
|
||||
|
||||
// Charge la première page (remplace le cache)
|
||||
async function browseContainer(serverId: string, containerId: string, useCache = true) {
|
||||
const key = `${serverId}/${containerId}`
|
||||
const key = browseCacheKey(serverId, containerId)
|
||||
|
||||
if (useCache && browseCache.value.has(key)) {
|
||||
return browseCache.value.get(key)!
|
||||
@@ -152,12 +161,14 @@ export function useMediaServers() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
const data = await api.browseContainer(serverId, containerId, 0)
|
||||
const data = await api.browseContainer(serverId, containerId, 0, 50)
|
||||
|
||||
browseCache.value.set(key, {
|
||||
container_id: data.container_id,
|
||||
entries: data.entries,
|
||||
total_count: data.total_count,
|
||||
hasMore: data.entries.length < data.total_count,
|
||||
currentOffset: data.entries.length,
|
||||
})
|
||||
|
||||
return browseCache.value.get(key)!
|
||||
@@ -172,22 +183,28 @@ export function useMediaServers() {
|
||||
|
||||
// Charge la page suivante et accumule (infinite scroll)
|
||||
async function loadMoreBrowse(serverId: string, containerId: string) {
|
||||
const key = `${serverId}/${containerId}`
|
||||
const key = browseCacheKey(serverId, containerId)
|
||||
const state = browseCache.value.get(key)
|
||||
|
||||
if (!state) return
|
||||
if (state.entries.length >= state.total_count) return
|
||||
if (!('hasMore' in state) || !state.hasMore) return
|
||||
if (loadingMore.value) return
|
||||
|
||||
try {
|
||||
loadingMore.value = true
|
||||
|
||||
const offset = state.entries.length
|
||||
// Le type cast est nécessaire car les anciens cached entries n'ont pas hasMore
|
||||
const state = browseCache.value.get(key) as BrowseState & { hasMore?: boolean; currentOffset?: number }
|
||||
const offset = state.currentOffset ?? state.entries.length
|
||||
const data = await api.browseContainer(serverId, containerId, offset)
|
||||
|
||||
// Accumuler les nouvelles entrées
|
||||
state.entries.push(...data.entries)
|
||||
// Accumuler les nouvelles entrées avec fenêtre glissante
|
||||
const combined = [...state.entries, ...data.entries]
|
||||
// Ne garder que les últimos BROWSE_WINDOW_SIZE items
|
||||
state.entries = combined.slice(-BROWSE_WINDOW_SIZE)
|
||||
state.total_count = data.total_count
|
||||
state.currentOffset = (state.currentOffset ?? 0) + data.entries.length
|
||||
state.hasMore = state.currentOffset < state.total_count
|
||||
// Forcer la réactivité
|
||||
browseCache.value.set(key, { ...state })
|
||||
} catch (e) {
|
||||
@@ -199,6 +216,7 @@ export function useMediaServers() {
|
||||
|
||||
// Recherche dans un serveur
|
||||
async function searchServer(serverId: string, query: string) {
|
||||
console.log(`[useMediaServers] searchServer called: serverId=${serverId}, query=${query}`);
|
||||
if (!query.trim()) {
|
||||
searchResults.value = null
|
||||
searchQuery.value = ''
|
||||
@@ -209,8 +227,10 @@ export function useMediaServers() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
searchQuery.value = query
|
||||
console.log(`[useMediaServers] Calling API searchServer for server ${serverId}`);
|
||||
|
||||
const data = await api.searchServer(serverId, query)
|
||||
console.log(`[useMediaServers] Search returned ${data.entries.length} entries, total=${data.total_count}`);
|
||||
searchResults.value = {
|
||||
container_id: 'search',
|
||||
entries: data.entries,
|
||||
@@ -236,12 +256,12 @@ export function useMediaServers() {
|
||||
}
|
||||
|
||||
function getBrowseCached(serverId: string, containerId: string) {
|
||||
const key = `${serverId}/${containerId}`
|
||||
const key = browseCacheKey(serverId, containerId)
|
||||
return browseCache.value.get(key)
|
||||
}
|
||||
|
||||
function hasMore(serverId: string, containerId: string): boolean {
|
||||
const key = `${serverId}/${containerId}`
|
||||
const key = browseCacheKey(serverId, containerId)
|
||||
const state = browseCache.value.get(key)
|
||||
if (!state) return false
|
||||
return state.entries.length < state.total_count
|
||||
@@ -259,12 +279,12 @@ export function useMediaServers() {
|
||||
// Invalidation du cache
|
||||
function invalidateCache(serverId: string, containerId?: string) {
|
||||
if (containerId) {
|
||||
const key = `${serverId}/${containerId}`
|
||||
browseCache.value.delete(key)
|
||||
browseCache.value.delete(browseCacheKey(serverId, containerId))
|
||||
} else {
|
||||
const encodedServerId = encodeURIComponent(serverId)
|
||||
const keysToDelete: string[] = []
|
||||
browseCache.value.forEach((_, key) => {
|
||||
if (key.startsWith(serverId + '/')) {
|
||||
if (key.startsWith(encodedServerId + ':')) {
|
||||
keysToDelete.push(key)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
* - Les snapshots complets proviennent de /renderers/{id}/full
|
||||
* - Les événements SSE ne servent qu'à déclencher un refetch.
|
||||
*/
|
||||
import { ref, reactive, computed, toRaw, type Ref } from "vue";
|
||||
import { ref, reactive, computed, type Ref, onUnmounted } from "vue";
|
||||
import { api } from "../services/pmocontrol/api";
|
||||
import { useSSE } from "./useSSE";
|
||||
import { apiCache } from "./apiCache";
|
||||
import { parseTimeToMs } from "../utils/time";
|
||||
import { useUIStore } from "@/stores/ui";
|
||||
import type {
|
||||
RendererSummary,
|
||||
RendererState,
|
||||
@@ -16,33 +17,35 @@ import type {
|
||||
AttachedPlaylistInfo,
|
||||
FullRendererSnapshot,
|
||||
} from "../services/pmocontrol/types";
|
||||
import { isTransportState } from "../services/pmocontrol/types";
|
||||
|
||||
interface RendererSnapshotState {
|
||||
snapshots: Map<string, FullRendererSnapshot>;
|
||||
lastSnapshotAt: Map<string, number>;
|
||||
lastEventAt: Map<string, number>;
|
||||
loadingIds: Set<string>;
|
||||
queueRefreshingIds: Set<string>;
|
||||
selectedRendererId: string | null;
|
||||
}
|
||||
// État global des snapshots avec reactive pour une réactivité native Vue sur les Maps
|
||||
const snapshots = reactive(new Map<string, FullRendererSnapshot>());
|
||||
const lastSnapshotAt = reactive(new Map<string, number>());
|
||||
const lastEventAt = reactive(new Map<string, number>());
|
||||
const loadingIds = reactive(new Set<string>());
|
||||
const queueRefreshingIds = reactive(new Set<string>());
|
||||
const selectedRendererId = ref<string | null>(null);
|
||||
|
||||
// Cache des renderers (summary)
|
||||
const renderersCache = ref<Map<string, RendererSummary>>(new Map());
|
||||
const RENDERERS_CACHE_MS = 2000;
|
||||
|
||||
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,
|
||||
});
|
||||
// Supprimé : les helpers triggerXXX ne sont plus nécessaires avec reactive
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
// Utiliser le composable SSE centralisé
|
||||
let sseInitialized = false;
|
||||
|
||||
/**
|
||||
* Réinitialise le flag SSE pour permettre une nouvelle connexion après reconnexion
|
||||
*/
|
||||
function resetSSE() {
|
||||
sseInitialized = false;
|
||||
}
|
||||
|
||||
function ensureSSEInitialized() {
|
||||
if (sseInitialized) return;
|
||||
|
||||
@@ -63,6 +66,7 @@ function ensureSSEInitialized() {
|
||||
id: rendererId,
|
||||
friendly_name: event.friendly_name,
|
||||
model_name: event.model_name,
|
||||
manufacturer: event.manufacturer,
|
||||
protocol: "upnp", // Valeur par défaut, sera mise à jour par le fetch
|
||||
capabilities: {
|
||||
has_avtransport: false,
|
||||
@@ -98,16 +102,16 @@ function ensureSSEInitialized() {
|
||||
}
|
||||
|
||||
// Supprimer le snapshot (il n'est plus valide)
|
||||
snapshotState.snapshots.delete(rendererId);
|
||||
snapshotState.lastSnapshotAt.delete(rendererId);
|
||||
snapshotState.lastEventAt.delete(rendererId);
|
||||
snapshots.delete(rendererId);
|
||||
lastSnapshotAt.delete(rendererId);
|
||||
lastEventAt.delete(rendererId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pour les autres événements, mettre à jour le snapshot local directement
|
||||
snapshotState.lastEventAt.set(rendererId, timestamp);
|
||||
lastEventAt.set(rendererId, timestamp);
|
||||
|
||||
const snapshot = snapshotState.snapshots.get(rendererId);
|
||||
const snapshot = snapshots.get(rendererId);
|
||||
|
||||
// Si pas de snapshot, on doit fetch
|
||||
if (!snapshot) {
|
||||
@@ -118,7 +122,16 @@ function ensureSSEInitialized() {
|
||||
// Sinon, mettre à jour le snapshot localement selon le type d'événement
|
||||
switch (event.type) {
|
||||
case "state_changed":
|
||||
snapshot.state.transport_state = event.state as any;
|
||||
// Créer un nouvel objet pour déclencher la réactivité
|
||||
if (isTransportState(event.state)) {
|
||||
snapshots.set(rendererId, {
|
||||
...snapshot,
|
||||
state: { ...snapshot.state, transport_state: event.state },
|
||||
});
|
||||
|
||||
} else {
|
||||
console.warn(`[useRenderers] transport_state inconnu: ${event.state}`);
|
||||
}
|
||||
break;
|
||||
|
||||
case "position_changed":
|
||||
@@ -127,35 +140,36 @@ function ensureSSEInitialized() {
|
||||
|
||||
// Convertir rel_time (HH:MM:SS) en millisecondes
|
||||
const positionMs = parseTimeToMs(event.rel_time ?? null);
|
||||
snapshot.state.position_ms = positionMs ?? 0;
|
||||
|
||||
// Convertir track_duration (HH:MM:SS) en millisecondes
|
||||
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 = {
|
||||
|
||||
// Créer un nouvel objet pour déclencher la réactivité
|
||||
snapshots.set(rendererId, {
|
||||
...snapshot,
|
||||
state: newState,
|
||||
};
|
||||
snapshotState.snapshots.set(rendererId, newSnapshot);
|
||||
state: {
|
||||
...snapshot.state,
|
||||
position_ms: positionMs ?? 0,
|
||||
duration_ms: durationMs,
|
||||
},
|
||||
});
|
||||
|
||||
break;
|
||||
|
||||
case "volume_changed":
|
||||
snapshot.state.volume = event.volume;
|
||||
// Créer un nouvel objet pour déclencher la réactivité
|
||||
snapshots.set(rendererId, {
|
||||
...snapshot,
|
||||
state: { ...snapshot.state, volume: event.volume },
|
||||
});
|
||||
break;
|
||||
|
||||
case "mute_changed":
|
||||
snapshot.state.mute = event.mute;
|
||||
// Créer un nouvel objet pour déclencher la réactivité
|
||||
snapshots.set(rendererId, {
|
||||
...snapshot,
|
||||
state: { ...snapshot.state, mute: event.mute },
|
||||
});
|
||||
break;
|
||||
|
||||
case "metadata_changed":
|
||||
@@ -172,20 +186,24 @@ function ensureSSEInitialized() {
|
||||
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, {
|
||||
snapshots.set(rendererId, {
|
||||
...snapshot,
|
||||
state: { ...snapshot.state },
|
||||
});
|
||||
break;
|
||||
|
||||
case "queue_refreshing":
|
||||
snapshotState.queueRefreshingIds.add(rendererId);
|
||||
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
|
||||
queueRefreshingIds.delete(rendererId);
|
||||
|
||||
// Fetch queue items immediately when we get queue_updated
|
||||
// The debounce was causing race conditions where queue_len was updated
|
||||
// but items weren't fetched yet when user clicked play
|
||||
void fetchRendererSnapshot(rendererId, { force: true });
|
||||
break;
|
||||
|
||||
@@ -201,10 +219,17 @@ function ensureSSEInitialized() {
|
||||
snapshot.binding = null;
|
||||
snapshot.state.attached_playlist = null;
|
||||
}
|
||||
// Créer un nouvel objet pour déclencher la réactivité
|
||||
snapshots.set(rendererId, {
|
||||
...snapshot,
|
||||
state: { ...snapshot.state },
|
||||
});
|
||||
break;
|
||||
|
||||
case "stream_state_changed":
|
||||
snapshot.is_stream = event.is_stream;
|
||||
// Créer un nouvel objet pour déclencher la réactivité
|
||||
snapshots.set(rendererId, { ...snapshot });
|
||||
break;
|
||||
|
||||
case "timer_started":
|
||||
@@ -217,8 +242,8 @@ function ensureSSEInitialized() {
|
||||
break;
|
||||
}
|
||||
|
||||
// Trigger reactivity
|
||||
snapshotState.snapshots.set(rendererId, snapshot);
|
||||
// Note: chaque case est maintenant responsable de stocker le snapshot dans la Map
|
||||
// Plus de réassignation finale après le switch
|
||||
});
|
||||
|
||||
sseInitialized = true;
|
||||
@@ -229,7 +254,7 @@ const onlineRenderers = computed(() =>
|
||||
allRenderers.value.filter((r) => r.online),
|
||||
);
|
||||
const allSnapshots = computed(() =>
|
||||
Array.from(snapshotState.snapshots.values()),
|
||||
Array.from(snapshots.values()),
|
||||
);
|
||||
const playingRenderers = computed(() =>
|
||||
allSnapshots.value
|
||||
@@ -242,57 +267,73 @@ function getRendererById(id: string) {
|
||||
}
|
||||
|
||||
function getSnapshotById(id: string) {
|
||||
return snapshotState.snapshots.get(id) ?? null;
|
||||
return snapshots.get(id) ?? null;
|
||||
}
|
||||
|
||||
function getStateById(id: string): RendererState | null {
|
||||
return snapshotState.snapshots.get(id)?.state ?? null;
|
||||
return snapshots.get(id)?.state ?? null;
|
||||
}
|
||||
|
||||
function getQueueById(id: string): QueueSnapshot | null {
|
||||
return snapshotState.snapshots.get(id)?.queue ?? null;
|
||||
return snapshots.get(id)?.queue ?? null;
|
||||
}
|
||||
|
||||
function getBindingById(id: string): AttachedPlaylistInfo | null {
|
||||
return snapshotState.snapshots.get(id)?.binding ?? null;
|
||||
return snapshots.get(id)?.binding ?? null;
|
||||
}
|
||||
|
||||
function isSnapshotLoading(id: string) {
|
||||
return snapshotState.loadingIds.has(id);
|
||||
return loadingIds.has(id);
|
||||
}
|
||||
|
||||
function isQueueRefreshing(id: string) {
|
||||
return snapshotState.queueRefreshingIds.has(id);
|
||||
return queueRefreshingIds.has(id);
|
||||
}
|
||||
|
||||
function selectRenderer(id: string | null) {
|
||||
snapshotState.selectedRendererId = id;
|
||||
selectedRendererId.value = id;
|
||||
}
|
||||
|
||||
async function fetchRenderers(force = false) {
|
||||
async function fetchRenderers(force = false, retries = 2) {
|
||||
ensureSSEInitialized();
|
||||
|
||||
// Créer le store UI pour les notifications (lazy import pour éviter les effets de bord)
|
||||
const uiStore = useUIStore();
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
try {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
// 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]),
|
||||
);
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Erreur fetch renderers";
|
||||
console.error("[useRenderers] Erreur fetch:", err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
// 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]),
|
||||
);
|
||||
return;
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error("Erreur fetch renderers");
|
||||
console.error("[useRenderers] Erreur fetch (attempt " + (attempt + 1) + "):", lastError);
|
||||
if (attempt < retries) {
|
||||
await new Promise(r => setTimeout(r, 500 * (attempt + 1)));
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
error.value = lastError?.message ?? "Erreur fetch renderers";
|
||||
|
||||
// Notifier l'utilisateur en cas d'erreur finale
|
||||
uiStore.notifyError("Impossible de rafraîchir la liste des lecteurs audio");
|
||||
}
|
||||
|
||||
async function fetchRendererSnapshot(
|
||||
@@ -301,29 +342,44 @@ async function fetchRendererSnapshot(
|
||||
) {
|
||||
ensureSSEInitialized();
|
||||
const force = opts?.force ?? false;
|
||||
const hasSnapshot = snapshotState.snapshots.has(rendererId);
|
||||
const hasSnapshot = snapshots.has(rendererId);
|
||||
|
||||
if (!force && hasSnapshot) {
|
||||
const lastSnapshot = snapshotState.lastSnapshotAt.get(rendererId) ?? 0;
|
||||
const lastEvent = snapshotState.lastEventAt.get(rendererId) ?? 0;
|
||||
const lastSnapshot = lastSnapshotAt.get(rendererId) ?? 0;
|
||||
const lastEvent = lastEventAt.get(rendererId) ?? 0;
|
||||
if (lastEvent <= lastSnapshot) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (snapshotState.loadingIds.has(rendererId)) {
|
||||
// Éviter les requêtes multiples simultanées pour le même renderer
|
||||
if (loadingIds.has(rendererId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
snapshotState.loadingIds.add(rendererId);
|
||||
loadingIds.add(rendererId);
|
||||
|
||||
|
||||
// Lazy load UI store pour les notifications
|
||||
const uiStore = useUIStore();
|
||||
|
||||
try {
|
||||
const snapshot = await api.getRendererFullSnapshot(rendererId);
|
||||
snapshotState.snapshots.set(rendererId, snapshot);
|
||||
snapshotState.lastSnapshotAt.set(rendererId, Date.now());
|
||||
snapshots.set(rendererId, snapshot);
|
||||
lastSnapshotAt.set(rendererId, Date.now());
|
||||
|
||||
} catch (err) {
|
||||
console.error(`[useRenderers] Erreur snapshot ${rendererId}:`, err);
|
||||
// En cas d'erreur, on supprime le snapshot pour permettre une nouvelle tentative
|
||||
snapshots.delete(rendererId);
|
||||
|
||||
// Notifier l'utilisateur
|
||||
const name = renderersCache.value.get(rendererId)?.friendly_name ?? rendererId;
|
||||
uiStore.notifyError(`Impossible de récupérer l'état de « ${name} »`);
|
||||
} finally {
|
||||
snapshotState.loadingIds.delete(rendererId);
|
||||
// Toujours nettoyer le flag de chargement
|
||||
loadingIds.delete(rendererId);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,7 +429,7 @@ async function play(id: string) {
|
||||
}
|
||||
|
||||
async function resumeOrPlayFromQueue(id: string) {
|
||||
const snapshot = snapshotState.snapshots.get(id);
|
||||
const snapshot = snapshots.get(id);
|
||||
if (!snapshot) {
|
||||
throw new Error(`Renderer ${id} non trouvé`);
|
||||
}
|
||||
@@ -383,6 +439,7 @@ async function resumeOrPlayFromQueue(id: string) {
|
||||
return play(id);
|
||||
}
|
||||
|
||||
// Check if queue has content
|
||||
if (
|
||||
["STOPPED", "NO_MEDIA"].includes(state.transport_state) &&
|
||||
snapshot.queue.items.length > 0
|
||||
@@ -495,7 +552,6 @@ export function useRenderers() {
|
||||
isSnapshotLoading,
|
||||
isQueueRefreshing,
|
||||
selectRenderer,
|
||||
snapshotState,
|
||||
// Fetchers
|
||||
fetchRenderers,
|
||||
fetchRendererSnapshot,
|
||||
@@ -519,6 +575,8 @@ export function useRenderers() {
|
||||
playContent,
|
||||
addToQueue,
|
||||
addAfterCurrent,
|
||||
// SSE
|
||||
resetSSE,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -534,10 +592,33 @@ export function useRenderer(rendererId: Ref<string>) {
|
||||
const isStream = computed(() => snapshot.value?.is_stream ?? false);
|
||||
const queueRefreshing = computed(() => isQueueRefreshing(rendererId.value));
|
||||
|
||||
// Debounce pour éviter les refreshs multiples trop fréquents
|
||||
let refreshDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const REFRESH_DEBOUNCE_MS = 500;
|
||||
|
||||
// Nettoyer le timer debounce si le composant est démonté
|
||||
onUnmounted(() => {
|
||||
if (refreshDebounceTimer !== null) {
|
||||
clearTimeout(refreshDebounceTimer);
|
||||
refreshDebounceTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function refresh(force = true) {
|
||||
const currentRendererId = rendererId.value;
|
||||
|
||||
// Debounce: ignorer si un refresh est en cours pour ce renderer
|
||||
if (refreshDebounceTimer !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
refreshDebounceTimer = setTimeout(() => {
|
||||
refreshDebounceTimer = null;
|
||||
}, REFRESH_DEBOUNCE_MS);
|
||||
|
||||
await Promise.all([
|
||||
fetchRenderers(force),
|
||||
fetchRendererSnapshot(rendererId.value, { force: true }),
|
||||
fetchRendererSnapshot(currentRendererId, { force: true }),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,13 +17,17 @@ import type {
|
||||
} from '../services/pmocontrol/types'
|
||||
|
||||
// État global partagé
|
||||
const connected = ref(sse.isConnectedState())
|
||||
const connectionCallbacks: Set<(connected: boolean) => void> = new Set()
|
||||
const connected = ref(sse.isConnectedState());
|
||||
const connectionCallbacks: Set<(connected: boolean) => void> = new Set();
|
||||
|
||||
// Flag pour éviter les double-connexions SSE avec lock
|
||||
let connectionLock = false;
|
||||
|
||||
// Abonnement à l'état de connexion global
|
||||
function setupConnectionListener() {
|
||||
// S'assurer qu'on ne s'abonne qu'une seule fois
|
||||
if (connectionCallbacks.size === 0) {
|
||||
// Vérifier avec lock pour éviter les conditions de course
|
||||
if (connectionCallbacks.size === 0 && !connectionLock) {
|
||||
connectionLock = true;
|
||||
sse.onConnectionChange((isConnected) => {
|
||||
connected.value = isConnected
|
||||
connectionCallbacks.forEach(cb => cb(isConnected))
|
||||
|
||||
@@ -43,6 +43,10 @@ const state = reactive<TabsState>({
|
||||
// Flag pour éviter les boucles de sauvegarde
|
||||
let isRestoringFromStorage = false;
|
||||
|
||||
// Debounce timer pour la sauvegarde localStorage
|
||||
let saveDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const SAVE_DEBOUNCE_MS = 100;
|
||||
|
||||
/**
|
||||
* Retourne le titre complet sans troncature
|
||||
* Note: On laisse le CSS gérer l'overflow avec ellipsis pour un affichage stable
|
||||
@@ -53,29 +57,38 @@ function truncateTitle(title: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sauvegarde l'état dans localStorage
|
||||
* Sauvegarde l'état dans localStorage (avec debounce)
|
||||
* Note: On ne sauvegarde que les onglets server (les renderer tabs sont auto-générés)
|
||||
*/
|
||||
function saveToLocalStorage() {
|
||||
if (isRestoringFromStorage) return;
|
||||
|
||||
try {
|
||||
const stateToSave = {
|
||||
// Sauvegarder uniquement les onglets server (fermables manuellement)
|
||||
tabs: state.tabs
|
||||
.filter((tab) => tab.type === "server")
|
||||
.map((tab) => ({
|
||||
...tab,
|
||||
// On ne peut pas sauvegarder les composants Vue, on sauve juste le type
|
||||
icon: undefined,
|
||||
})),
|
||||
activeTabId: state.activeTabId,
|
||||
tabHistory: state.tabHistory,
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave));
|
||||
} catch (error) {
|
||||
console.error("[useTabs] Erreur sauvegarde localStorage:", error);
|
||||
// Annuler le timer précédent
|
||||
if (saveDebounceTimer !== null) {
|
||||
clearTimeout(saveDebounceTimer);
|
||||
}
|
||||
|
||||
// Débouncer pour éviter les écritures multiples
|
||||
saveDebounceTimer = setTimeout(() => {
|
||||
try {
|
||||
const stateToSave = {
|
||||
// Sauvegarder uniquement les onglets server (fermables manuellement)
|
||||
tabs: state.tabs
|
||||
.filter((tab) => tab.type === "server")
|
||||
.map((tab) => ({
|
||||
...tab,
|
||||
// On ne peut pas sauvegarder les composants Vue, on sauve juste le type
|
||||
icon: undefined,
|
||||
})),
|
||||
activeTabId: state.activeTabId,
|
||||
tabHistory: state.tabHistory,
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave));
|
||||
} catch (error) {
|
||||
console.error("[useTabs] Erreur sauvegarde localStorage:", error);
|
||||
}
|
||||
saveDebounceTimer = null;
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,11 +96,11 @@ function saveToLocalStorage() {
|
||||
* Note: Restaure uniquement les onglets server (les renderer tabs seront auto-générés)
|
||||
*/
|
||||
function restoreFromLocalStorage() {
|
||||
isRestoringFromStorage = true;
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (!saved) return;
|
||||
|
||||
isRestoringFromStorage = true;
|
||||
const savedState = JSON.parse(saved);
|
||||
|
||||
// Reconstituer uniquement les tabs server avec les bonnes icônes
|
||||
@@ -110,10 +123,9 @@ function restoreFromLocalStorage() {
|
||||
if (!state.tabs.find((t) => t.id === state.activeTabId)) {
|
||||
state.activeTabId = "";
|
||||
}
|
||||
|
||||
isRestoringFromStorage = false;
|
||||
} catch (error) {
|
||||
console.error("[useTabs] Erreur restauration localStorage:", error);
|
||||
} finally {
|
||||
isRestoringFromStorage = false;
|
||||
}
|
||||
}
|
||||
@@ -346,12 +358,18 @@ function openServer(server: MediaServerSummary | undefined) {
|
||||
*/
|
||||
export function useTabs() {
|
||||
// Watch pour sauvegarde automatique (uniquement les server tabs)
|
||||
// Watch séparés sans deep pour éviter la sérialisation complète à chaque mutation
|
||||
watch(() => state.activeTabId, () => {
|
||||
saveToLocalStorage();
|
||||
});
|
||||
watch(() => state.tabHistory.length, () => {
|
||||
saveToLocalStorage();
|
||||
});
|
||||
watch(
|
||||
() => [state.tabs, state.activeTabId, state.tabHistory],
|
||||
() => state.tabs.map(t => t.id + t.type + (t.metadata?.rendererId ?? '') + (t.metadata?.serverId ?? '')).join('|'),
|
||||
() => {
|
||||
saveToLocalStorage();
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
// Restaurer au montage (uniquement les server tabs)
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
/**
|
||||
* Composable pour gérer le WebRenderer navigateur.
|
||||
*
|
||||
* S'enregistre via POST /api/webrenderer/register au montage
|
||||
* et diffuse l'audio via un élément <audio> pointant sur
|
||||
* /api/webrenderer/{id}/stream (flux FLAC encodé côté serveur).
|
||||
*
|
||||
* Le navigateur est vu comme un renderer UPnP par le ControlPoint.
|
||||
* Le gapless est géré côté serveur : le navigateur lit un flux continu.
|
||||
*
|
||||
* Cycle de vie du flux audio :
|
||||
* - PLAYING/TRANSITIONING → src = stream_url + play() (nouvelle connexion HTTP)
|
||||
* - PAUSED/STOPPED → pause() + src = "" (déconnexion HTTP)
|
||||
* Utilise PMOPlayer pour le controle remote.
|
||||
* S'enregistre via POST /api/webrenderer/register
|
||||
* Utilise polling HTTP pour les commands
|
||||
*/
|
||||
|
||||
import { ref, onMounted, onUnmounted, readonly } from "vue";
|
||||
import { useSSE } from "./useSSE";
|
||||
|
||||
// ─── Identifiant stable de l'instance navigateur ─────────────────────────────
|
||||
|
||||
const INSTANCE_ID_KEY = "pmomusic_webrenderer_instance_id";
|
||||
import { PMOPlayer } from "@/services/PMOPlayer";
|
||||
|
||||
function generateUUID(): string {
|
||||
if (typeof crypto.randomUUID === "function") {
|
||||
@@ -32,6 +21,36 @@ function generateUUID(): string {
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
|
||||
const INSTANCE_ID_KEY = "pmomusic_webrenderer_instance_id";
|
||||
const RENDERER_UDN_KEY = "pmomusic_webrenderer_udn";
|
||||
|
||||
// Load persisted UDN on module load
|
||||
function loadPersistedUdn(): string | null {
|
||||
try {
|
||||
return sessionStorage.getItem(RENDERER_UDN_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function savePersistedUdn(udn: string) {
|
||||
try {
|
||||
sessionStorage.setItem(RENDERER_UDN_KEY, udn);
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
// Module-level singleton for PMOPlayer to prevent duplicate instances
|
||||
let globalPlayer: PMOPlayer | null = null;
|
||||
let globalInstanceId: string | null = null;
|
||||
let registering = false;
|
||||
|
||||
// Module-level reactive state shared across all composable invocations
|
||||
const sharedConnected = ref(false);
|
||||
const sharedStreamUrl = ref<string | null>(null);
|
||||
const sharedRendererUdn = ref<string | null>(loadPersistedUdn());
|
||||
|
||||
function getOrCreateInstanceId(): string {
|
||||
try {
|
||||
let id = sessionStorage.getItem(INSTANCE_ID_KEY);
|
||||
@@ -45,146 +64,60 @@ function getOrCreateInstanceId(): string {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface RegisterRequest {
|
||||
instance_id: string;
|
||||
user_agent: string;
|
||||
}
|
||||
|
||||
interface RegisterResponse {
|
||||
stream_url: string;
|
||||
udn: string;
|
||||
}
|
||||
|
||||
// ─── Composable ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function useWebRenderer() {
|
||||
const connected = ref(false);
|
||||
const streamUrl = ref<string | null>(null);
|
||||
/** UDN du device UPnP créé côté serveur pour ce navigateur */
|
||||
const rendererUdn = ref<string | null>(null);
|
||||
const connected = sharedConnected;
|
||||
const streamUrl = sharedStreamUrl;
|
||||
const rendererUdn = sharedRendererUdn;
|
||||
|
||||
let audioEl: HTMLAudioElement | null = null;
|
||||
let instanceId: string | null = null;
|
||||
let currentStreamUrl: string | null = null;
|
||||
let player: PMOPlayer | null = null;
|
||||
let onConnectedCallback: (() => void) | null = null;
|
||||
let sseUnsubscribe: (() => void) | null = null;
|
||||
let pendingCanPlay: (() => void) | null = null;
|
||||
let positionInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// ── Reporting de position ─────────────────────────────────────────────────
|
||||
|
||||
function startPositionReporting(): void {
|
||||
stopPositionReporting();
|
||||
positionInterval = setInterval(() => {
|
||||
if (!audioEl || !instanceId) return;
|
||||
const pos = audioEl.currentTime;
|
||||
const dur = isFinite(audioEl.duration) ? audioEl.duration : null;
|
||||
void fetch(`/api/webrenderer/${instanceId}/position`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ position_sec: pos, duration_sec: dur }),
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function stopPositionReporting(): void {
|
||||
if (positionInterval !== null) {
|
||||
clearInterval(positionInterval);
|
||||
positionInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Connexion / déconnexion du flux audio ─────────────────────────────────
|
||||
|
||||
function startStream(): void {
|
||||
console.log("[WebRenderer] startStream called, audioEl=", !!audioEl, "currentStreamUrl=", currentStreamUrl);
|
||||
if (!audioEl || !currentStreamUrl) return;
|
||||
const el = audioEl;
|
||||
// Si le stream est en erreur (networkState=3), réinitialiser avant de réessayer
|
||||
if (el.networkState === 3 /* NETWORK_NO_SOURCE */ && !pendingCanPlay) {
|
||||
console.log("[WebRenderer] startStream: networkState=3, resetting before retry");
|
||||
el.removeAttribute("src");
|
||||
el.load();
|
||||
}
|
||||
// Si le stream est déjà chargé/en cours, ne pas réouvrir la connexion HTTP.
|
||||
// (évite que PLAYING après TRANSITIONING ne crée un nouveau pipe)
|
||||
if (el.hasAttribute("src") && (el.readyState > 0 || pendingCanPlay)) {
|
||||
console.log("[WebRenderer] startStream: stream already open, ignoring (readyState=", el.readyState, ")");
|
||||
return;
|
||||
}
|
||||
// Sur Safari, play() échoue avec NotSupportedError si appelé avant que
|
||||
// l'élément audio ait reçu assez de données (readyState < HAVE_FUTURE_DATA).
|
||||
// On attend canplay avant d'appeler play().
|
||||
if (pendingCanPlay) {
|
||||
el.removeEventListener("canplay", pendingCanPlay);
|
||||
}
|
||||
el.src = currentStreamUrl;
|
||||
console.log("[WebRenderer] src set, readyState=", el.readyState, "networkState=", el.networkState);
|
||||
|
||||
el.addEventListener("error", () => {
|
||||
console.error("[WebRenderer] event:error code=", el.error?.code, el.error?.message,
|
||||
"readyState=", el.readyState, "networkState=", el.networkState);
|
||||
// Nettoyer pendingCanPlay pour permettre un retry au prochain PLAYING/TRANSITIONING
|
||||
if (pendingCanPlay) {
|
||||
el.removeEventListener("canplay", pendingCanPlay);
|
||||
pendingCanPlay = null;
|
||||
}
|
||||
}, { once: true });
|
||||
el.addEventListener("loadstart", () => console.debug("[WebRenderer] event:loadstart readyState=", el.readyState), { once: true });
|
||||
el.addEventListener("loadedmetadata", () => console.debug("[WebRenderer] event:loadedmetadata readyState=", el.readyState), { once: true });
|
||||
el.addEventListener("loadeddata", () => console.debug("[WebRenderer] event:loadeddata readyState=", el.readyState), { once: true });
|
||||
el.addEventListener("progress", () => console.debug("[WebRenderer] event:progress readyState=", el.readyState), { once: true });
|
||||
el.addEventListener("stalled", () => console.warn("[WebRenderer] event:stalled readyState=", el.readyState, "networkState=", el.networkState));
|
||||
el.addEventListener("waiting", () => console.warn("[WebRenderer] event:waiting readyState=", el.readyState));
|
||||
el.addEventListener("suspend", () => console.debug("[WebRenderer] event:suspend readyState=", el.readyState, "networkState=", el.networkState), { once: true });
|
||||
el.addEventListener("abort", () => console.warn("[WebRenderer] event:abort"), { once: true });
|
||||
el.addEventListener("emptied", () => console.warn("[WebRenderer] event:emptied"), { once: true });
|
||||
|
||||
const onCanPlay = () => {
|
||||
console.log("[WebRenderer] event:canplay readyState=", el.readyState, "calling play()");
|
||||
pendingCanPlay = null;
|
||||
el.removeEventListener("canplay", onCanPlay);
|
||||
el.play().then(() => {
|
||||
console.log("[WebRenderer] play() resolved OK");
|
||||
startPositionReporting();
|
||||
}).catch((e: unknown) => {
|
||||
console.warn("[WebRenderer] play() rejected:", e);
|
||||
});
|
||||
};
|
||||
pendingCanPlay = onCanPlay;
|
||||
el.addEventListener("canplay", onCanPlay);
|
||||
}
|
||||
|
||||
function stopStream(): void {
|
||||
console.log("[WebRenderer] stopStream called, readyState=", audioEl?.readyState);
|
||||
stopPositionReporting();
|
||||
if (!audioEl) return;
|
||||
if (pendingCanPlay) {
|
||||
audioEl.removeEventListener("canplay", pendingCanPlay);
|
||||
pendingCanPlay = null;
|
||||
}
|
||||
audioEl.pause();
|
||||
audioEl.removeAttribute("src"); // ferme la connexion HTTP (src="" résolu comme URL de page sur Safari)
|
||||
audioEl.load(); // force le reset de l'état réseau
|
||||
}
|
||||
|
||||
// ── Enregistrement ────────────────────────────────────────────────────────
|
||||
|
||||
async function register(): Promise<void> {
|
||||
instanceId = getOrCreateInstanceId();
|
||||
// Allow re-registration if we have a stale player but no connection
|
||||
if (globalPlayer && !connected.value) {
|
||||
console.log('[WebRenderer] stale globalPlayer, clearing');
|
||||
globalPlayer = null;
|
||||
}
|
||||
|
||||
if (globalPlayer) {
|
||||
player = globalPlayer;
|
||||
connected.value = true;
|
||||
console.log('[WebRenderer] reusing existing player');
|
||||
return;
|
||||
}
|
||||
|
||||
const body: RegisterRequest = {
|
||||
instance_id: instanceId,
|
||||
user_agent: navigator.userAgent,
|
||||
};
|
||||
if (registering) {
|
||||
console.log('[WebRenderer] already registering, waiting...');
|
||||
// Wait a bit and try once
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
if (globalPlayer) {
|
||||
player = globalPlayer;
|
||||
connected.value = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
registering = true;
|
||||
|
||||
let instanceId;
|
||||
try {
|
||||
instanceId = getOrCreateInstanceId();
|
||||
} catch (e) {
|
||||
console.error('[WebRenderer] failed to get instanceId:', e);
|
||||
registering = false;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[WebRenderer] registering with instanceId:', instanceId);
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/webrenderer/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
body: JSON.stringify({
|
||||
instance_id: instanceId,
|
||||
user_agent: navigator.userAgent,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
@@ -192,98 +125,90 @@ export function useWebRenderer() {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await resp.json()) as RegisterResponse;
|
||||
const data = await resp.json();
|
||||
streamUrl.value = data.stream_url;
|
||||
currentStreamUrl = data.stream_url;
|
||||
rendererUdn.value = data.udn;
|
||||
savePersistedUdn(data.udn);
|
||||
|
||||
player = new PMOPlayer(instanceId);
|
||||
globalPlayer = player;
|
||||
globalInstanceId = instanceId;
|
||||
player.setDebug(true);
|
||||
|
||||
player.on('play', () => console.log('[WebRenderer] playing'));
|
||||
player.on('pause', () => console.log('[WebRenderer] paused'));
|
||||
|
||||
// Si le backend est déjà en lecture (reconnexion après reload), démarrer immédiatement
|
||||
if (data.should_play && data.stream_url) {
|
||||
console.log('[WebRenderer] backend already playing, starting stream');
|
||||
player.playStream(data.stream_url);
|
||||
}
|
||||
|
||||
connected.value = true;
|
||||
onConnectedCallback?.();
|
||||
|
||||
// S'abonner aux événements SSE du renderer pour piloter la lecture
|
||||
const { connect, onRendererEvent } = useSSE();
|
||||
connect();
|
||||
const udn = data.udn;
|
||||
sseUnsubscribe?.();
|
||||
sseUnsubscribe = onRendererEvent((event) => {
|
||||
if (event.renderer_id !== udn) return;
|
||||
if (event.type !== "state_changed") return;
|
||||
|
||||
const state = event.state;
|
||||
console.log("[WebRenderer] SSE state_changed →", state, "| event.renderer_id=", event.renderer_id, "udn=", udn, "| audioEl.src=", audioEl?.src, "readyState=", audioEl?.readyState, "networkState=", audioEl?.networkState, "pendingCanPlay=", !!pendingCanPlay);
|
||||
if (state === "PLAYING" || state === "TRANSITIONING") {
|
||||
startStream();
|
||||
} else if (state === "PAUSED" || state === "STOPPED") {
|
||||
stopStream();
|
||||
}
|
||||
});
|
||||
|
||||
// Force refresh renderers list after registration
|
||||
// The SSE might miss the initial event
|
||||
setTimeout(() => {
|
||||
console.log('[WebRenderer] forcing renderers refresh');
|
||||
window.dispatchEvent(new CustomEvent('webrenderer-registered'));
|
||||
}, 500);
|
||||
} catch (e) {
|
||||
console.error("[WebRenderer] register error:", e);
|
||||
} finally {
|
||||
registering = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Désenregistrement ─────────────────────────────────────────────────────
|
||||
|
||||
async function unregister(): Promise<void> {
|
||||
if (!instanceId) return;
|
||||
// Only unregister if this is the global player - prevent duplicate unregister calls
|
||||
if (player !== globalPlayer || !player) {
|
||||
return;
|
||||
}
|
||||
|
||||
const instanceId = globalInstanceId || getOrCreateInstanceId();
|
||||
|
||||
// Clear global first to prevent other components from using it
|
||||
globalPlayer = null;
|
||||
globalInstanceId = null;
|
||||
|
||||
try {
|
||||
await fetch(`/api/webrenderer/${instanceId}`, { method: "DELETE" });
|
||||
} catch {
|
||||
// Ignoré lors du déchargement de page
|
||||
// Ignored
|
||||
}
|
||||
instanceId = null;
|
||||
player?.destroy();
|
||||
player = null;
|
||||
}
|
||||
|
||||
// ── Volume / Mute ─────────────────────────────────────────────────────────
|
||||
|
||||
function setVolume(v: number): void {
|
||||
if (audioEl) audioEl.volume = v;
|
||||
function setVolume(_v: number): void {
|
||||
// PMOPlayer doesn't control volume directly - handled by backend
|
||||
}
|
||||
|
||||
function setMute(m: boolean): void {
|
||||
if (audioEl) audioEl.muted = m;
|
||||
function setMute(_m: boolean): void {
|
||||
// PMOPlayer doesn't control mute directly - handled by backend
|
||||
}
|
||||
|
||||
// ── Cycle de vie ──────────────────────────────────────────────────────────
|
||||
const beforeUnloadHandler = () => void unregister();
|
||||
|
||||
onMounted(() => {
|
||||
audioEl = document.createElement("audio");
|
||||
audioEl.preload = "auto";
|
||||
document.body.appendChild(audioEl);
|
||||
|
||||
void register();
|
||||
window.addEventListener("beforeunload", () => void unregister());
|
||||
window.addEventListener("beforeunload", beforeUnloadHandler);
|
||||
});
|
||||
|
||||
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());
|
||||
window.removeEventListener("beforeunload", beforeUnloadHandler);
|
||||
});
|
||||
|
||||
// ── 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,9 @@ import { sse } from "./services/pmocontrol/sse";
|
||||
// Store UI (garde UIStore pour les notifications et état UI global)
|
||||
import { useUIStore } from "./stores/ui";
|
||||
|
||||
// Image cache (pour cleanup)
|
||||
import { imageCache } from "./composables/imageCache";
|
||||
|
||||
// Styles
|
||||
import "./style.css";
|
||||
import "./assets/styles/variables.css";
|
||||
@@ -24,12 +27,13 @@ const pinia = createPinia();
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
|
||||
// Initialiser UIStore AVANT le montage pour éviter la race condition (P2)
|
||||
const uiStore = useUIStore();
|
||||
|
||||
// Monter l'application
|
||||
app.mount("#app");
|
||||
|
||||
// Après montage, initialiser SSE
|
||||
const uiStore = useUIStore();
|
||||
|
||||
// Les composables se connectent automatiquement à SSE
|
||||
// Ils gèrent eux-mêmes le re-fetch lors des événements
|
||||
|
||||
@@ -39,3 +43,8 @@ sse.onConnectionChange((connected) => {
|
||||
|
||||
// Démarrer la connexion SSE
|
||||
sse.connect();
|
||||
|
||||
// Cleanup global lors du unload de la page
|
||||
window.addEventListener('beforeunload', () => {
|
||||
imageCache.destroy();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import type { RouteRecordRaw } from "vue-router";
|
||||
|
||||
// PMOControl Unified View (nouvelle interface unifiée)
|
||||
import UnifiedControlView from "../views/UnifiedControlView.vue";
|
||||
@@ -8,18 +9,10 @@ import DashboardView from "../views/DashboardView.vue";
|
||||
import RendererView from "../views/RendererView.vue";
|
||||
import MediaServerView from "../views/MediaServerView.vue";
|
||||
|
||||
// Debug Components (anciennes routes)
|
||||
import GenericMusicPlayer from "../components/GenericMusicPlayer.vue";
|
||||
import LogView from "../components/LogView.vue";
|
||||
import CoverCacheManager from "../components/CoverCacheManager.vue";
|
||||
import AudioCacheManager from "../components/AudioCacheManager.vue";
|
||||
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";
|
||||
// Debug Components - lazy loaded uniquement en mode développement (P8)
|
||||
const isDev = import.meta.env.DEV;
|
||||
|
||||
const routes = [
|
||||
const routes: RouteRecordRaw[] = [
|
||||
// PMOControl Unified Interface (nouvelle interface unifiée avec onglets)
|
||||
{
|
||||
path: "/",
|
||||
@@ -43,57 +36,65 @@ const routes = [
|
||||
name: "MediaServer",
|
||||
component: MediaServerView,
|
||||
},
|
||||
|
||||
// Debug hub
|
||||
{
|
||||
path: "/debug",
|
||||
name: "Debug",
|
||||
component: DebugView,
|
||||
},
|
||||
|
||||
// Debug menu (anciennes routes déplacées sous /debug)
|
||||
{
|
||||
path: "/debug/generic-player",
|
||||
name: "GenericPlayer",
|
||||
component: GenericMusicPlayer,
|
||||
},
|
||||
{
|
||||
path: "/debug/logs",
|
||||
name: "Logs",
|
||||
component: LogView,
|
||||
},
|
||||
{
|
||||
path: "/debug/covers-cache",
|
||||
name: "CoversCache",
|
||||
component: CoverCacheManager,
|
||||
},
|
||||
{
|
||||
path: "/debug/audio-cache",
|
||||
name: "AudioCache",
|
||||
component: AudioCacheManager,
|
||||
},
|
||||
{
|
||||
path: "/debug/playlists",
|
||||
name: "PlaylistsManager",
|
||||
component: PlayListManager,
|
||||
},
|
||||
{
|
||||
path: "/debug/upnp",
|
||||
name: "UpnpExplorer",
|
||||
component: UpnpExplorer,
|
||||
},
|
||||
{
|
||||
path: "/debug/api-dashboard",
|
||||
name: "APIDashboard",
|
||||
component: APIDashboard,
|
||||
},
|
||||
{
|
||||
path: "/debug/radio-paradise",
|
||||
name: "RadioParadise",
|
||||
component: RadioParadiseExplorer,
|
||||
},
|
||||
];
|
||||
|
||||
// Ajouter les routes de debug uniquement en développement
|
||||
if (isDev) {
|
||||
routes.push(
|
||||
{
|
||||
path: "/debug",
|
||||
name: "Debug",
|
||||
component: () => import("../views/DebugView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/debug/generic-player",
|
||||
name: "GenericPlayer",
|
||||
component: () => import("../components/GenericMusicPlayer.vue"),
|
||||
},
|
||||
{
|
||||
path: "/debug/logs",
|
||||
name: "Logs",
|
||||
component: () => import("../components/LogView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/debug/covers-cache",
|
||||
name: "CoversCache",
|
||||
component: () => import("../components/CoverCacheManager.vue"),
|
||||
},
|
||||
{
|
||||
path: "/debug/audio-cache",
|
||||
name: "AudioCache",
|
||||
component: () => import("../components/AudioCacheManager.vue"),
|
||||
},
|
||||
{
|
||||
path: "/debug/playlists",
|
||||
name: "PlaylistsManager",
|
||||
component: () => import("../components/PlayListManager.vue"),
|
||||
},
|
||||
{
|
||||
path: "/debug/upnp",
|
||||
name: "UpnpExplorer",
|
||||
component: () => import("../components/UpnpExplorer.vue"),
|
||||
},
|
||||
{
|
||||
path: "/debug/api-dashboard",
|
||||
name: "APIDashboard",
|
||||
component: () => import("../components/APIDashboard.vue"),
|
||||
},
|
||||
{
|
||||
path: "/debug/radio-paradise",
|
||||
name: "RadioParadise",
|
||||
component: () => import("../components/RadioParadiseExplorer.vue"),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Wildcard redirect pour les routes inconnues
|
||||
routes.push({
|
||||
path: "/:pathMatch(.*)*",
|
||||
redirect: "/",
|
||||
});
|
||||
|
||||
const router = createRouter({
|
||||
// history avec base /app
|
||||
history: createWebHistory("/app"),
|
||||
|
||||
481
pmoapp/webapp/src/services/PMOPlayer.ts
Normal file
481
pmoapp/webapp/src/services/PMOPlayer.ts
Normal file
@@ -0,0 +1,481 @@
|
||||
/**
|
||||
* PMOPlayer - Invisible remote-controlled audio player for browser
|
||||
*
|
||||
* Receives commands from Web Media Renderer (backend)
|
||||
* Reports position/state back to backend via HTTP
|
||||
*
|
||||
* Usage:
|
||||
* const player = new PMOPlayer('my-instance-id');
|
||||
* player.on('play', () => console.log('playing'));
|
||||
*/
|
||||
|
||||
export type PlayerState = 'playing' | 'paused' | 'stopped' | 'buffering' | 'error';
|
||||
export type ReadyState = 'have_nothing' | 'have_metadata' | 'have_current_data' | 'have_future_data' | 'can_play' | 'can_play_through';
|
||||
|
||||
export interface PlayerEvents {
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
stop: () => void;
|
||||
flush: () => void;
|
||||
positionchange: (position_sec: number) => void;
|
||||
durationchange: (duration_sec: number) => void;
|
||||
statechange: (state: PlayerState) => void;
|
||||
trackchange: (track: TrackInfo) => void;
|
||||
readychange: (ready: ReadyState) => void;
|
||||
error: (error: string) => void;
|
||||
}
|
||||
|
||||
export interface TrackInfo {
|
||||
id: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
album?: string;
|
||||
cover?: string;
|
||||
}
|
||||
|
||||
// Types stricts pour les commandes reçues du backend (P6)
|
||||
interface StreamCommand {
|
||||
type: 'stream';
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface PlayCommand {
|
||||
type: 'play';
|
||||
}
|
||||
|
||||
interface PauseCommand {
|
||||
type: 'pause';
|
||||
}
|
||||
|
||||
interface SeekCommand {
|
||||
type: 'seek';
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface FlushCommand {
|
||||
type: 'flush';
|
||||
}
|
||||
|
||||
interface StopCommand {
|
||||
type: 'stop';
|
||||
}
|
||||
|
||||
type CommandMessage = StreamCommand | PlayCommand | PauseCommand | SeekCommand | FlushCommand | StopCommand;
|
||||
|
||||
function isValidCommand(msg: Record<string, unknown> | unknown): msg is CommandMessage {
|
||||
if (!msg || typeof msg !== 'object') return false;
|
||||
if (!('type' in msg)) return false;
|
||||
|
||||
const type = (msg as Record<string, unknown>).type;
|
||||
if (typeof type !== 'string') return false;
|
||||
|
||||
// Valider les champs selon le type
|
||||
switch (type) {
|
||||
case 'stream':
|
||||
return 'url' in msg && typeof (msg as StreamCommand).url === 'string';
|
||||
case 'seek':
|
||||
return 'timestamp' in msg && typeof (msg as SeekCommand).timestamp === 'number';
|
||||
case 'play':
|
||||
case 'pause':
|
||||
case 'flush':
|
||||
case 'stop':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export class PMOPlayer {
|
||||
private audio: HTMLAudioElement;
|
||||
private instanceId: string;
|
||||
private ac: AudioContext | null = null;
|
||||
|
||||
private state: PlayerState = 'stopped';
|
||||
private positionInterval: number | null = null;
|
||||
private commandInterval: number | null = null;
|
||||
private listeners: Partial<PlayerEvents> = {};
|
||||
private debug: boolean = false;
|
||||
private pendingPlay = false;
|
||||
private unlockListener: (() => void) | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private readonly MAX_RECONNECT_ATTEMPTS = 5;
|
||||
private reconnectTimeout: number | null = null;
|
||||
|
||||
constructor(instanceId: string) {
|
||||
this.instanceId = instanceId;
|
||||
console.log('[PMOPlayer] constructor called for:', instanceId);
|
||||
this.audio = new Audio();
|
||||
|
||||
this.audio.preload = 'auto';
|
||||
this.audio.style.display = 'none';
|
||||
this.audio.style.visibility = 'hidden';
|
||||
this.audio.style.position = 'absolute';
|
||||
this.audio.style.width = '0';
|
||||
this.audio.style.height = '0';
|
||||
this.audio.style.overflow = 'hidden';
|
||||
document.body.appendChild(this.audio);
|
||||
|
||||
this.setupAudioListeners();
|
||||
this.startCommandPolling();
|
||||
}
|
||||
|
||||
private ensureAudioContext(): AudioContext {
|
||||
if (!this.ac) {
|
||||
this.ac = new AudioContext();
|
||||
const source = this.ac.createMediaElementSource(this.audio);
|
||||
source.connect(this.ac.destination);
|
||||
}
|
||||
return this.ac;
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (this.reconnectAttempts >= this.MAX_RECONNECT_ATTEMPTS) {
|
||||
this.log('max reconnect attempts reached, giving up');
|
||||
this.setState('error');
|
||||
return;
|
||||
}
|
||||
const delayMs = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 16000);
|
||||
this.reconnectAttempts++;
|
||||
this.log(`reconnect attempt ${this.reconnectAttempts} in ${delayMs}ms`);
|
||||
this.reconnectTimeout = window.setTimeout(() => {
|
||||
const url = this.audio.getAttribute('data-stream-url');
|
||||
if (url) {
|
||||
this.stream(url);
|
||||
this.play();
|
||||
}
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
private log(...args: unknown[]) {
|
||||
if (this.debug) {
|
||||
console.log('[PMOPlayer]', ...args);
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchCommand() {
|
||||
try {
|
||||
const resp = await fetch(`/api/webrenderer/${this.instanceId}/command`);
|
||||
if (resp.status === 204) {
|
||||
// No command pending
|
||||
return;
|
||||
}
|
||||
if (resp.ok) {
|
||||
const text = await resp.text();
|
||||
if (text) {
|
||||
const cmd = JSON.parse(text);
|
||||
if (cmd && cmd.type) {
|
||||
this.log('fetched command', cmd);
|
||||
this.handleCommand(cmd);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.log('fetch command error', err);
|
||||
}
|
||||
}
|
||||
|
||||
private startCommandPolling() {
|
||||
this.commandInterval = window.setInterval(() => {
|
||||
// ATTENTION : ne pas conditionner ce poll à l'état courant.
|
||||
// L'état initial est 'stopped', et c'est dans cet état que le backend
|
||||
// envoie la première commande 'stream' pour démarrer la lecture.
|
||||
// Filtrer sur state !== 'stopped' casse le chemin nominal de démarrage.
|
||||
this.fetchCommand();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
private setupAudioListeners() {
|
||||
this.audio.addEventListener('play', () => {
|
||||
this.log('play event');
|
||||
this.setState('playing');
|
||||
this.startPositionReporting();
|
||||
});
|
||||
|
||||
this.audio.addEventListener('pause', () => {
|
||||
this.log('pause event');
|
||||
this.setState('paused');
|
||||
});
|
||||
|
||||
this.audio.addEventListener('ended', () => {
|
||||
this.log('ended event');
|
||||
this.setState('stopped');
|
||||
this.stopPositionReporting();
|
||||
});
|
||||
|
||||
this.audio.addEventListener('error', () => {
|
||||
if (!this.audio.getAttribute('src')) return;
|
||||
const code = this.audio.error?.code;
|
||||
const isNetworkError = code === MediaError.MEDIA_ERR_NETWORK
|
||||
|| code === MediaError.MEDIA_ERR_DECODE;
|
||||
if (isNetworkError && this.state !== 'stopped') {
|
||||
this.log('network error, scheduling reconnect');
|
||||
this.scheduleReconnect();
|
||||
} else {
|
||||
this.log('error event', this.audio.error);
|
||||
this.setState('error');
|
||||
this.listeners.error?.(this.audio.error?.message || 'unknown error');
|
||||
}
|
||||
});
|
||||
|
||||
this.audio.addEventListener('waiting', () => {
|
||||
this.log('waiting event');
|
||||
this.setState('buffering');
|
||||
});
|
||||
|
||||
this.audio.addEventListener('canplay', () => {
|
||||
this.log('canplay event');
|
||||
this.reportReadyState('can_play');
|
||||
});
|
||||
|
||||
this.audio.addEventListener('durationchange', () => {
|
||||
const dur = this.audio.duration;
|
||||
if (isFinite(dur)) {
|
||||
this.log('durationchange', dur);
|
||||
this.reportDuration(dur);
|
||||
}
|
||||
});
|
||||
|
||||
this.audio.addEventListener('loadedmetadata', () => {
|
||||
this.log('loadedmetadata', this.audio.duration);
|
||||
this.reportReadyState('have_metadata');
|
||||
});
|
||||
|
||||
this.audio.addEventListener('loadeddata', () => {
|
||||
this.log('loadeddata');
|
||||
this.reportReadyState('have_current_data');
|
||||
});
|
||||
}
|
||||
|
||||
private handleCommand(msg: Record<string, unknown>) {
|
||||
// Validate command structure before processing (P6)
|
||||
if (!isValidCommand(msg)) {
|
||||
console.warn('[PMOPlayer] Invalid command received:', msg);
|
||||
return;
|
||||
}
|
||||
|
||||
const type = msg.type;
|
||||
|
||||
switch (type) {
|
||||
case 'stream': {
|
||||
this.playStream(msg.url);
|
||||
break;
|
||||
}
|
||||
case 'play': {
|
||||
const streamUrl = `/api/webrenderer/${this.instanceId}/stream`;
|
||||
this.playStream(streamUrl);
|
||||
break;
|
||||
}
|
||||
case 'pause':
|
||||
this.pause();
|
||||
break;
|
||||
case 'seek':
|
||||
this.seek(msg.timestamp);
|
||||
break;
|
||||
case 'flush':
|
||||
this.flush();
|
||||
break;
|
||||
case 'stop':
|
||||
this.stop();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Commands from backend ─────────────────────────────────────────────
|
||||
|
||||
stream(url: string) {
|
||||
this.log('stream:', url);
|
||||
this.audio.setAttribute('data-stream-url', url);
|
||||
this.reconnectAttempts = 0;
|
||||
this.audio.src = url;
|
||||
this.audio.load();
|
||||
}
|
||||
|
||||
playStream(url: string) {
|
||||
this.stream(url);
|
||||
this.play();
|
||||
}
|
||||
|
||||
play() {
|
||||
this.log('play()');
|
||||
const ac = this.ensureAudioContext();
|
||||
if (ac.state === 'suspended') {
|
||||
ac.resume().catch(err => this.log('AudioContext resume error', err));
|
||||
}
|
||||
this.audio.play().catch(err => {
|
||||
if ((err as DOMException).name === 'NotAllowedError') {
|
||||
this.log('autoplay blocked, will retry on user interaction');
|
||||
this.pendingPlay = true;
|
||||
this.setupAutoplayUnlock();
|
||||
} else {
|
||||
this.log('play error', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private setupAutoplayUnlock() {
|
||||
if (this.unlockListener) return;
|
||||
this.unlockListener = () => {
|
||||
if (this.pendingPlay) {
|
||||
this.pendingPlay = false;
|
||||
this.log('retrying play after user interaction');
|
||||
this.audio.play().catch(err => this.log('play retry error', err));
|
||||
}
|
||||
document.removeEventListener('click', this.unlockListener!);
|
||||
this.unlockListener = null;
|
||||
};
|
||||
document.addEventListener('click', this.unlockListener);
|
||||
}
|
||||
|
||||
pause() {
|
||||
this.log('pause()');
|
||||
this.audio.pause();
|
||||
}
|
||||
|
||||
seek(timestamp: number) {
|
||||
this.log('seek:', timestamp);
|
||||
this.audio.currentTime = timestamp;
|
||||
}
|
||||
|
||||
flush() {
|
||||
this.log('flush()');
|
||||
this.audio.pause();
|
||||
this.audio.removeAttribute('src');
|
||||
this.audio.load();
|
||||
this.ac?.suspend();
|
||||
this.listeners.flush?.();
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.log('stop()');
|
||||
this.flush();
|
||||
this.setState('stopped');
|
||||
}
|
||||
|
||||
// ─── Reports to backend ──────────────────────────────────────────
|
||||
|
||||
private setState(state: PlayerState) {
|
||||
if (this.state !== state) {
|
||||
this.state = state;
|
||||
this.log('state:', state);
|
||||
this.listeners.statechange?.(state);
|
||||
|
||||
this.httpReport('report', {
|
||||
state: state,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private reportPosition() {
|
||||
const pos = this.audio.currentTime;
|
||||
const dur = isFinite(this.audio.duration) ? this.audio.duration : null;
|
||||
|
||||
this.listeners.positionchange?.(pos);
|
||||
|
||||
this.httpReport('report', {
|
||||
position_sec: pos,
|
||||
duration_sec: dur,
|
||||
state: this.state,
|
||||
});
|
||||
}
|
||||
|
||||
private reportDuration(dur: number) {
|
||||
this.listeners.durationchange?.(dur);
|
||||
}
|
||||
|
||||
private reportReadyState(ready: ReadyState) {
|
||||
this.log('ready_state:', ready);
|
||||
this.listeners.readychange?.(ready);
|
||||
|
||||
this.httpReport('report', {
|
||||
ready_state: ready,
|
||||
});
|
||||
}
|
||||
|
||||
private async httpReport(endpoint: string, data: Record<string, unknown>) {
|
||||
try {
|
||||
await fetch(`/api/webrenderer/${this.instanceId}/${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
} catch (err) {
|
||||
this.log('http report error', err);
|
||||
}
|
||||
}
|
||||
|
||||
private startPositionReporting() {
|
||||
this.stopPositionReporting();
|
||||
this.positionInterval = window.setInterval(() => {
|
||||
if (this.state === 'playing') {
|
||||
this.reportPosition();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
private stopPositionReporting() {
|
||||
if (this.positionInterval !== null) {
|
||||
clearInterval(this.positionInterval);
|
||||
this.positionInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────
|
||||
|
||||
on<K extends keyof PlayerEvents>(event: K, fn: PlayerEvents[K]) {
|
||||
this.listeners[event] = fn;
|
||||
}
|
||||
|
||||
off<K extends keyof PlayerEvents>(event: K) {
|
||||
delete this.listeners[event];
|
||||
}
|
||||
|
||||
getState(): PlayerState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
getPosition(): number {
|
||||
return this.audio.currentTime;
|
||||
}
|
||||
|
||||
getDuration(): number | null {
|
||||
const dur = this.audio.duration;
|
||||
return isFinite(dur) ? dur : null;
|
||||
}
|
||||
|
||||
// Track info from backend - not implemented yet
|
||||
getTrack(): null {
|
||||
return null;
|
||||
}
|
||||
|
||||
setDebug(enabled: boolean) {
|
||||
this.debug = enabled;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.log('destroy()');
|
||||
this.stopPositionReporting();
|
||||
if (this.commandInterval !== null) {
|
||||
clearInterval(this.commandInterval);
|
||||
this.commandInterval = null;
|
||||
}
|
||||
if (this.unlockListener) {
|
||||
document.removeEventListener('click', this.unlockListener);
|
||||
this.unlockListener = null;
|
||||
}
|
||||
if (this.reconnectTimeout !== null) {
|
||||
clearTimeout(this.reconnectTimeout);
|
||||
this.reconnectTimeout = null;
|
||||
}
|
||||
this.ac?.close();
|
||||
this.ac = null;
|
||||
// Rapport final garanti via sendBeacon (fonctionne pendant beforeunload)
|
||||
navigator.sendBeacon(
|
||||
`/api/webrenderer/${this.instanceId}/report`,
|
||||
JSON.stringify({ state: 'stopped' })
|
||||
);
|
||||
this.audio.pause();
|
||||
this.audio.removeAttribute('src');
|
||||
this.audio.load();
|
||||
this.audio.remove();
|
||||
}
|
||||
}
|
||||
@@ -200,34 +200,11 @@ export function getJpegUrl(pk: string, size?: number): string {
|
||||
return `/covers/jpeg/${pk}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* SVG par défaut pour les images qui ne se chargent pas
|
||||
*/
|
||||
const DEFAULT_COVER_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400">
|
||||
<defs>
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#764ba2;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="400" fill="url(#bgGrad)"/>
|
||||
<g transform="translate(200, 200)">
|
||||
<rect x="-60" y="-80" width="120" height="100" rx="8" fill="white" opacity="0.9"/>
|
||||
<circle cx="0" cy="-30" r="18" fill="rgba(102, 126, 234, 0.3)"/>
|
||||
<rect x="-40" y="10" width="80" height="8" rx="4" fill="rgba(102, 126, 234, 0.3)"/>
|
||||
<rect x="-30" y="30" width="60" height="6" rx="3" fill="rgba(102, 126, 234, 0.2)"/>
|
||||
<rect x="-35" y="50" width="70" height="6" rx="3" fill="rgba(102, 126, 234, 0.2)"/>
|
||||
</g>
|
||||
<text x="200" y="360" text-anchor="middle"
|
||||
font-family="system-ui, -apple-system, sans-serif"
|
||||
font-size="20" fill="white" opacity="0.6">
|
||||
No Image Available
|
||||
</text>
|
||||
</svg>`;
|
||||
import defaultCoverSvg from '../assets/default-cover.svg?raw';
|
||||
|
||||
/**
|
||||
* Retourne l'URL de l'image par défaut comme data URL
|
||||
*/
|
||||
export function getDefaultImageUrl(): string {
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(DEFAULT_COVER_SVG)}`;
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(defaultCoverSvg)}`;
|
||||
}
|
||||
|
||||
@@ -17,12 +17,45 @@ import type {
|
||||
ErrorResponse,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Fetch avec timeout et AbortController
|
||||
*/
|
||||
function fetchWithTimeout(
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
timeoutMs = 10_000,
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const id = setTimeout(() => controller.abort(), timeoutMs);
|
||||
return fetch(url, { ...options, signal: controller.signal }).finally(
|
||||
() => clearTimeout(id),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Client API REST pour le Control Point PMOMusic
|
||||
*/
|
||||
class PMOControlAPI {
|
||||
private readonly baseURL = "/api/control";
|
||||
|
||||
/**
|
||||
* Valide la structure de base d'une réponse
|
||||
* Jette une erreur si la réponse est invalide
|
||||
*/
|
||||
private validateResponse<T>(data: unknown, path: string): T {
|
||||
// Vérification basique : null ou undefined
|
||||
if (data == null) {
|
||||
throw new Error(`[PMOControlAPI] Réponse nulle pour ${path}`);
|
||||
}
|
||||
|
||||
// Vérification que c'est un objet
|
||||
if (typeof data !== 'object') {
|
||||
throw new Error(`[PMOControlAPI] Réponse invalide pour ${path}: attendu un objet`);
|
||||
}
|
||||
|
||||
return data as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effectue une requête HTTP générique
|
||||
*/
|
||||
@@ -32,7 +65,7 @@ class PMOControlAPI {
|
||||
): Promise<T> {
|
||||
const url = `${this.baseURL}${path}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
const response = await fetchWithTimeout(url, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -47,7 +80,15 @@ class PMOControlAPI {
|
||||
throw new Error(error.error);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
const data = await response.json();
|
||||
|
||||
// Validation de la réponse (P5)
|
||||
const validated = this.validateResponse<T>(data, path);
|
||||
|
||||
if (import.meta.env.DEV && data == null) {
|
||||
console.warn(`[PMOControlAPI] Réponse vide pour ${path}`);
|
||||
}
|
||||
return validated;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface RendererSummary {
|
||||
id: string;
|
||||
friendly_name: string;
|
||||
model_name: string;
|
||||
manufacturer?: string;
|
||||
protocol: RendererProtocolSummary;
|
||||
capabilities: RendererCapabilitiesSummary;
|
||||
online: boolean;
|
||||
@@ -38,13 +39,7 @@ export interface RendererSummary {
|
||||
export interface RendererState {
|
||||
id: string;
|
||||
friendly_name: string;
|
||||
transport_state:
|
||||
| "PLAYING"
|
||||
| "PAUSED"
|
||||
| "STOPPED"
|
||||
| "TRANSITIONING"
|
||||
| "NO_MEDIA"
|
||||
| "UNKNOWN";
|
||||
transport_state: TransportState;
|
||||
position_ms: number | null;
|
||||
duration_ms: number | null;
|
||||
volume: number | null; // 0-100
|
||||
@@ -54,6 +49,15 @@ export interface RendererState {
|
||||
current_track: CurrentTrackMetadata | null;
|
||||
}
|
||||
|
||||
export type TransportState = "PLAYING" | "PAUSED" | "STOPPED" | "TRANSITIONING" | "NO_MEDIA" | "UNKNOWN";
|
||||
|
||||
/**
|
||||
* Guard de type pour valider que string est un TransportState valide
|
||||
*/
|
||||
export function isTransportState(s: string): s is TransportState {
|
||||
return ["PLAYING", "PAUSED", "STOPPED", "TRANSITIONING", "NO_MEDIA", "UNKNOWN"].includes(s);
|
||||
}
|
||||
|
||||
export interface CurrentTrackMetadata {
|
||||
title: string | null;
|
||||
artist: string | null;
|
||||
@@ -122,6 +126,7 @@ export interface BrowseResponse {
|
||||
entries: ContainerEntry[];
|
||||
total_count: number;
|
||||
offset: number;
|
||||
hasMore?: boolean; // Client-side flag pour infinite scroll
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface Notification {
|
||||
duration?: number // ms, undefined = permanent
|
||||
}
|
||||
|
||||
const MAX_NOTIFICATIONS = 5
|
||||
|
||||
export const useUIStore = defineStore('ui', () => {
|
||||
// État
|
||||
const selectedRendererId = ref<string | null>(null)
|
||||
@@ -17,6 +19,9 @@ export const useUIStore = defineStore('ui', () => {
|
||||
const sseConnected = ref(false)
|
||||
const notifications = ref<Notification[]>([])
|
||||
|
||||
// Map pour suivre les timers et permettre le cleanup
|
||||
const notificationTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
// Actions
|
||||
function selectRenderer(id: string | null) {
|
||||
selectedRendererId.value = id
|
||||
@@ -39,6 +44,18 @@ export const useUIStore = defineStore('ui', () => {
|
||||
message: string,
|
||||
duration?: number
|
||||
) {
|
||||
// Limiter le nombre de notifications (P15)
|
||||
if (notifications.value.length >= MAX_NOTIFICATIONS) {
|
||||
const oldest = notifications.value.shift()
|
||||
if (oldest) {
|
||||
const timer = notificationTimers.get(oldest.id)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
notificationTimers.delete(oldest.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const id = `notif-${Date.now()}-${Math.random()}`
|
||||
const notification: Notification = {
|
||||
id,
|
||||
@@ -49,12 +66,13 @@ export const useUIStore = defineStore('ui', () => {
|
||||
|
||||
notifications.value.push(notification)
|
||||
|
||||
// Auto-remove après duration (défaut: 5s)
|
||||
// Auto-remove après duration (défaut: 5s) - avec tracking pour cleanup
|
||||
const timeout = duration !== undefined ? duration : 5000
|
||||
if (timeout > 0) {
|
||||
setTimeout(() => {
|
||||
const timer = setTimeout(() => {
|
||||
removeNotification(id)
|
||||
}, timeout)
|
||||
notificationTimers.set(id, timer)
|
||||
}
|
||||
|
||||
return id
|
||||
@@ -65,12 +83,27 @@ export const useUIStore = defineStore('ui', () => {
|
||||
if (index !== -1) {
|
||||
notifications.value.splice(index, 1)
|
||||
}
|
||||
// Nettoyer le timer associated
|
||||
const timer = notificationTimers.get(id)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
notificationTimers.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
function clearNotifications() {
|
||||
// Nettoyer tous les timers
|
||||
notificationTimers.forEach(timer => clearTimeout(timer))
|
||||
notificationTimers.clear()
|
||||
notifications.value = []
|
||||
}
|
||||
|
||||
// Cleanup function pour appeler lors du unmount de l'app
|
||||
function $dispose() {
|
||||
notificationTimers.forEach(timer => clearTimeout(timer))
|
||||
notificationTimers.clear()
|
||||
}
|
||||
|
||||
// Raccourcis pour les types de notifications
|
||||
function notifySuccess(message: string, duration?: number) {
|
||||
return addNotification('success', message, duration)
|
||||
@@ -107,5 +140,6 @@ export const useUIStore = defineStore('ui', () => {
|
||||
notifyError,
|
||||
notifyWarning,
|
||||
notifyInfo,
|
||||
$dispose,
|
||||
}
|
||||
})
|
||||
|
||||
24
pmoapp/webapp/src/types/vue-virtual-scroller.d.ts
vendored
Normal file
24
pmoapp/webapp/src/types/vue-virtual-scroller.d.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
declare module 'vue-virtual-scroller' {
|
||||
import { DefineComponent } from 'vue';
|
||||
|
||||
export interface RecycleScrollerProps {
|
||||
items?: any[];
|
||||
itemSize?: number;
|
||||
keyField?: string;
|
||||
direction?: 'vertical' | 'horizontal';
|
||||
minItemSize?: number;
|
||||
sizeField?: string;
|
||||
typeField?: string;
|
||||
buffer?: number;
|
||||
pageMode?: boolean;
|
||||
prerender?: number;
|
||||
}
|
||||
|
||||
export interface RecycleScrollerRef {
|
||||
scrollToItem(index: number): void;
|
||||
scrollToOffset(offset: number): void;
|
||||
$el: HTMLElement;
|
||||
}
|
||||
|
||||
export const RecycleScroller: DefineComponent<RecycleScrollerProps>;
|
||||
}
|
||||
@@ -45,5 +45,9 @@ export function normalizeUrl(url: string): string {
|
||||
*/
|
||||
export function truncate(str: string, maxLength: number, suffix = '...'): string {
|
||||
if (str.length <= maxLength) return str;
|
||||
// Guard: si suffix est plus long que maxLength, retourner juste le suffixe
|
||||
if (suffix.length >= maxLength) {
|
||||
return str.slice(0, maxLength);
|
||||
}
|
||||
return str.slice(0, maxLength - suffix.length) + suffix;
|
||||
}
|
||||
@@ -49,12 +49,3 @@ export function formatMsToTime(ms: number | null): string {
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -34,17 +34,19 @@ const rendererDrawerOpen = ref(false);
|
||||
// Ref pour le swipe edge detection
|
||||
const viewRef = ref<HTMLElement | null>(null);
|
||||
|
||||
// Position initiale du swipe pour détecter un swipe depuis le bord gauche
|
||||
const swipeStartX = ref(0);
|
||||
|
||||
// Swipe depuis le bord gauche pour ouvrir le drawer
|
||||
useSwipe(viewRef, {
|
||||
threshold: 50,
|
||||
onSwipeStart(e: TouchEvent) {
|
||||
swipeStartX.value = e.touches[0]?.clientX ?? 0;
|
||||
},
|
||||
onSwipeEnd(_e: TouchEvent, swipeDirection: string) {
|
||||
// Swipe right depuis le bord gauche → ouvrir drawer
|
||||
if (swipeDirection === "right" && !drawerOpen.value) {
|
||||
const touch = _e.changedTouches[0];
|
||||
// Vérifier que le swipe commence depuis le bord gauche (< 50px)
|
||||
if (touch && touch.clientX < 50) {
|
||||
drawerOpen.value = true;
|
||||
}
|
||||
if (swipeDirection === "right" && !drawerOpen.value && swipeStartX.value < 50) {
|
||||
drawerOpen.value = true;
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -77,9 +79,9 @@ const onlineServersCount = computed(
|
||||
() => allServers.value.filter((s) => s.online).length,
|
||||
);
|
||||
|
||||
// Nombre de renderers online pour afficher dans le badge
|
||||
// Nombre de renderers online (filtrés) pour afficher dans le badge
|
||||
const onlineRenderersCount = computed(
|
||||
() => allRenderers.value.filter((r) => r.online).length,
|
||||
() => filterRenderers(allRenderers.value).filter((r) => r.online).length,
|
||||
);
|
||||
|
||||
// Gestion de l'ouverture du drawer depuis le bouton
|
||||
@@ -103,14 +105,19 @@ 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.
|
||||
// Filter to show only our own WebRenderer based on UDN match
|
||||
function filterRenderers(renderers: typeof allRenderers.value) {
|
||||
const myUdn = webRenderer.rendererUdn.value;
|
||||
const normalizedUdn = myUdn?.replace(/^uuid:/, '') ?? null;
|
||||
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
|
||||
// WebRenderers have "Web Audio – " as friendly_name prefix
|
||||
const isWebRenderer = r.friendly_name?.startsWith("Web Audio – ") ?? false;
|
||||
if (isWebRenderer) {
|
||||
if (normalizedUdn === null) return false;
|
||||
const normalizedId = r.id.replace(/^uuid:/, '');
|
||||
return normalizedId === normalizedUdn;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -130,12 +137,18 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
// Watch renderers pour sync automatique des tabs
|
||||
// Note: on watch la taille du tableau + les IDs pour éviter un deep watch coûteux
|
||||
watch(
|
||||
() => allRenderers.value,
|
||||
(newRenderers) => {
|
||||
syncWithRenderers(filterRenderers(newRenderers));
|
||||
() => ({
|
||||
length: allRenderers.value.length,
|
||||
ids: allRenderers.value.map(r => r.id).join(','),
|
||||
}),
|
||||
(newVal, oldVal) => {
|
||||
// Re-sync uniquement si le nombre ou les IDs ont changé
|
||||
if (newVal.length !== oldVal?.length || newVal.ids !== oldVal?.ids) {
|
||||
syncWithRenderers(filterRenderers(allRenderers.value));
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
// Watch l'UDN du WebRenderer local : quand il s'établit, resync pour faire apparaître notre onglet
|
||||
@@ -257,6 +270,7 @@ const currentTabProps = computed(() => {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--color-bg);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
@@ -265,6 +279,7 @@ const currentTabProps = computed(() => {
|
||||
overflow-x: hidden;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Placeholder temporaire */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "pmoaudio-ext"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
# Core audio types
|
||||
@@ -32,10 +32,11 @@ serde = { workspace = true, optional = true }
|
||||
serde_json = { workspace = true, optional = true }
|
||||
reqwest = { workspace = true, features = ["stream"], optional = true }
|
||||
futures = { version = "0.3", optional = true }
|
||||
ureq = { version = "2", 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", "dep:reqwest", "dep:futures"]
|
||||
http-stream = ["dep:pmoflac", "dep:pmometadata", "dep:bytes", "dep:serde", "dep:reqwest", "dep:futures", "dep:ureq"]
|
||||
all = ["cache-sink", "playlist", "http-stream"]
|
||||
|
||||
@@ -233,7 +233,7 @@ impl NodeLogic for FlacCacheSinkLogic {
|
||||
}
|
||||
}
|
||||
_AudioSegment::Sync(marker) => match &**marker {
|
||||
SyncMarker::TrackBoundary { metadata } => {
|
||||
SyncMarker::TrackBoundary { metadata, .. } => {
|
||||
// TrackBoundary pendant le prebuffer - track courte (< 512KB)
|
||||
tracing::warn!(
|
||||
"FlacCacheSink: TrackBoundary received before prebuffer complete - track shorter than 512KB, closing pump and waiting for ingestion"
|
||||
@@ -589,7 +589,7 @@ impl NodeLogic for FlacCacheSinkLogic {
|
||||
// Si pump_closed, ignorer silencieusement le chunk
|
||||
}
|
||||
_AudioSegment::Sync(marker) => match &**marker {
|
||||
SyncMarker::TrackBoundary { metadata } => {
|
||||
SyncMarker::TrackBoundary { metadata, .. } => {
|
||||
// Nouveau morceau - fermer le pump si pas déjà fermé
|
||||
tracing::debug!("FlacCacheSink: TrackBoundary received, closing pump and storing metadata for next track");
|
||||
// Stocker les métadonnées pour la prochaine track
|
||||
|
||||
@@ -307,7 +307,7 @@ impl NodeLogic for StreamingFlacSinkLogic {
|
||||
|
||||
_AudioSegment::Sync(marker) => {
|
||||
match marker.as_ref() {
|
||||
SyncMarker::TrackBoundary { metadata } => {
|
||||
SyncMarker::TrackBoundary { metadata, .. } => {
|
||||
// Prepare encoder options (metadata + duration) for the upcoming track.
|
||||
if let Err(e) =
|
||||
self.ctx.prepare_encoder_options_for_track(metadata).await
|
||||
@@ -533,6 +533,9 @@ impl StreamingFlacSink {
|
||||
current_timestamp: Arc::new(RwLock::new(0.0)),
|
||||
pending_track_duration: None,
|
||||
pending_total_samples: None,
|
||||
is_paused: Arc::new(AtomicBool::new(false)),
|
||||
stream_type: Arc::new(RwLock::new(pmoaudio::StreamType::Finite)),
|
||||
last_track_metadata: Arc::new(RwLock::new(None)),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -88,8 +88,17 @@ impl IcyClientStream {
|
||||
|
||||
// Add cover URL if we have a cover_pk
|
||||
if let Some(pk) = &meta.cover_pk {
|
||||
#[cfg(feature = "playlist")]
|
||||
let cover_url = pmocache::covers_absolute_url_for_upnp(pk, None);
|
||||
metadata_str.push_str(&format!("StreamUrl='{}';", cover_url));
|
||||
#[cfg(feature = "playlist")]
|
||||
{
|
||||
metadata_str.push_str(&format!("StreamUrl='{}';", cover_url));
|
||||
}
|
||||
#[cfg(not(feature = "playlist"))]
|
||||
{
|
||||
// When playlist feature is not enabled, use relative URL
|
||||
metadata_str.push_str(&format!("StreamUrl='/covers/image/{}/256';", pk));
|
||||
}
|
||||
} else if let Some(url) = &meta.cover_url {
|
||||
// Fallback to external cover URL if no local pk
|
||||
metadata_str.push_str(&format!("StreamUrl='{}';", url));
|
||||
|
||||
@@ -61,7 +61,7 @@ use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use pmoaudio::{
|
||||
pipeline::{AudioPipelineNode, Node, NodeLogic, PipelineHandle, StopReason},
|
||||
AudioError, AudioSegment, SyncMarker, TypeRequirement, TypedAudioNode, _AudioSegment,
|
||||
AudioChunk, AudioError, AudioSegment, SyncMarker, TypeRequirement, TypedAudioNode, _AudioSegment,
|
||||
};
|
||||
use pmoflac::{EncoderOptions, FlacEncodedStream};
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, ReadBuf};
|
||||
@@ -109,6 +109,23 @@ impl OggFlacStreamHandle {
|
||||
pub fn set_auto_stop(&self, enabled: bool) {
|
||||
self.inner.auto_stop.store(enabled, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Pause playback - sends silence (zeros) to maintain client connections
|
||||
pub fn pause(&self) {
|
||||
self.inner.is_paused.store(true, Ordering::SeqCst);
|
||||
debug!("StreamingOggFlacSink paused");
|
||||
}
|
||||
|
||||
/// Resume playback - continues sending actual audio
|
||||
pub fn resume(&self) {
|
||||
self.inner.is_paused.store(false, Ordering::SeqCst);
|
||||
debug!("StreamingOggFlacSink resumed");
|
||||
}
|
||||
|
||||
/// Check if currently paused
|
||||
pub fn is_paused(&self) -> bool {
|
||||
self.inner.is_paused.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
/// OGG-FLAC client stream (implements AsyncRead).
|
||||
@@ -163,16 +180,98 @@ impl NodeLogic for StreamingOggFlacSinkLogic {
|
||||
|
||||
debug!("StreamingOggFlacSink started");
|
||||
|
||||
// TODO: Implement OGG-FLAC encoding logic
|
||||
// For now, just process segments without encoding
|
||||
let mut was_paused = false;
|
||||
let mut silence_timestamp: f64 = 0.0;
|
||||
|
||||
loop {
|
||||
// Check pause state BEFORE select to avoid receiving chunks when Finite + paused
|
||||
{
|
||||
let is_paused = self.ctx.is_paused.load(Ordering::SeqCst);
|
||||
let stream_type = *self.ctx.stream_type.read().await;
|
||||
|
||||
// For Finite + paused: loop WITHOUT receiving chunks
|
||||
if is_paused && stream_type == pmoaudio::StreamType::Finite {
|
||||
if self.ctx.sample_rate.is_none() {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
let sample_rate = self.ctx.sample_rate.unwrap();
|
||||
let frames_per_50ms = (sample_rate as f64 * 0.05) as usize;
|
||||
|
||||
// Create proper silence chunk using AudioChunk::silence
|
||||
let silence_chunk = AudioChunk::silence(frames_per_50ms, sample_rate);
|
||||
let pcm_bytes = chunk_to_pcm_bytes(&silence_chunk, self.ctx.bits_per_sample)?;
|
||||
|
||||
trace!("Sending silence during pause (finite) - no recv");
|
||||
let pcm_chunk = PcmChunk {
|
||||
bytes: pcm_bytes,
|
||||
timestamp_sec: silence_timestamp,
|
||||
duration_sec: 0.05,
|
||||
};
|
||||
|
||||
if let Some(tx) = &self.ctx.pcm_tx {
|
||||
let _ = tx.send(pcm_chunk).await;
|
||||
}
|
||||
silence_timestamp += 0.05;
|
||||
|
||||
continue; // Loop again WITHOUT going to select
|
||||
}
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
_ = stop_token.cancelled() => {
|
||||
debug!("StreamingOggFlacSink stopped by cancellation");
|
||||
break;
|
||||
}
|
||||
|
||||
// Check for pause state changes periodically
|
||||
_ = tokio::time::sleep(std::time::Duration::from_millis(50)) => {
|
||||
let is_paused = self.ctx.is_paused.load(Ordering::SeqCst);
|
||||
|
||||
if is_paused != was_paused {
|
||||
if is_paused {
|
||||
// Entering pause - restart encoder to close OGG segment
|
||||
debug!("StreamingOggFlacSink: entering pause - restarting encoder");
|
||||
let metadata = self.ctx.last_track_metadata.read().await.clone();
|
||||
if let Some(ref meta) = metadata {
|
||||
if let Err(e) = self.ctx.prepare_encoder_options_for_track(meta).await {
|
||||
error!("Failed to prepare encoder options for pause: {}", e);
|
||||
}
|
||||
}
|
||||
if self.ctx.encoder_state.is_some() {
|
||||
if let Err(e) = self.ctx.restart_encoder_for_new_track(
|
||||
|flac_stream, broadcast, header, current_timestamp, current_duration, max_lead, _sample_rate, timestamp_offset_sec| {
|
||||
broadcast_ogg_flac_stream(flac_stream, broadcast, header, current_timestamp, current_duration, max_lead, timestamp_offset_sec)
|
||||
},
|
||||
).await {
|
||||
warn!("Failed to restart encoder on pause: {}", e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Exiting pause - restart encoder to open new OGG segment
|
||||
debug!("StreamingOggFlacSink: exiting pause - restarting encoder");
|
||||
let metadata = self.ctx.last_track_metadata.read().await.clone();
|
||||
if let Some(ref meta) = metadata {
|
||||
if let Err(e) = self.ctx.prepare_encoder_options_for_track(meta).await {
|
||||
error!("Failed to prepare encoder options for resume: {}", e);
|
||||
}
|
||||
}
|
||||
if self.ctx.encoder_state.is_some() {
|
||||
if let Err(e) = self.ctx.restart_encoder_for_new_track(
|
||||
|flac_stream, broadcast, header, current_timestamp, current_duration, max_lead, _sample_rate, timestamp_offset_sec| {
|
||||
broadcast_ogg_flac_stream(flac_stream, broadcast, header, current_timestamp, current_duration, max_lead, timestamp_offset_sec)
|
||||
},
|
||||
).await {
|
||||
warn!("Failed to restart encoder on resume: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
was_paused = is_paused;
|
||||
}
|
||||
continue; // Skip processing - this is just a check
|
||||
}
|
||||
|
||||
segment = input.recv() => {
|
||||
match segment {
|
||||
Some(seg) => {
|
||||
@@ -241,7 +340,33 @@ impl NodeLogic for StreamingOggFlacSinkLogic {
|
||||
duration_sec
|
||||
);
|
||||
|
||||
// Send to FLAC encoder with timestamp and duration
|
||||
// Check pause state
|
||||
let was_paused = self.ctx.is_paused.load(Ordering::SeqCst);
|
||||
|
||||
// Get stream type
|
||||
let stream_type = *self.ctx.stream_type.read().await;
|
||||
|
||||
// Handle pause:
|
||||
// - Always send silence to hear something
|
||||
// - Continuous: receive chunks but drop them
|
||||
// - Finite: don't receive chunks (backpressure)
|
||||
if was_paused {
|
||||
trace!("StreamingOggFlacSink: sending silence during pause ({})",
|
||||
if stream_type == pmoaudio::StreamType::Continuous { "continuous" } else { "finite" });
|
||||
let silence_bytes = vec![0u8; pcm_bytes.len()];
|
||||
let silence_chunk = PcmChunk {
|
||||
bytes: silence_bytes,
|
||||
timestamp_sec: seg.timestamp_sec,
|
||||
duration_sec,
|
||||
};
|
||||
if let Some(tx) = &self.ctx.pcm_tx {
|
||||
let _ = tx.send(silence_chunk).await;
|
||||
}
|
||||
// Always continue (skip sending real audio)
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normal case: send PCM to encoder
|
||||
let pcm_chunk = PcmChunk {
|
||||
bytes: pcm_bytes,
|
||||
timestamp_sec: seg.timestamp_sec,
|
||||
@@ -265,7 +390,13 @@ impl NodeLogic for StreamingOggFlacSinkLogic {
|
||||
|
||||
_AudioSegment::Sync(marker) => {
|
||||
match marker.as_ref() {
|
||||
SyncMarker::TrackBoundary { metadata } => {
|
||||
SyncMarker::TrackBoundary { metadata, stream_type } => {
|
||||
// Store the last TrackBoundary metadata for pause/resume
|
||||
*self.ctx.last_track_metadata.write().await = Some(metadata.clone());
|
||||
|
||||
// Update stream type
|
||||
*self.ctx.stream_type.write().await = *stream_type;
|
||||
|
||||
// Inject per-track metadata and duration into the next FLAC header.
|
||||
if let Err(e) =
|
||||
self.ctx.prepare_encoder_options_for_track(metadata).await
|
||||
@@ -442,30 +573,33 @@ impl StreamingOggFlacSink {
|
||||
|
||||
let logic = StreamingOggFlacSinkLogic {
|
||||
ctx: SharedSinkContext {
|
||||
encoder_options,
|
||||
bits_per_sample,
|
||||
enable_total_samples: options.enable_total_samples,
|
||||
restart_encoder_on_track_boundary: options.restart_encoder_on_track_boundary,
|
||||
default_title: options.default_title.clone(),
|
||||
default_artist: options.default_artist.clone(),
|
||||
use_only_default_metadata: options.use_only_default_metadata,
|
||||
pcm_tx: Some(pcm_tx),
|
||||
pcm_rx: Some(pcm_rx),
|
||||
metadata,
|
||||
broadcast,
|
||||
header,
|
||||
encoder_state: None,
|
||||
sample_rate: None,
|
||||
broadcast_max_lead_time: broadcast_max_lead_time.max(0.0),
|
||||
first_chunk_timestamp_checked: false,
|
||||
timestamp_offset_sec: 0.0,
|
||||
current_timestamp: Arc::new(RwLock::new(0.0)),
|
||||
pending_track_duration: None,
|
||||
pending_total_samples: None,
|
||||
},
|
||||
encoder_options,
|
||||
bits_per_sample,
|
||||
enable_total_samples: options.enable_total_samples,
|
||||
restart_encoder_on_track_boundary: options.restart_encoder_on_track_boundary,
|
||||
default_title: options.default_title.clone(),
|
||||
default_artist: options.default_artist.clone(),
|
||||
use_only_default_metadata: options.use_only_default_metadata,
|
||||
pcm_tx: Some(pcm_tx),
|
||||
pcm_rx: Some(pcm_rx),
|
||||
metadata,
|
||||
broadcast,
|
||||
header,
|
||||
encoder_state: None,
|
||||
sample_rate: None,
|
||||
broadcast_max_lead_time: broadcast_max_lead_time.max(0.0),
|
||||
first_chunk_timestamp_checked: false,
|
||||
timestamp_offset_sec: 0.0,
|
||||
current_timestamp: Arc::new(RwLock::new(0.0)),
|
||||
pending_track_duration: None,
|
||||
pending_total_samples: None,
|
||||
is_paused: Arc::new(AtomicBool::new(false)),
|
||||
stream_type: Arc::new(RwLock::new(pmoaudio::StreamType::Finite)),
|
||||
last_track_metadata: Arc::new(RwLock::new(None)),
|
||||
},
|
||||
};
|
||||
|
||||
let sink = Self {
|
||||
let sink = StreamingOggFlacSink {
|
||||
inner: Node::new_with_input(logic, 16),
|
||||
};
|
||||
|
||||
|
||||
@@ -118,6 +118,10 @@ pub struct SharedStreamHandleInner {
|
||||
pub stop_token: CancellationToken,
|
||||
pub header: Arc<RwLock<Option<Bytes>>>,
|
||||
pub auto_stop: Arc<AtomicBool>,
|
||||
/// Pause state - true if paused, false if playing
|
||||
pub is_paused: Arc<AtomicBool>,
|
||||
/// Stream type (Continuous for radio, Finite for tracks)
|
||||
pub stream_type: Arc<RwLock<pmoaudio::StreamType>>,
|
||||
}
|
||||
|
||||
impl SharedStreamHandleInner {
|
||||
@@ -135,6 +139,8 @@ impl SharedStreamHandleInner {
|
||||
stop_token,
|
||||
header,
|
||||
auto_stop,
|
||||
is_paused: Arc::new(AtomicBool::new(false)),
|
||||
stream_type: Arc::new(RwLock::new(pmoaudio::StreamType::Finite)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,6 +300,12 @@ pub struct SharedSinkContext {
|
||||
pub current_timestamp: Arc<RwLock<f64>>,
|
||||
pub pending_track_duration: Option<Duration>,
|
||||
pub pending_total_samples: Option<u64>,
|
||||
/// Pause state - true if paused, false if playing
|
||||
pub is_paused: Arc<AtomicBool>,
|
||||
/// Stream type (Continuous for radio, Finite for tracks)
|
||||
pub stream_type: Arc<RwLock<pmoaudio::StreamType>>,
|
||||
/// Last TrackBoundary metadata received (for pause/resume)
|
||||
pub last_track_metadata: Arc<RwLock<Option<Arc<RwLock<dyn TrackMetadata>>>>>,
|
||||
}
|
||||
|
||||
impl SharedSinkContext {
|
||||
|
||||
@@ -35,6 +35,7 @@ use pmoaudio::{
|
||||
AudioSegment,
|
||||
nodes::AudioError,
|
||||
pipeline::{AudioPipelineNode, Node, NodeLogic, send_to_children},
|
||||
StreamType,
|
||||
};
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
@@ -164,6 +165,7 @@ impl NodeLogic for PlayerSourceLogic {
|
||||
let mut current_uri: Option<String> = None;
|
||||
let mut next_uri: Option<String> = None;
|
||||
let mut paused_at_sec: f64 = 0.0;
|
||||
let mut is_continuous: bool = false;
|
||||
|
||||
info!("PlayerSource: started");
|
||||
|
||||
@@ -181,7 +183,7 @@ impl NodeLogic for PlayerSourceLogic {
|
||||
None => break,
|
||||
Some(cmd) => {
|
||||
self.handle_command(
|
||||
cmd, &mut state, &mut current_uri,
|
||||
cmd, is_continuous, &mut state, &mut current_uri,
|
||||
&mut next_uri, &mut paused_at_sec,
|
||||
&output, &stop_token,
|
||||
).await?;
|
||||
@@ -212,15 +214,17 @@ impl NodeLogic for PlayerSourceLogic {
|
||||
};
|
||||
|
||||
let duration_sec = source.duration_sec();
|
||||
let is_continuous = source.is_continuous();
|
||||
let _ = self.event_tx.send(PlayerEvent::Playing {
|
||||
uri: uri.clone(),
|
||||
duration_sec,
|
||||
});
|
||||
info!("PlayerSource: playing {:?} from {:.1}s", uri, paused_at_sec);
|
||||
info!("PlayerSource: playing {:?} from {:.1}s continuous={}", uri, paused_at_sec, is_continuous);
|
||||
|
||||
// Pompe audio — s'arrête sur EOF, Pause, Stop, ou commande
|
||||
let result = self.pump(
|
||||
source,
|
||||
is_continuous,
|
||||
&mut state,
|
||||
&mut current_uri,
|
||||
&mut next_uri,
|
||||
@@ -255,6 +259,7 @@ impl PlayerSourceLogic {
|
||||
async fn handle_command(
|
||||
&mut self,
|
||||
cmd: PlayerCommand,
|
||||
is_continuous: bool,
|
||||
state: &mut TransportState,
|
||||
current_uri: &mut Option<String>,
|
||||
next_uri: &mut Option<String>,
|
||||
@@ -281,14 +286,16 @@ impl PlayerSourceLogic {
|
||||
TransportState::Paused => {
|
||||
info!("PlayerSource: Play (resume from {:.1}s)", paused_at_sec);
|
||||
// Injecter un TrackBoundary pour EOS + nouveau BOS OGG propre
|
||||
send_track_boundary(current_uri.as_deref(), output, *paused_at_sec, stop_token).await?;
|
||||
let stream_type = if is_continuous { StreamType::Continuous } else { StreamType::Finite };
|
||||
send_track_boundary(current_uri.as_deref(), output, *paused_at_sec, stream_type, stop_token).await?;
|
||||
*state = TransportState::Playing;
|
||||
}
|
||||
TransportState::Loaded => {
|
||||
info!("PlayerSource: Play (start)");
|
||||
*paused_at_sec = 0.0;
|
||||
// TrackBoundary initial pour le premier BOS OGG
|
||||
send_track_boundary(current_uri.as_deref(), output, 0.0, stop_token).await?;
|
||||
let stream_type = if is_continuous { StreamType::Continuous } else { StreamType::Finite };
|
||||
send_track_boundary(current_uri.as_deref(), output, 0.0, stream_type, stop_token).await?;
|
||||
*state = TransportState::Playing;
|
||||
}
|
||||
TransportState::Idle => {
|
||||
@@ -320,7 +327,8 @@ impl PlayerSourceLogic {
|
||||
if current_uri.is_some() {
|
||||
info!("PlayerSource: Seek to {:.1}s", pos);
|
||||
*paused_at_sec = pos;
|
||||
send_track_boundary(current_uri.as_deref(), output, pos, stop_token).await?;
|
||||
let stream_type = if is_continuous { StreamType::Continuous } else { StreamType::Finite };
|
||||
send_track_boundary(current_uri.as_deref(), output, pos, stream_type, stop_token).await?;
|
||||
*state = TransportState::Playing;
|
||||
}
|
||||
}
|
||||
@@ -335,6 +343,7 @@ impl PlayerSourceLogic {
|
||||
async fn pump(
|
||||
&mut self,
|
||||
source: UriSource,
|
||||
is_continuous: bool,
|
||||
state: &mut TransportState,
|
||||
current_uri: &mut Option<String>,
|
||||
next_uri: &mut Option<String>,
|
||||
@@ -394,7 +403,8 @@ impl PlayerSourceLogic {
|
||||
*next_uri = None;
|
||||
*paused_at_sec = 0.0;
|
||||
// TrackBoundary pour clore le bitstream OGG proprement
|
||||
if let Err(e) = send_track_boundary(current_uri.as_deref(), output, 0.0, stop_token).await {
|
||||
let stream_type = if is_continuous { StreamType::Continuous } else { StreamType::Finite };
|
||||
if let Err(e) = send_track_boundary(current_uri.as_deref(), output, 0.0, stream_type, stop_token).await {
|
||||
result = Err(e);
|
||||
}
|
||||
*state = TransportState::Playing;
|
||||
@@ -408,7 +418,8 @@ impl PlayerSourceLogic {
|
||||
info!("PlayerSource: Seek to {:.1}s", pos);
|
||||
source_stop.cancel();
|
||||
*paused_at_sec = pos;
|
||||
if let Err(e) = send_track_boundary(current_uri.as_deref(), output, pos, stop_token).await {
|
||||
let stream_type = if is_continuous { StreamType::Continuous } else { StreamType::Finite };
|
||||
if let Err(e) = send_track_boundary(current_uri.as_deref(), output, pos, stream_type, stop_token).await {
|
||||
result = Err(e);
|
||||
break;
|
||||
}
|
||||
@@ -434,7 +445,8 @@ impl PlayerSourceLogic {
|
||||
info!("PlayerSource: gapless transition to {:?}", next);
|
||||
*current_uri = Some(next);
|
||||
*paused_at_sec = 0.0;
|
||||
if let Err(e) = send_track_boundary(current_uri.as_deref(), output, 0.0, stop_token).await {
|
||||
let stream_type = if is_continuous { StreamType::Continuous } else { StreamType::Finite };
|
||||
if let Err(e) = send_track_boundary(current_uri.as_deref(), output, 0.0, stream_type, stop_token).await {
|
||||
result = Err(e);
|
||||
}
|
||||
*state = TransportState::Playing;
|
||||
@@ -494,6 +506,7 @@ async fn send_track_boundary(
|
||||
uri: Option<&str>,
|
||||
output: &[mpsc::Sender<Arc<AudioSegment>>],
|
||||
timestamp_sec: f64,
|
||||
stream_type: StreamType,
|
||||
stop_token: &CancellationToken,
|
||||
) -> Result<(), AudioError> {
|
||||
if output.is_empty() || stop_token.is_cancelled() {
|
||||
@@ -505,7 +518,7 @@ async fn send_track_boundary(
|
||||
let _ = meta.set_title(Some(u.to_string())).await;
|
||||
}
|
||||
let meta_arc = Arc::new(tokio::sync::RwLock::new(meta));
|
||||
let boundary = AudioSegment::new_track_boundary(0, timestamp_sec, meta_arc);
|
||||
let boundary = AudioSegment::new_track_boundary(0, timestamp_sec, meta_arc, stream_type);
|
||||
|
||||
send_to_children("PlayerSource", output, boundary).await
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ use pmoaudio::{
|
||||
nodes::{AudioError, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS},
|
||||
pipeline::{send_to_children, AudioPipelineNode, Node, NodeLogic},
|
||||
type_constraints::TypeRequirement,
|
||||
AudioSegment,
|
||||
AudioSegment, StreamType,
|
||||
};
|
||||
use pmoaudiocache::Cache as AudioCache;
|
||||
use pmoflac::decode_audio_stream;
|
||||
@@ -279,7 +279,7 @@ impl NodeLogic for PlaylistSourceLogic {
|
||||
let track_start = std::time::Instant::now();
|
||||
tracing::debug!("PlaylistSourceLogic: emitting TrackBoundary");
|
||||
let metadata_for_boundary = metadata.clone();
|
||||
let boundary = AudioSegment::new_track_boundary(0, 0.0, metadata_for_boundary);
|
||||
let boundary = AudioSegment::new_track_boundary(0, 0.0, metadata_for_boundary, StreamType::Finite);
|
||||
send_to_children(node_name, &output, boundary).await?;
|
||||
|
||||
// Obtenir le chemin du fichier
|
||||
|
||||
@@ -49,6 +49,8 @@ pub struct UriSource {
|
||||
reader: Box<dyn tokio::io::AsyncRead + Send + Unpin>,
|
||||
stream_info: StreamInfo,
|
||||
frames_to_skip: u64,
|
||||
/// true si c'est un flux continu (radio, stream) sans durée définie
|
||||
pub is_continuous: bool,
|
||||
}
|
||||
|
||||
impl UriSource {
|
||||
@@ -88,6 +90,11 @@ impl UriSource {
|
||||
})
|
||||
}
|
||||
|
||||
/// Retourne true si c'est un flux continu (radio, stream) sans durée définie.
|
||||
pub fn is_continuous(&self) -> bool {
|
||||
self.is_continuous
|
||||
}
|
||||
|
||||
/// Émet les chunks audio vers `tx`.
|
||||
///
|
||||
/// Retourne `Ok(true)` si EOF naturel, `Ok(false)` si annulé ou receiver fermé.
|
||||
@@ -201,7 +208,7 @@ impl UriSource {
|
||||
);
|
||||
|
||||
let (_, reader) = stream.into_reader();
|
||||
Ok(Self { reader: Box::new(reader), stream_info, frames_to_skip })
|
||||
Ok(Self { reader: Box::new(reader), stream_info, frames_to_skip, is_continuous: false })
|
||||
}
|
||||
|
||||
async fn open_http(
|
||||
@@ -209,6 +216,9 @@ impl UriSource {
|
||||
seek_sec: f64,
|
||||
stop_token: &CancellationToken,
|
||||
) -> Result<Self, AudioError> {
|
||||
// Détecter si c'est un flux continu (radio, stream) basé sur l'URL
|
||||
let is_continuous = detect_continuous_stream(url);
|
||||
|
||||
let response = tokio::select! {
|
||||
_ = stop_token.cancelled() => {
|
||||
return Err(AudioError::IoError("Cancelled before HTTP connect".into()));
|
||||
@@ -244,11 +254,89 @@ impl UriSource {
|
||||
let frames_to_skip = (seek_sec * stream_info.sample_rate as f64) as u64;
|
||||
|
||||
info!(
|
||||
"UriSource: opened HTTP {} Hz {} ch {} bps",
|
||||
stream_info.sample_rate, stream_info.channels, stream_info.bits_per_sample,
|
||||
"UriSource: opened HTTP {} Hz {} ch {} bps continuous={}",
|
||||
stream_info.sample_rate, stream_info.channels, stream_info.bits_per_sample, is_continuous,
|
||||
);
|
||||
|
||||
let (_, reader) = stream.into_reader();
|
||||
Ok(Self { reader: Box::new(reader), stream_info, frames_to_skip })
|
||||
Ok(Self {
|
||||
reader: Box::new(reader),
|
||||
stream_info,
|
||||
frames_to_skip,
|
||||
is_continuous,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Détecte si une URL correspond à un flux continu (radio, stream) sans durée définie.
|
||||
///
|
||||
/// Cette fonction:
|
||||
/// 1. Vérifie les patterns d'URL connus (stream, live, radio, etc.)
|
||||
/// 2. Fait une requête HTTP HEAD pour vérifier les headers (Content-Length, ICY, etc.)
|
||||
fn detect_continuous_stream(url: &str) -> bool {
|
||||
let url_lower = url.to_lowercase();
|
||||
|
||||
// 1. Quick check sur les patterns d'URL très explicites
|
||||
// Ces patterns indiquent clairement un stream live
|
||||
if url_lower.contains("/live")
|
||||
|| url_lower.contains("/radiolar")
|
||||
|| url_lower.contains(".pls")
|
||||
|| url_lower.contains(".m3u")
|
||||
|| url_lower.contains("icy")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. Vérification HTTP headers (le plus fiable)
|
||||
if url.starts_with("http://") || url.starts_with("https://") {
|
||||
if let Ok(is_stream) = check_http_stream_headers(url) {
|
||||
if is_stream {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Vérifie les headers HTTP pour déterminer si c'est un stream
|
||||
fn check_http_stream_headers(url: &str) -> Result<bool, String> {
|
||||
use std::time::Duration;
|
||||
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout(Duration::from_secs(3))
|
||||
.build();
|
||||
|
||||
let response = agent
|
||||
.head(url)
|
||||
.call()
|
||||
.map_err(|e| format!("HTTP HEAD failed: {}", e))?;
|
||||
|
||||
// Headers ICY (Icecast/Shoutcast) = toujours un stream
|
||||
if response.header("icy-name").is_some()
|
||||
|| response.header("icy-metaint").is_some()
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// Pas de Content-Length = stream potentiel
|
||||
let has_content_length = response.header("content-length").is_some();
|
||||
|
||||
// Transfer-Encoding: chunked = stream potentiel
|
||||
let is_chunked = response
|
||||
.header("transfer-encoding")
|
||||
.map(|v| v.to_lowercase().contains("chunked"))
|
||||
.unwrap_or(false);
|
||||
|
||||
// Decision: pas de Content-Length + (chunked ou content-type streaming)
|
||||
let content_type = response
|
||||
.header("content-type")
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
|
||||
let is_streaming_mime = content_type.contains("audio/mpeg")
|
||||
|| content_type.contains("audio/aac")
|
||||
|| content_type.contains("application/ogg");
|
||||
|
||||
Ok(!has_content_length && (is_streaming_mime || is_chunked))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "pmoaudio"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
@@ -91,6 +91,25 @@ impl<T: Sample> AudioChunkData<T> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Crée un chunk de silence
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `frames` - Nombre de frames (échantillons par canal)
|
||||
/// * `sample_rate` - Taux d'échantillonnage en Hz
|
||||
///
|
||||
/// # Exemples
|
||||
///
|
||||
/// ```
|
||||
/// use pmoaudio::AudioChunkData;
|
||||
///
|
||||
/// let silence = AudioChunkData::<i32>::silence(48000, 48000);
|
||||
/// assert_eq!(silence.len(), 48000);
|
||||
/// ```
|
||||
pub fn silence(frames: usize, sample_rate: u32) -> Arc<Self> {
|
||||
Self::new(vec![[T::ZERO; 2]; frames], sample_rate, 0.0)
|
||||
}
|
||||
|
||||
/// Retourne le nombre d'échantillons par canal (frames)
|
||||
#[inline]
|
||||
pub fn len(&self) -> usize {
|
||||
@@ -317,6 +336,11 @@ pub enum AudioChunk {
|
||||
}
|
||||
|
||||
impl AudioChunk {
|
||||
/// Crée un chunk de silence (I32 par défaut)
|
||||
pub fn silence(frames: usize, sample_rate: u32) -> Self {
|
||||
AudioChunk::I32(AudioChunkData::<i32>::silence(frames, sample_rate))
|
||||
}
|
||||
|
||||
/// Retourne le nombre de frames du chunk
|
||||
pub fn len(&self) -> usize {
|
||||
match self {
|
||||
|
||||
@@ -3,7 +3,7 @@ use tokio::sync::RwLock;
|
||||
|
||||
use pmometadata::TrackMetadata;
|
||||
|
||||
use crate::{gain_db_from_linear, AudioChunk, AudioChunkData, BitDepth, SyncMarker};
|
||||
use crate::{gain_db_from_linear, AudioChunk, AudioChunkData, BitDepth, StreamType, SyncMarker};
|
||||
|
||||
pub enum _AudioSegment {
|
||||
Chunk(Arc<AudioChunk>),
|
||||
@@ -160,9 +160,11 @@ impl AudioSegment {
|
||||
order: u64,
|
||||
timestamp_sec: f64,
|
||||
metadata: Arc<RwLock<dyn TrackMetadata>>,
|
||||
stream_type: StreamType,
|
||||
) -> Arc<Self> {
|
||||
let marker = Arc::new(SyncMarker::TrackBoundary {
|
||||
metadata: Arc::clone(&metadata),
|
||||
stream_type,
|
||||
});
|
||||
Arc::new(Self {
|
||||
order,
|
||||
@@ -303,7 +305,18 @@ impl AudioSegment {
|
||||
pub fn as_track_metadata(&self) -> Option<&Arc<RwLock<dyn TrackMetadata>>> {
|
||||
match &self.segment {
|
||||
_AudioSegment::Sync(marker) => match &**marker {
|
||||
SyncMarker::TrackBoundary { metadata } => Some(metadata),
|
||||
SyncMarker::TrackBoundary { metadata, .. } => Some(metadata),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère le stream_type si c'est un TrackBoundary
|
||||
pub fn stream_type(&self) -> Option<StreamType> {
|
||||
match &self.segment {
|
||||
_AudioSegment::Sync(marker) => match &**marker {
|
||||
SyncMarker::TrackBoundary { stream_type, .. } => Some(*stream_type),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
@@ -409,7 +422,7 @@ impl<'a> TryInto<&'a Arc<AudioChunk>> for &'a AudioSegment {
|
||||
|
||||
fn try_into(self) -> Result<&'a Arc<AudioChunk>, Self::Error> {
|
||||
match &self.segment {
|
||||
_AudioSegment::Chunk(ref chunk) => Ok(chunk),
|
||||
_AudioSegment::Chunk(chunk) => Ok(chunk),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
@@ -420,7 +433,7 @@ impl<'a> TryInto<&'a Arc<SyncMarker>> for &'a AudioSegment {
|
||||
|
||||
fn try_into(self) -> Result<&'a Arc<SyncMarker>, Self::Error> {
|
||||
match &self.segment {
|
||||
_AudioSegment::Sync(ref marker) => Ok(marker),
|
||||
_AudioSegment::Sync(marker) => Ok(marker),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ pub mod bit_depth;
|
||||
pub mod dsp;
|
||||
|
||||
pub use audio_segment::{AudioSegment, _AudioSegment};
|
||||
pub use sync_marker::SyncMarker;
|
||||
pub use sync_marker::{StreamType, SyncMarker};
|
||||
|
||||
pub use audio_chunk::{
|
||||
gain_db_from_linear, gain_linear_from_db, AudioChunk, AudioChunkData, AudioFloatChunk,
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::{
|
||||
nodes::{AudioError, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS},
|
||||
pipeline::{send_to_children, AudioPipelineNode, Node, NodeLogic},
|
||||
type_constraints::TypeRequirement,
|
||||
AudioChunk, AudioChunkData, AudioSegment, I24,
|
||||
AudioChunk, AudioChunkData, AudioSegment, I24, StreamType,
|
||||
};
|
||||
use pmoflac::{decode_audio_stream, AudioFileMetadata, StreamInfo};
|
||||
use pmometadata::{MemoryTrackMetadata, TrackMetadata};
|
||||
@@ -98,6 +98,7 @@ impl NodeLogic for FileSourceLogic {
|
||||
0,
|
||||
0.0,
|
||||
Arc::new(tokio::sync::RwLock::new(metadata)),
|
||||
StreamType::Finite,
|
||||
);
|
||||
send_to_children(std::any::type_name::<Self>(), &output, track_boundary).await?;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::{
|
||||
nodes::{AudioError, TypedAudioNode, DEFAULT_CHANNEL_SIZE},
|
||||
pipeline::{Node, NodeLogic},
|
||||
type_constraints::TypeRequirement,
|
||||
AudioChunk, AudioPipelineNode, AudioSegment, SyncMarker,
|
||||
AudioChunk, AudioPipelineNode, AudioSegment, SyncMarker, StreamType,
|
||||
};
|
||||
use pmoflac::{encode_flac_stream, EncoderOptions, PcmFormat};
|
||||
use std::{
|
||||
@@ -822,6 +822,7 @@ mod tests {
|
||||
0,
|
||||
0.0,
|
||||
std::sync::Arc::new(tokio::sync::RwLock::new(metadata)),
|
||||
crate::StreamType::Finite,
|
||||
);
|
||||
tx.send(track_boundary).await.unwrap();
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::{
|
||||
nodes::{AudioError, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS},
|
||||
pipeline::{send_to_children, Node, NodeLogic},
|
||||
type_constraints::TypeRequirement,
|
||||
AudioChunk, AudioChunkData, AudioPipelineNode, AudioSegment, I24,
|
||||
AudioChunk, AudioChunkData, AudioPipelineNode, AudioSegment, I24, StreamType,
|
||||
};
|
||||
use futures_util::StreamExt;
|
||||
use pmoflac::{decode_audio_stream, StreamInfo};
|
||||
@@ -178,7 +178,7 @@ impl NodeLogic for HttpSourceLogic {
|
||||
|
||||
// Émettre TrackBoundary avec les métadonnées HTTP
|
||||
let track_boundary =
|
||||
AudioSegment::new_track_boundary(0, 0.0, Arc::new(tokio::sync::RwLock::new(metadata)));
|
||||
AudioSegment::new_track_boundary(0, 0.0, Arc::new(tokio::sync::RwLock::new(metadata)), StreamType::Finite);
|
||||
send_to_children(std::any::type_name::<Self>(), &output, track_boundary).await?;
|
||||
|
||||
// Préparer la lecture des chunks audio
|
||||
|
||||
@@ -33,7 +33,7 @@ use crate::{
|
||||
nodes::{AudioError, TypedAudioNode},
|
||||
pipeline::{send_to_children, AudioPipelineNode, Node, NodeLogic},
|
||||
type_constraints::TypeRequirement,
|
||||
AudioChunk, AudioChunkData, AudioSegment, BitDepth, I24,
|
||||
AudioChunk, AudioChunkData, AudioSegment, BitDepth, I24, StreamType,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
@@ -495,7 +495,7 @@ mod tests {
|
||||
let metadata = Arc::new(tokio::sync::RwLock::new(
|
||||
pmometadata::MemoryTrackMetadata::new(),
|
||||
));
|
||||
let boundary = AudioSegment::new_track_boundary(0, 0.0, metadata);
|
||||
let boundary = AudioSegment::new_track_boundary(0, 0.0, metadata, StreamType::Finite);
|
||||
|
||||
// Envoyer le boundary
|
||||
input_tx.send(boundary.clone()).await.unwrap();
|
||||
|
||||
@@ -3,9 +3,16 @@ use tokio::sync::RwLock;
|
||||
|
||||
use pmometadata::TrackMetadata;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StreamType {
|
||||
Continuous,
|
||||
Finite,
|
||||
}
|
||||
|
||||
pub enum SyncMarker {
|
||||
TrackBoundary {
|
||||
metadata: Arc<RwLock<dyn TrackMetadata>>,
|
||||
stream_type: StreamType,
|
||||
},
|
||||
StreamMetadata {
|
||||
key: String,
|
||||
@@ -15,5 +22,4 @@ pub enum SyncMarker {
|
||||
Heartbeat,
|
||||
EndOfStream,
|
||||
Error(String),
|
||||
// autres cas à venir…
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "pmoaudiocache"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
# Cache générique
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "pmocache"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
# Base de données
|
||||
|
||||
@@ -155,6 +155,7 @@ impl DB {
|
||||
let conn = Connection::open(path)?;
|
||||
conn.execute("PRAGMA foreign_keys = ON", [])?;
|
||||
|
||||
// 1. CRÉATION DES TABLES EN PREMIER
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS asset (
|
||||
pk TEXT PRIMARY KEY,
|
||||
@@ -180,21 +181,30 @@ impl DB {
|
||||
[],
|
||||
)?;
|
||||
|
||||
// Créer un index sur la collection pour les requêtes rapides
|
||||
// 2. Optimisations SQLite pour production
|
||||
conn.execute_batch(
|
||||
"
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
PRAGMA cache_size = -32768;
|
||||
PRAGMA temp_store = MEMORY;
|
||||
PRAGMA mmap_size = 268435456;
|
||||
",
|
||||
)?;
|
||||
|
||||
// 3. CRÉATION DES INDEX APRÈS LES TABLES
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_asset_collection
|
||||
ON ASSET (collection)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// Créer un index composite pour optimiser la politique LRU (get_oldest)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_asset_lru
|
||||
ON asset (last_used ASC, hits ASC)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// Crée un index composite pour rendre unique les ids si défini dans une collection
|
||||
conn.execute(
|
||||
"CREATE UNIQUE INDEX
|
||||
IF NOT EXISTS asset_collection_id_unique
|
||||
@@ -203,12 +213,32 @@ impl DB {
|
||||
[],
|
||||
)?;
|
||||
|
||||
// LAZY PK SUPPORT: Index sur lazy_pk pour lookups rapides (lazy_pk → real pk)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_metadata_key_value ON metadata (key, value)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_asset_last_used ON asset (last_used DESC)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_asset_hits ON asset (hits DESC)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_asset_lazy_pk ON asset (lazy_pk)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_lazy_pk_unique
|
||||
ON asset (lazy_pk) WHERE lazy_pk IS NOT NULL",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// Index unique sur lazy_pk (non-NULL) pour éviter les doublons
|
||||
// Un lazy_pk ne peut pointer que vers un seul entry
|
||||
conn.execute(
|
||||
@@ -220,7 +250,12 @@ impl DB {
|
||||
// Inscrire la version du schéma
|
||||
conn.execute_batch(&format!("PRAGMA user_version = {}", SCHEMA_VERSION))?;
|
||||
|
||||
Ok((Self { conn: Mutex::new(conn) }, was_reset))
|
||||
Ok((
|
||||
Self {
|
||||
conn: Mutex::new(conn),
|
||||
},
|
||||
was_reset,
|
||||
))
|
||||
}
|
||||
|
||||
/// Ajoute ou met à jour une entrée dans la base de données
|
||||
@@ -1150,7 +1185,9 @@ impl DB {
|
||||
|
||||
tracing::debug!(
|
||||
"update_lazy_to_downloaded: {} → {} ({} metadata rows still under lazy_pk)",
|
||||
lazy_pk, real_pk, meta_under_lazy
|
||||
lazy_pk,
|
||||
real_pk,
|
||||
meta_under_lazy
|
||||
);
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
[package]
|
||||
name = "pmoconfig"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
pmoutils = { version = "0.1.2", registry = "pmo" }
|
||||
|
||||
@@ -20,9 +20,7 @@ ratatui = { version = "0.26", default-features = false, features = ["crossterm"]
|
||||
crossterm = "0.27"
|
||||
rust_cast = "0.19"
|
||||
rustls = { version = "0.23", features = ["aws-lc-rs"] }
|
||||
mdns = "3.0"
|
||||
async-std = "1.12"
|
||||
futures-util = "0.3"
|
||||
mdns-sd = "0.19"
|
||||
smol = "2.0"
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -13,7 +13,7 @@ use tracing::{debug, error, info, warn};
|
||||
use crate::discovery::manager::UDNRegistry;
|
||||
use crate::errors::ControlPointError;
|
||||
use crate::events::{MediaServerEventBus, RendererEventBus};
|
||||
use crate::media_server::{MediaBrowser, MusicServer, playback_item_from_entry};
|
||||
use crate::media_server::{playback_item_from_entry, MediaBrowser, MusicServer};
|
||||
use crate::media_server_events::spawn_media_server_event_runtime;
|
||||
use crate::model::{MediaServerEvent, RendererEvent};
|
||||
use crate::model::{PlaybackState, TrackMetadata};
|
||||
@@ -26,7 +26,7 @@ use crate::openapi::{
|
||||
CurrentTrackMetadata, FullRendererSnapshot, QueueItem, QueueSnapshotView, RendererBindingView,
|
||||
RendererStateView,
|
||||
};
|
||||
use crate::queue::{EnqueueMode, PlaybackItem, QueueSnapshot};
|
||||
use crate::queue::{EnqueueMode, PlaybackItem, QueueSnapshot, SyncScheduleOutcome};
|
||||
use crate::registry::DeviceRegistry;
|
||||
|
||||
/// Control point minimal :
|
||||
@@ -147,51 +147,52 @@ impl ControlPoint {
|
||||
let udn_cache_for_mdns = Arc::clone(&udn_cache);
|
||||
thread::spawn(move || {
|
||||
use crate::discovery::ChromecastDiscoveryManager;
|
||||
use futures_util::StreamExt;
|
||||
use mdns_sd::{ServiceDaemon, ServiceEvent};
|
||||
|
||||
// Créer le gestionnaire de découverte UPNP
|
||||
let mut discovery_manager =
|
||||
ChromecastDiscoveryManager::new(registry_for_mdns, udn_cache_for_mdns);
|
||||
|
||||
debug!("Starting mDNS discovery thread for Chromecast devices");
|
||||
|
||||
const SERVICE_NAME: &str = "_googlecast._tcp.local";
|
||||
const SERVICE_TYPE: &str = "_googlecast._tcp.local.";
|
||||
|
||||
// Run async discovery in a blocking task
|
||||
async_std::task::block_on(async {
|
||||
// Create mDNS discovery stream with 15 second query interval
|
||||
// (shorter interval for faster initial discovery)
|
||||
match mdns::discover::all(SERVICE_NAME, Duration::from_secs(15)) {
|
||||
Ok(discovery) => {
|
||||
let stream = discovery.listen();
|
||||
futures_util::pin_mut!(stream);
|
||||
|
||||
debug!("mDNS discovery stream started for Chromecast devices");
|
||||
|
||||
// Listen to mDNS responses
|
||||
while let Some(result) = stream.next().await {
|
||||
match result {
|
||||
Ok(response) => {
|
||||
debug!(
|
||||
"Received mDNS response with {} records",
|
||||
response.records().count()
|
||||
);
|
||||
|
||||
discovery_manager.handle_mdns_response(response);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("mDNS discovery error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
warn!("mDNS discovery stream ended unexpectedly");
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to start mDNS discovery: {}", e);
|
||||
}
|
||||
let daemon = match ServiceDaemon::new() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
error!("Failed to create mDNS daemon: {}", e);
|
||||
return;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let receiver = match daemon.browse(SERVICE_TYPE) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
error!("Failed to start mDNS browse for {}: {}", SERVICE_TYPE, e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
debug!("mDNS discovery started for Chromecast devices");
|
||||
|
||||
while let Ok(event) = receiver.recv() {
|
||||
match event {
|
||||
ServiceEvent::ServiceResolved(info) => {
|
||||
debug!(
|
||||
fullname = info.get_fullname(),
|
||||
host = info.get_hostname(),
|
||||
port = info.get_port(),
|
||||
"mDNS Chromecast service resolved"
|
||||
);
|
||||
discovery_manager.handle_service_resolved(&info);
|
||||
}
|
||||
ServiceEvent::ServiceRemoved(_service_type, fullname) => {
|
||||
debug!(fullname = %fullname, "mDNS Chromecast service removed");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
warn!("mDNS discovery receiver closed");
|
||||
});
|
||||
|
||||
// Note: Renderer polling is now handled by each MusicRenderer's own watcher thread.
|
||||
@@ -267,19 +268,12 @@ impl ControlPoint {
|
||||
"Triggering queue refresh for bound playlist"
|
||||
);
|
||||
|
||||
if let Err(err) = refresh_attached_queue_for(
|
||||
let _ = schedule_queue_refresh_for(
|
||||
®istry_for_media_worker,
|
||||
&renderer_id,
|
||||
&event_bus_for_media_worker,
|
||||
None,
|
||||
) {
|
||||
warn!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
server = server_id.0.as_str(),
|
||||
error = %err,
|
||||
"Failed to refresh queue from playlist container"
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
MediaServerEvent::Online { server_id, info } => {
|
||||
@@ -337,18 +331,12 @@ impl ControlPoint {
|
||||
"Periodic refresh triggered for bound playlist"
|
||||
);
|
||||
|
||||
if let Err(err) = refresh_attached_queue_for(
|
||||
let _ = schedule_queue_refresh_for(
|
||||
®istry_for_periodic,
|
||||
&renderer_id,
|
||||
&event_bus_for_periodic,
|
||||
None,
|
||||
) {
|
||||
warn!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
error = %err,
|
||||
"Periodic refresh failed for bound playlist"
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
})?;
|
||||
@@ -851,11 +839,17 @@ impl ControlPoint {
|
||||
"Playing current playback item from queue"
|
||||
);
|
||||
|
||||
// Temporarily disable auto-advance to prevent race condition
|
||||
// when renderer sends Stopped event during SetAVTransportURI
|
||||
renderer.set_playback_source(PlaybackSource::None);
|
||||
// Note: We set playback_source to FromQueue BEFORE calling play_from_queue().
|
||||
// This prevents a race condition where:
|
||||
// 1. play_from_queue() is called
|
||||
// 2. watcher detects STOPPED from track transition (before playback_source is set)
|
||||
// 3. with source=None, is_playing_from_queue() returns false, breaking auto-advance
|
||||
// By setting it BEFORE, even if STOPPED is detected, source will be FromQueue.
|
||||
// Note: We no longer temporarily set playback_source to None (that caused Bug #2).
|
||||
|
||||
// Start playback using play_from_queue which preserves the queue
|
||||
// Set source to FromQueue BEFORE calling to prevent race condition
|
||||
renderer.set_playback_source(PlaybackSource::FromQueue);
|
||||
if let Err(err) = renderer.play_from_queue() {
|
||||
error!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
@@ -877,7 +871,7 @@ impl ControlPoint {
|
||||
let metadata = playback_item_track_metadata(&item);
|
||||
renderer.set_last_metadata(Some(metadata));
|
||||
|
||||
renderer.set_playback_source(PlaybackSource::FromQueue);
|
||||
// Note: playback_source already set to FromQueue above
|
||||
|
||||
// Prefetch next track if supported (gapless playback)
|
||||
self.prefetch_next_track(&renderer, renderer_id);
|
||||
@@ -907,8 +901,9 @@ impl ControlPoint {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Temporarily disable auto-advance to prevent race condition
|
||||
renderer.set_playback_source(PlaybackSource::None);
|
||||
// Note: Same fix as play_current_from_queue - set source BEFORE calling.
|
||||
// Set source to FromQueue BEFORE calling to prevent race condition
|
||||
renderer.set_playback_source(PlaybackSource::FromQueue);
|
||||
|
||||
// Use the backend's play_next which handles queue advancement correctly for each backend type
|
||||
if let Err(err) = renderer.play_next_from_queue() {
|
||||
@@ -1047,8 +1042,9 @@ impl ControlPoint {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Temporarily disable auto-advance to prevent race condition
|
||||
renderer.set_playback_source(PlaybackSource::None);
|
||||
// Note: Same fix as play_current_from_queue - set source BEFORE calling.
|
||||
// Set source to FromQueue BEFORE calling to prevent race condition
|
||||
renderer.set_playback_source(PlaybackSource::FromQueue);
|
||||
|
||||
// Use the backend's play_from_index which handles everything correctly
|
||||
if let Err(err) = renderer.play_from_index(index) {
|
||||
@@ -1182,19 +1178,23 @@ impl ControlPoint {
|
||||
binding.auto_play_on_refresh = auto_play;
|
||||
renderer.set_playlist_binding(Some(binding));
|
||||
|
||||
let mut auto_start_cb = |rid: &DeviceId| self.play_current_from_queue(rid);
|
||||
let callback: Option<&mut dyn FnMut(&DeviceId) -> Result<(), ControlPointError>> =
|
||||
if auto_play {
|
||||
Some(&mut auto_start_cb)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
return refresh_attached_queue_for(
|
||||
&self.registry,
|
||||
renderer_id,
|
||||
&self.event_bus,
|
||||
callback,
|
||||
);
|
||||
let callback: Option<
|
||||
Box<dyn FnOnce(&DeviceId) -> Result<(), ControlPointError> + Send + 'static>,
|
||||
> = if auto_play {
|
||||
let reg = Arc::clone(&self.registry);
|
||||
Some(Box::new(move |rid: &DeviceId| {
|
||||
let renderer = reg.read().unwrap().get_renderer(rid).ok_or_else(|| {
|
||||
ControlPointError::ControlPoint(format!("Renderer {} not found", rid.0))
|
||||
})?;
|
||||
// Use retry version to handle transient renderer errors (like JBL Authentics 300)
|
||||
renderer.play_current_from_queue_with_retry()
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let _ =
|
||||
schedule_queue_refresh_for(&self.registry, renderer_id, &self.event_bus, callback);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// CRITICAL: When attaching a new playlist to a renderer, we must UNCONDITIONALLY
|
||||
@@ -1247,21 +1247,27 @@ impl ControlPoint {
|
||||
// Note: BindingChanged event is emitted automatically by MusicRenderer::set_playlist_binding()
|
||||
|
||||
// For initial attach with auto_play, force playback start (don't check if idle)
|
||||
let mut auto_start_cb = |rid: &DeviceId| {
|
||||
debug!(
|
||||
renderer = rid.0.as_str(),
|
||||
"Attach callback: forcing playback start (not checking if idle)"
|
||||
);
|
||||
self.play_current_from_queue(rid)
|
||||
let callback: Option<
|
||||
Box<dyn FnOnce(&DeviceId) -> Result<(), ControlPointError> + Send + 'static>,
|
||||
> = if auto_play {
|
||||
let reg = Arc::clone(&self.registry);
|
||||
Some(Box::new(move |rid: &DeviceId| {
|
||||
debug!(
|
||||
renderer = rid.0.as_str(),
|
||||
"Attach callback: forcing playback start (not checking if idle)"
|
||||
);
|
||||
let renderer = reg.read().unwrap().get_renderer(rid).ok_or_else(|| {
|
||||
ControlPointError::ControlPoint(format!("Renderer {} not found", rid.0))
|
||||
})?;
|
||||
// Use retry version to handle transient renderer errors (like JBL Authentics 300)
|
||||
renderer.play_current_from_queue_with_retry()
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let callback: Option<&mut dyn FnMut(&DeviceId) -> Result<(), ControlPointError>> =
|
||||
if auto_play {
|
||||
Some(&mut auto_start_cb)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
refresh_attached_queue_for(&self.registry, renderer_id, &self.event_bus, callback)
|
||||
let _ = schedule_queue_refresh_for(&self.registry, renderer_id, &self.event_bus, callback);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Detach a renderer's queue from its associated playlist container.
|
||||
@@ -1760,6 +1766,203 @@ fn refresh_attached_queue_for(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fetch_queue_items_for(
|
||||
music_server: &Arc<crate::media_server::MusicServer>,
|
||||
container_id: &str,
|
||||
) -> Result<Vec<PlaybackItem>, ControlPointError> {
|
||||
const MAX_BROWSE_ATTEMPTS: usize = 3;
|
||||
const BROWSE_RETRY_DELAY_MS: u64 = 200;
|
||||
const BROWSE_PAGE_SIZE: u32 = 64;
|
||||
|
||||
let entries = {
|
||||
let mut all_entries = Vec::new();
|
||||
let mut offset = 0u32;
|
||||
loop {
|
||||
let mut attempt = 1;
|
||||
let page = loop {
|
||||
match music_server.browse_children(container_id, offset, BROWSE_PAGE_SIZE) {
|
||||
Ok(e) => break e,
|
||||
Err(err) => {
|
||||
if attempt >= MAX_BROWSE_ATTEMPTS {
|
||||
return Err(err);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(
|
||||
BROWSE_RETRY_DELAY_MS * attempt as u64,
|
||||
));
|
||||
attempt += 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
let fetched = page.len() as u32;
|
||||
all_entries.extend(page);
|
||||
if fetched < BROWSE_PAGE_SIZE {
|
||||
break;
|
||||
}
|
||||
offset += fetched;
|
||||
}
|
||||
all_entries
|
||||
};
|
||||
|
||||
let new_items: Vec<PlaybackItem> = entries
|
||||
.iter()
|
||||
.filter_map(|entry| playback_item_from_entry(music_server.clone(), entry))
|
||||
.collect();
|
||||
|
||||
Ok(new_items)
|
||||
}
|
||||
|
||||
fn schedule_queue_refresh_for(
|
||||
registry: &Arc<RwLock<DeviceRegistry>>,
|
||||
renderer_id: &DeviceId,
|
||||
event_bus: &RendererEventBus,
|
||||
after_refresh: Option<
|
||||
Box<dyn FnOnce(&DeviceId) -> Result<(), ControlPointError> + Send + 'static>,
|
||||
>,
|
||||
) -> SyncScheduleOutcome {
|
||||
// Step 1: Get renderer from registry
|
||||
let renderer = {
|
||||
let reg = registry.read().unwrap();
|
||||
reg.get_renderer(renderer_id)
|
||||
};
|
||||
|
||||
let renderer = match renderer {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
debug!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
"schedule_queue_refresh_for: renderer not found"
|
||||
);
|
||||
return SyncScheduleOutcome::Scheduled;
|
||||
}
|
||||
};
|
||||
|
||||
// Check if there's a binding
|
||||
let (server_id, container_id) = {
|
||||
let binding = match renderer.get_playlist_binding() {
|
||||
Some(b) => b,
|
||||
None => {
|
||||
debug!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
"schedule_queue_refresh_for: no binding present"
|
||||
);
|
||||
return SyncScheduleOutcome::Scheduled;
|
||||
}
|
||||
};
|
||||
(binding.server_id.clone(), binding.container_id.clone())
|
||||
};
|
||||
|
||||
// Step 2: Get server from registry
|
||||
let music_server = {
|
||||
let reg = registry.read().unwrap();
|
||||
reg.get_server(&server_id)
|
||||
};
|
||||
|
||||
let music_server = match music_server {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
warn!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
server = server_id.0.as_str(),
|
||||
"schedule_queue_refresh_for: server not found in registry"
|
||||
);
|
||||
return SyncScheduleOutcome::Scheduled;
|
||||
}
|
||||
};
|
||||
|
||||
if !music_server.is_online() {
|
||||
debug!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
server = server_id.0.as_str(),
|
||||
"schedule_queue_refresh_for: server offline, skipping refresh"
|
||||
);
|
||||
return SyncScheduleOutcome::Scheduled;
|
||||
}
|
||||
|
||||
// Emit QueueRefreshing
|
||||
event_bus.broadcast(RendererEvent::QueueRefreshing {
|
||||
id: renderer_id.clone(),
|
||||
});
|
||||
|
||||
// Get the queue Arc
|
||||
let queue_arc = {
|
||||
let reg = registry.read().unwrap();
|
||||
match reg.get_renderer_queue_arc(renderer_id) {
|
||||
Some(q) => q,
|
||||
None => {
|
||||
warn!("schedule_queue_refresh_for: could not get queue arc");
|
||||
return SyncScheduleOutcome::Scheduled;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Build the pending_items_fn for re-fetching
|
||||
let registry_clone = Arc::clone(registry);
|
||||
let server_id_clone = server_id.clone();
|
||||
let container_id_clone = container_id.clone();
|
||||
let pending_fn = Box::new(move || {
|
||||
let music_server = {
|
||||
let reg = registry_clone.read().unwrap();
|
||||
reg.get_server(&server_id_clone)
|
||||
};
|
||||
match music_server {
|
||||
Some(s) => fetch_queue_items_for(&s, &container_id_clone),
|
||||
None => Err(ControlPointError::MediaServerError(
|
||||
"Server not found".to_string(),
|
||||
)),
|
||||
}
|
||||
});
|
||||
|
||||
// Build on_ready callback
|
||||
let rid = renderer_id.clone();
|
||||
let bus = event_bus.clone();
|
||||
let after_cb = after_refresh;
|
||||
let on_ready: Option<Box<dyn FnOnce() + Send + 'static>> = Some(Box::new(move || {
|
||||
bus.broadcast(RendererEvent::QueueReadyToPlay { id: rid.clone() });
|
||||
if let Some(cb) = after_cb {
|
||||
if let Err(e) = cb(&rid) {
|
||||
warn!("auto-play callback failed: {}", e);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Build on_complete callback
|
||||
let rid2 = renderer_id.clone();
|
||||
let bus2 = event_bus.clone();
|
||||
let on_complete = Box::new(move |queue_len: usize| {
|
||||
bus2.broadcast(RendererEvent::QueueUpdated {
|
||||
id: rid2.clone(),
|
||||
queue_length: queue_len,
|
||||
});
|
||||
});
|
||||
|
||||
// First fetch
|
||||
let items = match fetch_queue_items_for(&music_server, &container_id) {
|
||||
Ok(items) => items,
|
||||
Err(e) => {
|
||||
warn!("schedule_queue_refresh_for: failed to fetch items: {}", e);
|
||||
return SyncScheduleOutcome::Scheduled;
|
||||
}
|
||||
};
|
||||
|
||||
// Call schedule_sync
|
||||
let outcome = crate::queue::MusicQueue::schedule_sync(
|
||||
&queue_arc,
|
||||
&renderer_id.0,
|
||||
items,
|
||||
pending_fn,
|
||||
on_ready,
|
||||
on_complete,
|
||||
);
|
||||
|
||||
if matches!(outcome, SyncScheduleOutcome::AlreadyRunning) {
|
||||
event_bus.broadcast(RendererEvent::QueueSyncCancelled {
|
||||
id: renderer_id.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
outcome
|
||||
}
|
||||
|
||||
fn didl_item_from_playback_item(item: &PlaybackItem) -> DidlItem {
|
||||
let metadata = item.metadata.as_ref();
|
||||
let title = metadata
|
||||
|
||||
@@ -5,14 +5,15 @@
|
||||
//! This module handles the discovery of Chromecast devices and registers them
|
||||
//! directly into the `DeviceRegistry`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
use crate::DeviceId;
|
||||
use crate::DeviceRegistry;
|
||||
use mdns_sd::ResolvedService;
|
||||
use mdns_sd::ServiceInfo;
|
||||
|
||||
use crate::discovery::manager::UDNRegistry;
|
||||
use crate::model::{RendererCapabilities, RendererInfo, RendererProtocol};
|
||||
use crate::DeviceId;
|
||||
use crate::DeviceRegistry;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// Gestionnaire des événements mDNS pour Chromecast.
|
||||
@@ -32,146 +33,80 @@ impl ChromecastDiscoveryManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Traite une réponse mDNS pour un appareil Chromecast.
|
||||
/// Traite un service Chromecast résolu par mDNS-SD.
|
||||
///
|
||||
/// Cette fonction parse les réponses de service discovery mDNS pour les appareils
|
||||
/// Chromecast et les enregistre directement dans le registre.
|
||||
pub fn handle_mdns_response(&mut self, response: mdns::Response) {
|
||||
// Extract basic information from the mDNS response
|
||||
let service_name = match response.records().find_map(|r| {
|
||||
if let mdns::RecordKind::PTR(ref name) = r.kind {
|
||||
Some(name.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}) {
|
||||
Some(name) => name,
|
||||
None => {
|
||||
warn!("No PTR record found in mDNS response");
|
||||
return;
|
||||
}
|
||||
};
|
||||
/// `ServiceInfo` arrive pré-assemblé : plus besoin de jointure manuelle
|
||||
/// des enregistrements PTR / A / SRV / TXT.
|
||||
pub fn handle_service_resolved(&mut self, info: &ResolvedService) {
|
||||
let fullname = info.get_fullname().to_string();
|
||||
|
||||
debug!("Processing mDNS response for service: {}", service_name);
|
||||
debug!("Processing resolved Chromecast service: {}", fullname);
|
||||
|
||||
// Extract IP addresses
|
||||
let addresses: Vec<IpAddr> = response
|
||||
.records()
|
||||
.filter_map(|r| match r.kind {
|
||||
mdns::RecordKind::A(addr) => Some(IpAddr::V4(addr)),
|
||||
mdns::RecordKind::AAAA(addr) => Some(IpAddr::V6(addr)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
if addresses.is_empty() {
|
||||
warn!(
|
||||
"No IP address found for Chromecast device: {}",
|
||||
service_name
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prefer IPv4 addresses
|
||||
let host = match addresses
|
||||
let host = match info
|
||||
.get_addresses()
|
||||
.iter()
|
||||
.find(|addr| matches!(addr, IpAddr::V4(_)))
|
||||
.or_else(|| addresses.first())
|
||||
.find(|a| a.is_ipv4())
|
||||
.or_else(|| info.get_addresses().iter().next())
|
||||
{
|
||||
Some(addr) => addr.to_string(),
|
||||
None => {
|
||||
warn!("Could not extract host from addresses");
|
||||
warn!("No IP address for Chromecast service: {}", fullname);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Extract port from SRV record
|
||||
let port = response
|
||||
.records()
|
||||
.find_map(|r| {
|
||||
if let mdns::RecordKind::SRV { port, .. } = r.kind {
|
||||
Some(port)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or(8009); // Default Chromecast port
|
||||
let port = info.get_port();
|
||||
|
||||
// Extract TXT records for additional metadata
|
||||
let txt_records: HashMap<String, String> = response
|
||||
.records()
|
||||
.filter_map(|r| {
|
||||
if let mdns::RecordKind::TXT(ref data) = r.kind {
|
||||
Some(data.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.flat_map(|data| {
|
||||
// data is Vec<String>, each string is "key=value"
|
||||
data.into_iter().filter_map(|s| {
|
||||
let parts: Vec<&str> = s.splitn(2, '=').collect();
|
||||
if parts.len() == 2 {
|
||||
Some((parts[0].to_string(), parts[1].to_string()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let uuid = info
|
||||
.get_property_val_str("id")
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let uuid = if uuid.is_empty() {
|
||||
format!("chromecast-{}-{}", host, port)
|
||||
} else {
|
||||
uuid
|
||||
};
|
||||
|
||||
// Extract metadata from TXT records
|
||||
let model = txt_records.get("md").cloned();
|
||||
let uuid = txt_records
|
||||
.get("id")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("chromecast-{}-{}", host, port));
|
||||
let manufacturer = Some("Google Inc.".to_string());
|
||||
let model = info.get_property_val_str("md").map(|s| s.to_string());
|
||||
|
||||
// Extract friendly name from TXT record "fn" if available
|
||||
// Otherwise, extract from service instance name (PTR record)
|
||||
let friendly_name = txt_records.get("fn").cloned().unwrap_or_else(|| {
|
||||
// Fallback: extract from service name, removing the UUID suffix if present
|
||||
service_name
|
||||
.split("._googlecast._tcp.local")
|
||||
.next()
|
||||
.unwrap_or("Unknown Chromecast")
|
||||
.split('-')
|
||||
.take_while(|part| part.len() != 32) // Skip 32-char hex UUID
|
||||
.collect::<Vec<_>>()
|
||||
.join("-")
|
||||
.trim()
|
||||
.to_string()
|
||||
});
|
||||
let friendly_name = info
|
||||
.get_property_val_str("fn")
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| {
|
||||
fullname
|
||||
.split("._googlecast._tcp.local")
|
||||
.next()
|
||||
.unwrap_or("Unknown Chromecast")
|
||||
.split('-')
|
||||
.take_while(|part| part.len() != 32)
|
||||
.collect::<Vec<_>>()
|
||||
.join("-")
|
||||
.trim()
|
||||
.to_string()
|
||||
});
|
||||
|
||||
debug!(
|
||||
"Discovered Chromecast: {} at {}:{} (UUID: {}, Model: {:?})",
|
||||
friendly_name, host, port, uuid, model
|
||||
);
|
||||
|
||||
// Build UDN and check cache
|
||||
let udn = format!("uuid:{}", uuid);
|
||||
let default_max_age = 1800u64;
|
||||
|
||||
// Pour Chromecast, on utilise un max_age par défaut car mDNS n'a pas ce concept
|
||||
let default_max_age = 1800u64; // 30 minutes
|
||||
|
||||
// Check cache to avoid redundant updates
|
||||
if !UDNRegistry::should_fetch(self.udn_cache.clone(), &udn, default_max_age) {
|
||||
debug!("Chromecast {} recently seen, skipping", udn);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create RendererInfo for the registry
|
||||
let renderer_info = build_renderer_info(
|
||||
&uuid,
|
||||
&friendly_name,
|
||||
&host,
|
||||
port,
|
||||
model.as_deref(),
|
||||
manufacturer.as_deref(),
|
||||
Some("Google Inc."),
|
||||
);
|
||||
|
||||
// Register the renderer
|
||||
self.device_registry
|
||||
.write()
|
||||
.expect("DeviceRegistry mutex lock failed")
|
||||
|
||||
@@ -49,6 +49,8 @@ pub enum ControlPointError {
|
||||
SnapshotError(String),
|
||||
#[error("Error on ControlPoint: {0}")]
|
||||
ControlPoint(String),
|
||||
#[error("Queue sync cancelled (superseded by a newer request)")]
|
||||
SyncCancelled,
|
||||
}
|
||||
|
||||
impl ControlPointError {
|
||||
|
||||
@@ -2,8 +2,8 @@ use std::sync::{Arc, Mutex};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use pmodidl::DIDLLite;
|
||||
use pmoupnp::soap::SoapEnvelope;
|
||||
use pmoupnp::soap::error_codes;
|
||||
use pmoupnp::soap::SoapEnvelope;
|
||||
use tracing::{debug, warn};
|
||||
use xmltree::{Element, XMLNode};
|
||||
|
||||
@@ -11,8 +11,8 @@ use crate::errors::ControlPointError;
|
||||
use crate::model::TrackMetadata;
|
||||
use crate::online::{DeviceConnectionState, DeviceOnline};
|
||||
use crate::queue::PlaybackItem;
|
||||
use crate::soap_client::{SoapCallResult, invoke_upnp_action_with_timeout};
|
||||
use crate::{DEFAULT_HTTP_TIMEOUT, DeviceId, DeviceIdentity};
|
||||
use crate::soap_client::{invoke_upnp_action_with_timeout, SoapCallResult};
|
||||
use crate::{DeviceId, DeviceIdentity, DEFAULT_HTTP_TIMEOUT};
|
||||
|
||||
/// Snapshot of a media server discovered through UPnP SSDP.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -91,7 +91,9 @@ impl UpnpMediaServer {
|
||||
start: u32,
|
||||
count: u32,
|
||||
) -> Result<Vec<MediaEntry>, ControlPointError> {
|
||||
Ok(self.browse_with_flag_paged(object_id, browse_flag, start, count)?.entries)
|
||||
Ok(self
|
||||
.browse_with_flag_paged(object_id, browse_flag, start, count)?
|
||||
.entries)
|
||||
}
|
||||
|
||||
fn browse_with_flag_paged(
|
||||
@@ -109,17 +111,23 @@ impl UpnpMediaServer {
|
||||
("Filter", "*".to_string()),
|
||||
("StartingIndex", start_str),
|
||||
("RequestedCount", count_str),
|
||||
("SortCriteria", String::new()),
|
||||
// Tri par titre (alphabétique croissant) pour avoir un ordre déterministe
|
||||
("SortCriteria", "+dc:title".to_string()),
|
||||
];
|
||||
|
||||
let response = self.invoke_content_directory("Browse", None, args)?;
|
||||
let envelope = response.envelope.ok_or_else(|| {
|
||||
ControlPointError::MediaServerError("Missing SOAP envelope in Browse response".to_string())
|
||||
ControlPointError::MediaServerError(
|
||||
"Missing SOAP envelope in Browse response".to_string(),
|
||||
)
|
||||
})?;
|
||||
let total_count = extract_total_matches(&envelope, "BrowseResponse");
|
||||
let didl_xml = extract_result_payload(&envelope, "BrowseResponse")?;
|
||||
let entries = map_didl_entries(&didl_xml)?;
|
||||
Ok(BrowsePage { entries, total_count })
|
||||
Ok(BrowsePage {
|
||||
entries,
|
||||
total_count,
|
||||
})
|
||||
}
|
||||
|
||||
fn has_content_directory(&self) -> bool {
|
||||
@@ -398,7 +406,10 @@ pub trait MediaBrowser {
|
||||
) -> Result<BrowsePage, ControlPointError> {
|
||||
let entries = self.browse_children(object_id, start, count)?;
|
||||
let total_count = entries.len() as u32 + start;
|
||||
Ok(BrowsePage { entries, total_count })
|
||||
Ok(BrowsePage {
|
||||
entries,
|
||||
total_count,
|
||||
})
|
||||
}
|
||||
fn browse_object(&self, object_id: &str) -> Result<MediaEntry, ControlPointError>;
|
||||
fn search(
|
||||
|
||||
@@ -437,6 +437,12 @@ pub enum RendererEvent {
|
||||
QueueRefreshing {
|
||||
id: DeviceId,
|
||||
},
|
||||
QueueReadyToPlay {
|
||||
id: DeviceId,
|
||||
},
|
||||
QueueSyncCancelled {
|
||||
id: DeviceId,
|
||||
},
|
||||
BindingChanged {
|
||||
id: DeviceId,
|
||||
binding: Option<PlaylistBinding>,
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{atomic::AtomicBool, Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Deserialize;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::DeviceIdentity;
|
||||
use crate::arylic_client::{
|
||||
ARYLIC_TCP_PORT, DEFAULT_TIMEOUT_SECS, send_command_no_response, send_command_optional,
|
||||
send_command_required,
|
||||
send_command_no_response, send_command_optional, send_command_required, ARYLIC_TCP_PORT,
|
||||
DEFAULT_TIMEOUT_SECS,
|
||||
};
|
||||
use crate::errors::ControlPointError;
|
||||
use crate::linkplay_client::extract_linkplay_host;
|
||||
use crate::model::{PlaybackState, RendererInfo};
|
||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||
use crate::music_renderer::capabilities::{
|
||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
|
||||
TransportControl, VolumeControl,
|
||||
};
|
||||
use crate::music_renderer::musicrenderer::MusicRendererBackend;
|
||||
use crate::music_renderer::time_utils::{format_hhmmss, ms_to_seconds, parse_hhmmss_strict};
|
||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||
use crate::queue::MusicQueue;
|
||||
use crate::queue::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot};
|
||||
use crate::DeviceIdentity;
|
||||
|
||||
/// Raw response from Arylic MCU+PINFGET command
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -409,8 +409,16 @@ impl QueueBackend for ArylicTcpRenderer {
|
||||
.replace_queue(items, current_index)
|
||||
}
|
||||
|
||||
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||
self.queue.lock().unwrap().sync_queue(items)
|
||||
fn sync_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
_cancel_token: &Arc<AtomicBool>,
|
||||
on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sync_queue(items, &Arc::new(AtomicBool::new(false)), on_ready)
|
||||
}
|
||||
|
||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
//! are wrapped in sync calls using smol::block_on for compatibility with
|
||||
//! the existing sync trait interfaces.
|
||||
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::{Arc, Mutex, Once};
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
@@ -906,8 +907,16 @@ impl QueueBackend for ChromecastRenderer {
|
||||
.replace_queue(items, current_index)
|
||||
}
|
||||
|
||||
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||
self.queue.lock().unwrap().sync_queue(items)
|
||||
fn sync_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
_cancel_token: &Arc<AtomicBool>,
|
||||
on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sync_queue(items, &Arc::new(AtomicBool::new(false)), on_ready)
|
||||
}
|
||||
|
||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
use std::fmt;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{atomic::AtomicBool, Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use ureq::Agent;
|
||||
|
||||
use crate::DeviceIdentity;
|
||||
use crate::errors::ControlPointError;
|
||||
use crate::linkplay_client::{
|
||||
LinkPlayStatus, build_agent, extract_linkplay_host, fetch_status_for_host, percent_encode,
|
||||
build_agent, extract_linkplay_host, fetch_status_for_host, percent_encode, LinkPlayStatus,
|
||||
};
|
||||
use crate::model::{PlaybackState, RendererInfo};
|
||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||
use crate::music_renderer::capabilities::{
|
||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
|
||||
TransportControl, VolumeControl,
|
||||
};
|
||||
use crate::music_renderer::musicrenderer::MusicRendererBackend;
|
||||
use crate::music_renderer::time_utils::parse_hhmmss_strict;
|
||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||
use crate::queue::MusicQueue;
|
||||
use crate::queue::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot};
|
||||
use crate::DeviceIdentity;
|
||||
|
||||
const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 3;
|
||||
|
||||
@@ -287,8 +287,16 @@ impl QueueBackend for LinkPlayRenderer {
|
||||
.replace_queue(items, current_index)
|
||||
}
|
||||
|
||||
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||
self.queue.lock().unwrap().sync_queue(items)
|
||||
fn sync_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
_cancel_token: &Arc<AtomicBool>,
|
||||
on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sync_queue(items, &Arc::new(AtomicBool::new(false)), on_ready)
|
||||
}
|
||||
|
||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::SystemTime;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use pmodidl::{DIDLLite, MediaMetadataParser};
|
||||
use tracing::{debug, error};
|
||||
@@ -312,17 +312,37 @@ impl MusicRenderer {
|
||||
|
||||
/// Main loop for the watcher thread.
|
||||
fn watcher_loop(&self, strategy: WatchStrategy, stop_flag: Arc<AtomicBool>) {
|
||||
let Some(interval) = strategy.polling_interval() else {
|
||||
let Some(base_interval) = strategy.polling_interval() else {
|
||||
// Pure push strategy - no polling needed (future implementation)
|
||||
return;
|
||||
};
|
||||
|
||||
let short_interval = base_interval;
|
||||
let long_interval = Duration::from_millis(5_000);
|
||||
let inactivity_threshold = Duration::from_secs(10);
|
||||
|
||||
let mut tick: u32 = 0;
|
||||
let mut next_poll_time = SystemTime::now();
|
||||
let mut last_activity_time = SystemTime::now();
|
||||
|
||||
while !stop_flag.load(Ordering::SeqCst) {
|
||||
let is_active = {
|
||||
let watched = self
|
||||
.watched_state
|
||||
.lock()
|
||||
.expect("WatchedState mutex poisoned");
|
||||
watched.is_active
|
||||
};
|
||||
|
||||
let interval = if is_active {
|
||||
short_interval
|
||||
} else {
|
||||
long_interval
|
||||
};
|
||||
|
||||
if self.is_online() {
|
||||
self.poll_and_emit_changes(tick);
|
||||
last_activity_time = SystemTime::now();
|
||||
}
|
||||
|
||||
tick = tick.wrapping_add(1);
|
||||
@@ -341,6 +361,17 @@ impl MusicRenderer {
|
||||
);
|
||||
next_poll_time = SystemTime::now();
|
||||
}
|
||||
|
||||
// Reset is_active flag if no activity for threshold
|
||||
if let Ok(elapsed) = SystemTime::now().duration_since(last_activity_time) {
|
||||
if elapsed >= inactivity_threshold {
|
||||
let mut watched = self
|
||||
.watched_state
|
||||
.lock()
|
||||
.expect("WatchedState mutex poisoned");
|
||||
watched.is_active = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!(
|
||||
@@ -662,18 +693,37 @@ impl MusicRenderer {
|
||||
self.set_playback_source(PlaybackSource::None);
|
||||
self.clear_has_played_flag();
|
||||
} else if self.is_playing_from_queue() {
|
||||
// Only auto-advance if we have actually seen a PLAYING state
|
||||
// since the track was started. This prevents auto-advance on
|
||||
// transient STOPPED states during track initialization.
|
||||
if self.check_and_clear_has_played_flag() {
|
||||
debug!(
|
||||
renderer = self.info.friendly_name(),
|
||||
"Renderer stopped after queue-driven playback; advancing"
|
||||
);
|
||||
let has_played = self.check_and_clear_has_played_flag();
|
||||
|
||||
// ✅ CORRECTION BUG PRODUCTION: Garde fou contre latence DB
|
||||
// Si la base de données est saturée (mutex >400ms) on rate parfois l'événement PLAYING.
|
||||
// On autorise l'auto-avance si:
|
||||
// 1. On a bien vu PLAYING OU
|
||||
// 2. Le titre a été lancé depuis plus de 20 secondes
|
||||
let track_start = self.state.lock().unwrap().track_start_time;
|
||||
let elapsed = track_start
|
||||
.and_then(|t| t.elapsed().ok())
|
||||
.unwrap_or_default();
|
||||
let force_advance = elapsed.as_secs() > 20;
|
||||
|
||||
if has_played || force_advance {
|
||||
if force_advance {
|
||||
debug!(
|
||||
renderer = self.info.friendly_name(),
|
||||
elapsed_sec = elapsed.as_secs(),
|
||||
"⚠️ On force l'auto-avance: on a raté l'événement PLAYING (latence DB)"
|
||||
);
|
||||
} else {
|
||||
debug!(
|
||||
renderer = self.info.friendly_name(),
|
||||
"Renderer stopped after queue-driven playback; advancing"
|
||||
);
|
||||
}
|
||||
// Use catch_unwind to prevent panics in play_next_from_queue
|
||||
// from poisoning the backend mutex
|
||||
// Also add retry logic for transient errors from renderer
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
self.play_next_from_queue()
|
||||
self.play_next_from_queue_with_retry()
|
||||
}));
|
||||
match result {
|
||||
Ok(Ok(())) => {}
|
||||
@@ -681,7 +731,7 @@ impl MusicRenderer {
|
||||
error!(
|
||||
renderer = self.info.friendly_name(),
|
||||
error = %err,
|
||||
"Auto-advance failed; clearing queue playback state"
|
||||
"Auto-advance failed after retries; clearing queue playback state"
|
||||
);
|
||||
self.set_playback_source(PlaybackSource::None);
|
||||
}
|
||||
@@ -945,6 +995,12 @@ impl MusicRenderer {
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
/// Get a clone of the queue Arc (for async sync operations).
|
||||
pub fn queue(&self) -> Arc<Mutex<MusicQueue>> {
|
||||
let backend = self.lock_backend_for("queue");
|
||||
crate::music_renderer::capabilities::RendererBackend::queue(&*backend).clone()
|
||||
}
|
||||
|
||||
/// Get the current queue item without advancing.
|
||||
/// Returns the item and count of remaining items after current.
|
||||
pub fn peek_current(&self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
||||
@@ -956,13 +1012,69 @@ impl MusicRenderer {
|
||||
self.lock_backend_for("upcoming_len").upcoming_len()
|
||||
}
|
||||
|
||||
/// Play the current item from the queue with retry logic for transient renderer errors.
|
||||
///
|
||||
/// Some renderers (like JBL Authentics 300) may fail the first Play command
|
||||
/// due to timing issues. This method retries with a small delay.
|
||||
pub fn play_current_from_queue_with_retry(&self) -> Result<(), ControlPointError> {
|
||||
const MAX_RETRIES: usize = 3;
|
||||
const RETRY_DELAY_MS: u64 = 200;
|
||||
|
||||
let mut last_error = None;
|
||||
|
||||
for attempt in 0..MAX_RETRIES {
|
||||
// Reset the has_played flag before starting playback to prevent
|
||||
// auto-advance on transient STOPPED states during track initialization.
|
||||
// The flag will be set back to true when PLAYING state is detected.
|
||||
self.clear_has_played_flag();
|
||||
|
||||
// Set playback_source to FromQueue BEFORE calling backend
|
||||
// to prevent race condition where watcher sees STOPPED before
|
||||
// source is set, breaking auto-advance
|
||||
self.set_playback_source(PlaybackSource::FromQueue);
|
||||
|
||||
match self
|
||||
.lock_backend_for("play_current_from_queue_with_retry")
|
||||
.play_from_queue()
|
||||
{
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => {
|
||||
last_error = Some(e);
|
||||
if attempt < MAX_RETRIES - 1 {
|
||||
tracing::warn!(
|
||||
renderer = self.info.friendly_name(),
|
||||
attempt = attempt + 1,
|
||||
error = %last_error.as_ref().unwrap(),
|
||||
"Initial play attempt failed, retrying..."
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(RETRY_DELAY_MS));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All retries failed
|
||||
Err(last_error.unwrap_or_else(|| {
|
||||
ControlPointError::ControlPoint("Unknown error in retry logic".into())
|
||||
}))
|
||||
}
|
||||
|
||||
/// Play the current item from the queue.
|
||||
#[deprecated(
|
||||
since = "0.1.0",
|
||||
note = "Use play_current_from_queue_with_retry instead"
|
||||
)]
|
||||
pub fn play_current_from_queue(&self) -> Result<(), ControlPointError> {
|
||||
// Reset the has_played flag before starting playback to prevent
|
||||
// Reset the has_played_flag before starting playback to prevent
|
||||
// auto-advance on transient STOPPED states during track initialization.
|
||||
// The flag will be set back to true when PLAYING state is detected.
|
||||
self.clear_has_played_flag();
|
||||
|
||||
// Set playback_source to FromQueue BEFORE calling backend
|
||||
// to prevent race condition where watcher sees STOPPED before
|
||||
// source is set, breaking auto-advance
|
||||
self.set_playback_source(PlaybackSource::FromQueue);
|
||||
|
||||
self.lock_backend_for("play_current_from_queue")
|
||||
.play_from_queue()
|
||||
}
|
||||
@@ -974,11 +1086,50 @@ impl MusicRenderer {
|
||||
// The flag will be set back to true when PLAYING state is detected.
|
||||
self.clear_has_played_flag();
|
||||
|
||||
// Set playback_source to FromQueue BEFORE calling backend
|
||||
// to prevent race condition where watcher sees STOPPED before
|
||||
// source is set, breaking auto-advance
|
||||
self.set_playback_source(PlaybackSource::FromQueue);
|
||||
|
||||
self.lock_backend_for("play_next_from_queue").play_next()?;
|
||||
self.emit_queue_updated();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Play next from queue with retry logic for transient renderer errors.
|
||||
///
|
||||
/// Some renderers (like JBL Authentics 300) may fail the first Play command
|
||||
/// due to timing issues. This method retries with a small delay.
|
||||
pub fn play_next_from_queue_with_retry(&self) -> Result<(), ControlPointError> {
|
||||
const MAX_RETRIES: usize = 3;
|
||||
const RETRY_DELAY_MS: u64 = 200;
|
||||
|
||||
let mut last_error = None;
|
||||
|
||||
for attempt in 0..MAX_RETRIES {
|
||||
match self.play_next_from_queue() {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => {
|
||||
last_error = Some(e);
|
||||
if attempt < MAX_RETRIES - 1 {
|
||||
tracing::warn!(
|
||||
renderer = self.info.friendly_name(),
|
||||
attempt = attempt + 1,
|
||||
error = %last_error.as_ref().unwrap(),
|
||||
"Auto-advance attempt failed, retrying..."
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(RETRY_DELAY_MS));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All retries failed
|
||||
Err(last_error.unwrap_or_else(|| {
|
||||
ControlPointError::ControlPoint("Unknown error in retry logic".into())
|
||||
}))
|
||||
}
|
||||
|
||||
/// Advance the queue index by one without starting playback.
|
||||
///
|
||||
/// Used by the WebRenderer gapless path: the browser autonomously transitions
|
||||
@@ -996,14 +1147,21 @@ impl MusicRenderer {
|
||||
|
||||
/// Play from a specific index in the queue.
|
||||
pub fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
|
||||
// Reset the has_played flag before starting playback to prevent
|
||||
// auto-advance on transient STOPPED states during track initialization.
|
||||
// The flag will be set back to true when PLAYING state is detected.
|
||||
// ✅ CORRECTIF BUG SHUFFLE: Quand on change d'index manuellement
|
||||
// (par exemple shuffle, clique sur un titre), on réinitialise OBLIGATOIREMENT
|
||||
// le flag has_played. Sinon quand le titre se termine l'auto-avance
|
||||
// pense qu'il n'a jamais démarré et s'arrête.
|
||||
tracing::debug!(
|
||||
index = index,
|
||||
renderer = self.info.friendly_name(),
|
||||
"🎯 play_from_index appelé, réinitialisation has_played_flag"
|
||||
);
|
||||
self.clear_has_played_flag();
|
||||
|
||||
self.lock_backend_for("play_from_index")
|
||||
.play_from_index(index)?;
|
||||
self.emit_queue_updated();
|
||||
|
||||
self.set_playback_source(PlaybackSource::FromQueue);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1012,11 +1170,25 @@ impl MusicRenderer {
|
||||
/// Démarre ou reprend la lecture. Si une queue non vide existe,
|
||||
/// joue le track courant de la queue automatiquement (comportement unifié pour tous les backends).
|
||||
pub fn play(&self) -> Result<(), ControlPointError> {
|
||||
// Mark renderer as active for adaptive polling
|
||||
{
|
||||
let mut watched = self
|
||||
.watched_state
|
||||
.lock()
|
||||
.expect("WatchedState mutex poisoned");
|
||||
watched.is_active = true;
|
||||
}
|
||||
|
||||
// Vérifier si on a une queue non vide
|
||||
let backend = self.lock_backend_for("play");
|
||||
let queue_not_empty = backend.len().unwrap_or(0) > 0;
|
||||
|
||||
if queue_not_empty {
|
||||
// Set playback_source to FromQueue BEFORE calling backend
|
||||
// to prevent race condition where watcher sees STOPPED before
|
||||
// source is set, breaking auto-advance
|
||||
self.set_playback_source(PlaybackSource::FromQueue);
|
||||
|
||||
// Si on a des items dans la queue, jouer le track courant (ou le premier si aucun n'est sélectionné)
|
||||
// Cela fonctionne pour tous les backends (UPnP interne, OpenHome, etc.)
|
||||
backend.play_from_queue()
|
||||
@@ -1034,6 +1206,15 @@ impl MusicRenderer {
|
||||
/// Transport control: stop
|
||||
#[track_caller]
|
||||
pub fn stop(&self) -> Result<(), ControlPointError> {
|
||||
// Mark renderer as active for adaptive polling
|
||||
{
|
||||
let mut watched = self
|
||||
.watched_state
|
||||
.lock()
|
||||
.expect("WatchedState mutex poisoned");
|
||||
watched.is_active = true;
|
||||
}
|
||||
|
||||
// Reset the has_played flag when stopping playback.
|
||||
// This ensures that if we start a new track, the flag will be false
|
||||
// until PLAYING state is observed, preventing auto-advance on
|
||||
@@ -1067,6 +1248,15 @@ impl MusicRenderer {
|
||||
|
||||
/// Transport control: seek to relative time
|
||||
pub fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
||||
// Mark renderer as active for adaptive polling
|
||||
{
|
||||
let mut watched = self
|
||||
.watched_state
|
||||
.lock()
|
||||
.expect("WatchedState mutex poisoned");
|
||||
watched.is_active = true;
|
||||
}
|
||||
|
||||
self.lock_backend_for("seek_rel_time").seek_rel_time(hhmmss)
|
||||
}
|
||||
|
||||
@@ -1314,8 +1504,28 @@ impl MusicRenderer {
|
||||
/// - If the current track is NOT in the new items, it's preserved as the first item
|
||||
/// - If there's no current track, the queue is simply replaced
|
||||
pub fn sync_queue(&self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||
self.sync_queue_with_callback(items, None, None)
|
||||
}
|
||||
|
||||
/// Synchronize the queue with new items with optional cancel token and on_ready callback.
|
||||
pub fn sync_queue_with_callback(
|
||||
&self,
|
||||
items: Vec<PlaybackItem>,
|
||||
cancel_token: Option<&Arc<AtomicBool>>,
|
||||
on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
{
|
||||
let mut watched = self
|
||||
.watched_state
|
||||
.lock()
|
||||
.expect("WatchedState mutex poisoned");
|
||||
watched.is_active = true;
|
||||
}
|
||||
|
||||
let mut backend = self.lock_backend_for("sync_queue");
|
||||
backend.sync_queue(items)?;
|
||||
let dummy_token = Arc::new(AtomicBool::new(false));
|
||||
let token = cancel_token.unwrap_or(&dummy_token);
|
||||
backend.sync_queue(items, token, on_ready)?;
|
||||
drop(backend);
|
||||
self.emit_queue_updated();
|
||||
Ok(())
|
||||
@@ -1444,6 +1654,11 @@ impl MusicRenderer {
|
||||
// The flag will be set back to true when PLAYING state is detected.
|
||||
self.clear_has_played_flag();
|
||||
|
||||
// Set playback_source to FromQueue BEFORE calling backend
|
||||
// to prevent race condition where watcher sees STOPPED before
|
||||
// source is set, breaking auto-advance
|
||||
self.set_playback_source(PlaybackSource::FromQueue);
|
||||
|
||||
self.lock_backend_for("play_from_queue").play_from_queue()
|
||||
}
|
||||
|
||||
@@ -1499,11 +1714,18 @@ impl MusicRenderer {
|
||||
)
|
||||
}
|
||||
|
||||
/// Marks playback as external if currently idle (source is None).
|
||||
/// Marks playback as external if currently idle.
|
||||
///
|
||||
/// Only sets to External if we were already playing from an external source.
|
||||
/// Does NOT change None -> External because that would break queue playback
|
||||
/// (the control_point will set it to FromQueue after play_from_queue succeeds).
|
||||
pub fn mark_external_if_idle(&self) {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
if matches!(state.playback_source, PlaybackSource::None) {
|
||||
state.playback_source = PlaybackSource::External;
|
||||
if matches!(state.playback_source, PlaybackSource::External) {
|
||||
// Keep External if we were already playing externally
|
||||
} else {
|
||||
// Don't change None -> External - that breaks queue auto-advance!
|
||||
// The control_point will set playback_source to FromQueue after play_from_queue succeeds.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1656,8 +1878,9 @@ impl MusicRenderer {
|
||||
// 5. Replace the queue with shuffled items, starting at index 0
|
||||
self.replace_queue(shuffled_items, Some(0))?;
|
||||
|
||||
// 6. Start playback from the first track
|
||||
self.play_from_index(0)?;
|
||||
// 6. Start playback from the first track with retry for JBL-like renderers
|
||||
// that fail the first Play command due to timing issues
|
||||
self.play_current_from_queue_with_retry()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -2216,14 +2439,21 @@ impl QueueBackend for MusicRendererBackend {
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||
fn sync_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicRendererBackend::Upnp(r) => r.sync_queue(items),
|
||||
MusicRendererBackend::OpenHome(r) => r.sync_queue(items),
|
||||
MusicRendererBackend::LinkPlay(r) => r.sync_queue(items),
|
||||
MusicRendererBackend::ArylicTcp(r) => r.sync_queue(items),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.sync_queue(items),
|
||||
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.sync_queue(items),
|
||||
MusicRendererBackend::Upnp(r) => r.sync_queue(items, cancel_token, on_ready),
|
||||
MusicRendererBackend::OpenHome(r) => r.sync_queue(items, cancel_token, on_ready),
|
||||
MusicRendererBackend::LinkPlay(r) => r.sync_queue(items, cancel_token, on_ready),
|
||||
MusicRendererBackend::ArylicTcp(r) => r.sync_queue(items, cancel_token, on_ready),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.sync_queue(items, cancel_token, on_ready),
|
||||
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => {
|
||||
upnp.sync_queue(items, cancel_token, on_ready)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{atomic::AtomicBool, Arc, Mutex};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use crate::music_renderer::capabilities::{
|
||||
@@ -182,9 +182,8 @@ impl OpenHomeRenderer {
|
||||
/// Retourne les IDs des pistes de la playlist OpenHome.
|
||||
/// Plus rapide que snapshot_openhome_playlist() car ne récupère pas les métadonnées.
|
||||
pub(crate) fn openhome_playlist_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
||||
// Use the queue's cached track_ids() instead of direct id_array() call
|
||||
let queue = self.queue.lock().unwrap();
|
||||
if let MusicQueue::OpenHome(oh_queue) = &*queue {
|
||||
if let Some(oh_queue) = queue.as_openhome() {
|
||||
oh_queue.track_ids()
|
||||
} else {
|
||||
Err(ControlPointError::QueueError(
|
||||
@@ -209,9 +208,8 @@ impl OpenHomeRenderer {
|
||||
let insert_after = match after_id {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
// Use the queue's cached track_ids() instead of direct id_array() call
|
||||
let queue = self.queue.lock().unwrap();
|
||||
if let MusicQueue::OpenHome(oh_queue) = &*queue {
|
||||
if let Some(oh_queue) = queue.as_openhome() {
|
||||
oh_queue
|
||||
.track_ids()?
|
||||
.last()
|
||||
@@ -413,7 +411,7 @@ impl PlaybackPosition for OpenHomeRenderer {
|
||||
|
||||
// Get track ID from queue (uses cached data)
|
||||
let queue_guard_for_id = self.queue.lock().unwrap();
|
||||
if let MusicQueue::OpenHome(oh_queue) = &*queue_guard_for_id {
|
||||
if let Some(oh_queue) = queue_guard_for_id.as_openhome() {
|
||||
match oh_queue.current_track() {
|
||||
Ok(id_opt) => track_id = id_opt,
|
||||
Err(err) => debug!(
|
||||
@@ -690,17 +688,48 @@ impl QueueBackend for OpenHomeRenderer {
|
||||
items: Vec<PlaybackItem>,
|
||||
current_index: Option<usize>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
// ✅ CORRECTION BUG PRODUCTION: On ne charge PAS toutes les métadonnées
|
||||
// dans le thread principal. OpenHome sur 1000 titres inondait la base SQLite
|
||||
// et bloquait TOUS les autres threads (mutex >500ms).
|
||||
//
|
||||
// On fait juste l'insertion minimaliste maintenant. Le préchargement
|
||||
// des métadonnées est délégué à un thread background.
|
||||
self.queue
|
||||
.lock()
|
||||
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
|
||||
.replace_queue(items, current_index)
|
||||
.map_err(|_| ControlPointError::QueueError("Mutex poisoned".into()))?
|
||||
.replace_queue(items, current_index)?;
|
||||
|
||||
// Background worker: charge les métadonnées petit à petit sans bloquer personne
|
||||
let queue = self.queue.clone();
|
||||
std::thread::spawn(move || {
|
||||
debug!("🔄 OpenHome: préchargement métadonnées queue en background");
|
||||
if let Ok(mut queue) = queue.lock() {
|
||||
// On ne fait que les 10 prochains titres maintenant, le reste on s'en fout
|
||||
if let Ok(Some(idx)) = queue.current_index() {
|
||||
let end = std::cmp::min(idx + 10, queue.len().unwrap_or(0));
|
||||
for i in idx..end {
|
||||
let _ = queue.get_item(i);
|
||||
// Petit délai pour ne pas noyer la base de données
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
}
|
||||
debug!("✅ OpenHome: préchargement métadonnées terminé");
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||
fn sync_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
|
||||
.sync_queue(items)
|
||||
.sync_queue(items, cancel_token, on_ready)
|
||||
}
|
||||
|
||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{atomic::AtomicBool, Arc, Mutex};
|
||||
|
||||
use crate::errors::ControlPointError;
|
||||
use crate::model::PlaybackState;
|
||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||
use crate::music_renderer::capabilities::{
|
||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
|
||||
TransportControl, VolumeControl,
|
||||
};
|
||||
use crate::music_renderer::musicrenderer::{MusicRendererBackend, build_didl_lite_metadata};
|
||||
use crate::music_renderer::musicrenderer::{build_didl_lite_metadata, MusicRendererBackend};
|
||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot};
|
||||
use crate::upnp_clients::{
|
||||
AvTransportClient, ConnectionInfo, ConnectionManagerClient, PositionInfo, ProtocolInfo,
|
||||
@@ -218,9 +218,22 @@ impl QueueTransportControl for UpnpRenderer {
|
||||
// Détecte si l'URL est un flux continu en interrogeant le serveur HTTP
|
||||
let is_stream = crate::music_renderer::is_continuous_stream_url(&item.uri);
|
||||
*self.continuous_stream.lock().unwrap() = is_stream;
|
||||
|
||||
// Log current queue state for debugging
|
||||
let queue_state = {
|
||||
let queue = self.queue.lock().unwrap();
|
||||
let idx = queue.current_index().unwrap_or(None);
|
||||
let len = queue.len().unwrap_or(0);
|
||||
let uri = item.uri.clone();
|
||||
let title = item.metadata.as_ref().and_then(|m| m.title.clone());
|
||||
(idx, len, uri, title)
|
||||
};
|
||||
tracing::debug!(
|
||||
"UpnpRenderer play_from_queue: URI={}, continuous_stream={}",
|
||||
item.uri,
|
||||
"UpnpRenderer play_from_queue: index={:?}/{}, uri={}, title={:?}, continuous_stream={}",
|
||||
queue_state.0,
|
||||
queue_state.1,
|
||||
queue_state.2,
|
||||
queue_state.3,
|
||||
is_stream
|
||||
);
|
||||
|
||||
@@ -243,11 +256,28 @@ impl QueueTransportControl for UpnpRenderer {
|
||||
}
|
||||
|
||||
fn play_next(&self) -> Result<(), ControlPointError> {
|
||||
let current_idx = {
|
||||
let queue = self.queue.lock().unwrap();
|
||||
let idx = queue.current_index().unwrap_or(None);
|
||||
let len = queue.len().unwrap_or(0);
|
||||
tracing::debug!(
|
||||
current_index = ?idx,
|
||||
queue_len = len,
|
||||
"play_next: attempting to advance"
|
||||
);
|
||||
idx
|
||||
};
|
||||
{
|
||||
let mut queue = self.queue.lock().unwrap();
|
||||
if !queue.advance()? {
|
||||
return Err(ControlPointError::QueueError("No next track".into()));
|
||||
}
|
||||
let new_idx = queue.current_index().unwrap_or(None);
|
||||
tracing::debug!(
|
||||
previous_index = ?current_idx,
|
||||
new_index = ?new_idx,
|
||||
"play_next: advanced"
|
||||
);
|
||||
}
|
||||
|
||||
self.play_from_queue()
|
||||
@@ -269,6 +299,9 @@ impl QueueTransportControl for UpnpRenderer {
|
||||
let mut queue = self.queue.lock().unwrap();
|
||||
queue.set_index(Some(index))?;
|
||||
}
|
||||
// CORRECTIF: Quand on change l'index manuellement (shuffle, sélection d'un titre)
|
||||
// on logue pour être sûr que c'est bien appelé
|
||||
tracing::debug!(index = index, "✅ SHUFFLE / SEEK: play_from_index appelé");
|
||||
|
||||
self.play_from_queue()
|
||||
}
|
||||
@@ -318,8 +351,16 @@ impl QueueBackend for UpnpRenderer {
|
||||
.replace_queue(items, current_index)
|
||||
}
|
||||
|
||||
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||
self.queue.lock().unwrap().sync_queue(items)
|
||||
fn sync_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
_cancel_token: &Arc<AtomicBool>,
|
||||
on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sync_queue(items, &Arc::new(AtomicBool::new(false)), on_ready)
|
||||
}
|
||||
|
||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||
|
||||
@@ -91,6 +91,8 @@ pub struct WatchedState {
|
||||
pub metadata: Option<TrackMetadata>,
|
||||
/// Last known stream state (continuous stream vs bounded media)
|
||||
pub is_stream: Option<bool>,
|
||||
/// Flag indicating if renderer was active recently (used for adaptive polling)
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -28,7 +28,8 @@
|
||||
//! - This identity is used by the sync helpers to preserve the current
|
||||
//! track across queue rebuilds when the MediaServer content changes.
|
||||
|
||||
use crate::{PlaybackItem, QueueSnapshot, errors::ControlPointError};
|
||||
use crate::{errors::ControlPointError, PlaybackItem, QueueSnapshot};
|
||||
use std::sync::{atomic::AtomicBool, Arc};
|
||||
|
||||
/// High-level enqueue mode.
|
||||
///
|
||||
@@ -107,7 +108,12 @@ pub trait QueueBackend {
|
||||
/// corresponds to the old current index track.
|
||||
/// If the old current index track is absent from the new queue,
|
||||
/// it is kept as the first item and the new items are appended after it.
|
||||
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError>;
|
||||
fn sync_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError>;
|
||||
|
||||
/// Returns the item at `index`, if it exists.
|
||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError>;
|
||||
|
||||
@@ -20,6 +20,7 @@ use crate::{
|
||||
queue::{MusicQueue, PlaybackItem, QueueBackend, QueueFromRendererInfo, QueueSnapshot},
|
||||
DeviceId, DeviceIdentity, RendererInfo,
|
||||
};
|
||||
use std::sync::{atomic::AtomicBool, Arc};
|
||||
|
||||
/// Internal/local queue implementation.
|
||||
///
|
||||
@@ -65,25 +66,7 @@ impl InternalQueue {
|
||||
/// Vérifie si une durée a diminué (format HH:MM:SS).
|
||||
/// Retourne true si new_duration < old_duration.
|
||||
fn duration_decreased(old_duration: &str, new_duration: &str) -> bool {
|
||||
let parse_duration = |dur: &str| -> Option<u32> {
|
||||
let parts: Vec<&str> = dur.split(':').collect();
|
||||
if parts.len() == 3 {
|
||||
let h: u32 = parts[0].parse().ok()?;
|
||||
let m: u32 = parts[1].parse().ok()?;
|
||||
let s: u32 = parts[2].parse().ok()?;
|
||||
Some(h * 3600 + m * 60 + s)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let (Some(old_secs), Some(new_secs)) =
|
||||
(parse_duration(old_duration), parse_duration(new_duration))
|
||||
{
|
||||
new_secs < old_secs
|
||||
} else {
|
||||
false // Impossible de parser: considérer que ça n'a pas diminué
|
||||
}
|
||||
super::stream_duration_decreased(old_duration, new_duration)
|
||||
}
|
||||
|
||||
/// Protège les durées des streams contre la diminution.
|
||||
@@ -164,45 +147,23 @@ impl InternalQueue {
|
||||
// Même chanson sur un stream: vérifier que la durée n'a pas diminué
|
||||
let should_update = match (&old_meta.duration, &new_meta.duration) {
|
||||
(Some(old_dur), Some(new_dur)) => {
|
||||
// Parser les durées (format HH:MM:SS)
|
||||
let parse_duration = |dur: &str| -> Option<u32> {
|
||||
let parts: Vec<&str> = dur.split(':').collect();
|
||||
if parts.len() == 3 {
|
||||
let h: u32 = parts[0].parse().ok()?;
|
||||
let m: u32 = parts[1].parse().ok()?;
|
||||
let s: u32 = parts[2].parse().ok()?;
|
||||
Some(h * 3600 + m * 60 + s)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let (Some(old_secs), Some(new_secs)) =
|
||||
(parse_duration(old_dur), parse_duration(new_dur))
|
||||
{
|
||||
if new_secs < old_secs {
|
||||
// Durée a diminué: garder l'ancienne
|
||||
tracing::trace!(
|
||||
"InternalQueue merge_metadata: uri={}, REJECTING update (same stream track, duration decreased): {} -> {}",
|
||||
if super::stream_duration_decreased(old_dur, new_dur) {
|
||||
tracing::trace!(
|
||||
"InternalQueue merge_metadata: uri={}, REJECTING update (same stream track, duration decreased): {} -> {}",
|
||||
uri,
|
||||
old_dur,
|
||||
new_dur
|
||||
);
|
||||
false
|
||||
} else {
|
||||
if super::stream_duration_increased(old_dur, new_dur) {
|
||||
tracing::debug!(
|
||||
"InternalQueue merge_metadata: uri={}, same stream track, duration increased: {} -> {}",
|
||||
uri,
|
||||
old_dur,
|
||||
new_dur
|
||||
);
|
||||
false
|
||||
} else {
|
||||
// Durée a augmenté ou est égale: accepter
|
||||
if new_secs > old_secs {
|
||||
tracing::debug!(
|
||||
"InternalQueue merge_metadata: uri={}, same stream track, duration increased: {} -> {}",
|
||||
uri,
|
||||
old_dur,
|
||||
new_dur
|
||||
);
|
||||
}
|
||||
true
|
||||
}
|
||||
} else {
|
||||
// Impossible de parser: accepter par défaut
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -309,25 +270,43 @@ impl QueueBackend for InternalQueue {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||
use tracing::debug;
|
||||
fn sync_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
mut on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
|
||||
if items.is_empty() {
|
||||
return self.replace_queue(Vec::new(), None);
|
||||
if cancel_token.load(SeqCst) {
|
||||
return Err(ControlPointError::SyncCancelled);
|
||||
}
|
||||
|
||||
let has_current = self.current_index().ok().flatten().is_some();
|
||||
if has_current {
|
||||
if let Some(f) = on_ready.take() {
|
||||
f();
|
||||
}
|
||||
}
|
||||
|
||||
if items.is_empty() {
|
||||
let _ = self.replace_queue(Vec::new(), None);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Protéger les durées des streams contre la diminution
|
||||
let updated_items = self.protect_stream_durations(items);
|
||||
|
||||
// Récupérer l'item actuel
|
||||
let current = self.current_index.and_then(|idx| {
|
||||
self.items
|
||||
.get(idx)
|
||||
.map(|item| (idx, item.uri.clone(), item.didl_id.clone()))
|
||||
});
|
||||
|
||||
if cancel_token.load(SeqCst) {
|
||||
return Err(ControlPointError::SyncCancelled);
|
||||
}
|
||||
|
||||
if let Some((_current_idx, current_uri, current_didl_id)) = current {
|
||||
// Chercher l'item actuel dans la nouvelle liste (par URI d'abord, puis par didl_id)
|
||||
let new_idx = updated_items
|
||||
.iter()
|
||||
.position(|item| item.uri == current_uri)
|
||||
@@ -338,34 +317,25 @@ impl QueueBackend for InternalQueue {
|
||||
});
|
||||
|
||||
if let Some(new_idx) = new_idx {
|
||||
// Item trouvé dans la nouvelle liste
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
current_uri = current_uri.as_str(),
|
||||
new_idx,
|
||||
"sync_queue: current item found in new playlist"
|
||||
);
|
||||
self.replace_queue(updated_items, Some(new_idx))
|
||||
self.replace_queue(updated_items, Some(new_idx))?;
|
||||
} else {
|
||||
// Item pas trouvé - cela ne devrait pas arriver si la playlist n'a pas changé
|
||||
// Loguer pour diagnostic
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
current_uri = current_uri.as_str(),
|
||||
current_didl_id = current_didl_id.as_str(),
|
||||
new_items_count = updated_items.len(),
|
||||
"sync_queue: current item NOT found in new playlist, preserving as first item"
|
||||
);
|
||||
let current_item = self.items[self.current_index.unwrap()].clone();
|
||||
let mut new_items = Vec::with_capacity(updated_items.len() + 1);
|
||||
new_items.push(current_item);
|
||||
new_items.extend(updated_items);
|
||||
self.replace_queue(new_items, Some(0))
|
||||
self.replace_queue(new_items, Some(0))?;
|
||||
}
|
||||
} else {
|
||||
// Pas d'item actuel
|
||||
self.replace_queue(updated_items, None)
|
||||
self.replace_queue(updated_items, None)?;
|
||||
}
|
||||
|
||||
if !has_current {
|
||||
if let Some(f) = on_ready.take() {
|
||||
f();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn enqueue_items(
|
||||
@@ -542,6 +512,6 @@ impl QueueFromRendererInfo for InternalQueue {
|
||||
}
|
||||
|
||||
fn to_backend(self) -> MusicQueue {
|
||||
MusicQueue::Internal(self)
|
||||
MusicQueue::from_internal(self)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,14 +7,38 @@ mod snapshot;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
pub use backend::{EnqueueMode, QueueBackend};
|
||||
pub use music_queue::MusicQueue;
|
||||
pub use music_queue::{MusicQueue, SyncScheduleOutcome};
|
||||
pub use snapshot::{PlaybackItem, QueueSnapshot};
|
||||
|
||||
// Internal queue implementations - not part of the public API
|
||||
pub(crate) use interne::InternalQueue;
|
||||
pub(crate) use openhome::OpenHomeQueue;
|
||||
|
||||
use crate::{RendererInfo, errors::ControlPointError};
|
||||
use crate::music_renderer::time_utils::parse_time_flexible;
|
||||
use crate::{errors::ControlPointError, RendererInfo};
|
||||
|
||||
/// Returns true if `new_dur` < `old_dur` (both parseable as HH:MM:SS/MM:SS/SS).
|
||||
/// Used to protect stream durations from decreasing for the same track.
|
||||
pub(super) fn stream_duration_decreased(old_dur: &str, new_dur: &str) -> bool {
|
||||
match (
|
||||
parse_time_flexible(old_dur).ok(),
|
||||
parse_time_flexible(new_dur).ok(),
|
||||
) {
|
||||
(Some(old_secs), Some(new_secs)) => new_secs < old_secs,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if `new_dur` > `old_dur` (both parseable as HH:MM:SS/MM:SS/SS).
|
||||
pub(super) fn stream_duration_increased(old_dur: &str, new_dur: &str) -> bool {
|
||||
match (
|
||||
parse_time_flexible(old_dur).ok(),
|
||||
parse_time_flexible(new_dur).ok(),
|
||||
) {
|
||||
(Some(old_secs), Some(new_secs)) => new_secs > old_secs,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub trait QueueFromRendererInfo {
|
||||
fn from_renderer_info(renderer: &RendererInfo) -> Result<Self, ControlPointError>
|
||||
|
||||
@@ -3,84 +3,303 @@ use crate::queue::{
|
||||
EnqueueMode, InternalQueue, OpenHomeQueue, QueueBackend, QueueFromRendererInfo,
|
||||
};
|
||||
use crate::{PlaybackItem, QueueSnapshot, RendererInfo};
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering::SeqCst},
|
||||
Arc, Mutex,
|
||||
};
|
||||
use std::thread;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SyncScheduleOutcome {
|
||||
Scheduled,
|
||||
AlreadyRunning,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum MusicQueue {
|
||||
enum MusicQueueBackend {
|
||||
Internal(InternalQueue),
|
||||
OpenHome(OpenHomeQueue),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MusicQueue {
|
||||
backend: MusicQueueBackend,
|
||||
sync_in_progress: Arc<AtomicBool>,
|
||||
sync_pending: Arc<AtomicBool>,
|
||||
sync_cancel_token: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl MusicQueue {
|
||||
/// Creates a queue appropriate for the given renderer.
|
||||
/// This is the factory method used by QueueFromRendererInfo trait.
|
||||
pub fn from_renderer_info(info: &RendererInfo) -> Result<MusicQueue, ControlPointError> {
|
||||
if info.capabilities().has_oh_playlist() {
|
||||
Ok(MusicQueue::OpenHome(OpenHomeQueue::from_renderer_info(
|
||||
info,
|
||||
)?))
|
||||
let backend = if info.capabilities().has_oh_playlist() {
|
||||
MusicQueueBackend::OpenHome(OpenHomeQueue::from_renderer_info(info)?)
|
||||
} else {
|
||||
Ok(MusicQueue::Internal(InternalQueue::from_renderer_info(
|
||||
info,
|
||||
)?))
|
||||
MusicQueueBackend::Internal(InternalQueue::from_renderer_info(info)?)
|
||||
};
|
||||
|
||||
Ok(MusicQueue {
|
||||
backend,
|
||||
sync_in_progress: Arc::new(AtomicBool::new(false)),
|
||||
sync_pending: Arc::new(AtomicBool::new(false)),
|
||||
sync_cancel_token: Arc::new(AtomicBool::new(false)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Creates a MusicQueue from an InternalQueue backend.
|
||||
pub fn from_internal(queue: InternalQueue) -> MusicQueue {
|
||||
MusicQueue {
|
||||
backend: MusicQueueBackend::Internal(queue),
|
||||
sync_in_progress: Arc::new(AtomicBool::new(false)),
|
||||
sync_pending: Arc::new(AtomicBool::new(false)),
|
||||
sync_cancel_token: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a MusicQueue from an OpenHomeQueue backend.
|
||||
pub fn from_openhome(queue: OpenHomeQueue) -> MusicQueue {
|
||||
MusicQueue {
|
||||
backend: MusicQueueBackend::OpenHome(queue),
|
||||
sync_in_progress: Arc::new(AtomicBool::new(false)),
|
||||
sync_pending: Arc::new(AtomicBool::new(false)),
|
||||
sync_cancel_token: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if this is an OpenHome backend.
|
||||
pub fn is_openhome(&self) -> bool {
|
||||
matches!(self.backend, MusicQueueBackend::OpenHome(_))
|
||||
}
|
||||
|
||||
/// Returns a reference to the OpenHome queue if this is an OpenHome backend.
|
||||
pub fn as_openhome(&self) -> Option<&OpenHomeQueue> {
|
||||
match &self.backend {
|
||||
MusicQueueBackend::OpenHome(q) => Some(q),
|
||||
MusicQueueBackend::Internal(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a mutable reference to the OpenHome queue if this is an OpenHome backend.
|
||||
pub fn as_openhome_mut(&mut self) -> Option<&mut OpenHomeQueue> {
|
||||
match &mut self.backend {
|
||||
MusicQueueBackend::OpenHome(q) => Some(q),
|
||||
MusicQueueBackend::Internal(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn len(&self) -> Result<usize, ControlPointError> {
|
||||
match &self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.len(),
|
||||
MusicQueueBackend::OpenHome(q) => q.len(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn schedule_sync(
|
||||
queue_arc: &Arc<Mutex<MusicQueue>>,
|
||||
renderer_id: &str,
|
||||
items: Vec<PlaybackItem>,
|
||||
pending_items_fn: Box<
|
||||
dyn Fn() -> Result<Vec<PlaybackItem>, ControlPointError> + Send + 'static,
|
||||
>,
|
||||
on_ready: Option<Box<dyn FnOnce() + Send + 'static>>,
|
||||
on_complete: Box<dyn Fn(usize) + Send + 'static>,
|
||||
) -> SyncScheduleOutcome {
|
||||
let (sync_in_progress, sync_pending, sync_cancel_token) = {
|
||||
let q = queue_arc.lock().unwrap();
|
||||
(
|
||||
Arc::clone(&q.sync_in_progress),
|
||||
Arc::clone(&q.sync_pending),
|
||||
Arc::clone(&q.sync_cancel_token),
|
||||
)
|
||||
};
|
||||
|
||||
if sync_in_progress.swap(true, SeqCst) {
|
||||
sync_cancel_token.store(true, SeqCst);
|
||||
sync_pending.store(true, SeqCst);
|
||||
return SyncScheduleOutcome::AlreadyRunning;
|
||||
}
|
||||
|
||||
sync_cancel_token.store(false, SeqCst);
|
||||
sync_pending.store(false, SeqCst);
|
||||
|
||||
let queue_arc = Arc::clone(queue_arc);
|
||||
let thread_name = format!("queue-sync-{}", renderer_id);
|
||||
|
||||
thread::Builder::new()
|
||||
.name(thread_name)
|
||||
.spawn(move || {
|
||||
struct Guard(Arc<AtomicBool>);
|
||||
impl Drop for Guard {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(false, SeqCst);
|
||||
}
|
||||
}
|
||||
let _guard = Guard(Arc::clone(&sync_in_progress));
|
||||
|
||||
let mut current_items = items;
|
||||
let mut current_on_ready = Some(on_ready);
|
||||
let mut on_complete = Some(on_complete);
|
||||
|
||||
tracing::debug!(thread = %std::thread::current().name().unwrap_or("?"), "queue-sync thread started");
|
||||
|
||||
loop {
|
||||
sync_pending.store(false, SeqCst);
|
||||
sync_cancel_token.store(false, SeqCst);
|
||||
|
||||
// Extract the real on_ready BEFORE locking the queue.
|
||||
// on_ready may call play_from_queue() which re-locks the queue,
|
||||
// so we must NOT call it while holding queue_arc.
|
||||
let real_on_ready = current_on_ready.take().flatten();
|
||||
let on_ready_triggered = Arc::new(AtomicBool::new(false));
|
||||
let proxy_on_ready: Option<Box<dyn FnOnce() + Send + 'static>> =
|
||||
real_on_ready.as_ref().map(|_| {
|
||||
let flag = Arc::clone(&on_ready_triggered);
|
||||
Box::new(move || {
|
||||
flag.store(true, SeqCst);
|
||||
}) as Box<dyn FnOnce() + Send + 'static>
|
||||
});
|
||||
|
||||
tracing::debug!(
|
||||
thread = %std::thread::current().name().unwrap_or("?"),
|
||||
items = current_items.len(),
|
||||
has_on_ready = real_on_ready.is_some(),
|
||||
"queue-sync: calling sync_queue"
|
||||
);
|
||||
|
||||
let result = {
|
||||
let mut q = queue_arc.lock().unwrap();
|
||||
<MusicQueue as QueueBackend>::sync_queue(
|
||||
&mut q,
|
||||
current_items,
|
||||
&sync_cancel_token,
|
||||
proxy_on_ready,
|
||||
)
|
||||
};
|
||||
// Queue lock is released here.
|
||||
// Now safe to call on_ready (which may re-lock the queue).
|
||||
// If on_ready was triggered by the proxy, consume and call it.
|
||||
// If not (cancelled before first insert), keep it to pass to retry.
|
||||
let carry_on_ready = if on_ready_triggered.load(SeqCst) {
|
||||
tracing::debug!(
|
||||
thread = %std::thread::current().name().unwrap_or("?"),
|
||||
"queue-sync: on_ready triggered, calling callback"
|
||||
);
|
||||
if let Some(f) = real_on_ready {
|
||||
f();
|
||||
}
|
||||
None
|
||||
} else {
|
||||
if real_on_ready.is_some() {
|
||||
tracing::debug!(
|
||||
thread = %std::thread::current().name().unwrap_or("?"),
|
||||
"queue-sync: on_ready not triggered, carrying to next attempt"
|
||||
);
|
||||
}
|
||||
real_on_ready
|
||||
};
|
||||
|
||||
match result {
|
||||
Err(ControlPointError::SyncCancelled) => {
|
||||
tracing::debug!(
|
||||
thread = %std::thread::current().name().unwrap_or("?"),
|
||||
"queue-sync: cancelled"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("queue-sync error: {}", e);
|
||||
}
|
||||
Ok(()) => {
|
||||
tracing::debug!(
|
||||
thread = %std::thread::current().name().unwrap_or("?"),
|
||||
"queue-sync: completed successfully"
|
||||
);
|
||||
if let Some(cb) = on_complete.take() {
|
||||
let queue_len = queue_arc.lock().unwrap().len().unwrap_or(0);
|
||||
cb(queue_len);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !sync_pending.load(SeqCst) {
|
||||
break;
|
||||
}
|
||||
|
||||
match pending_items_fn() {
|
||||
Ok(new_items) => {
|
||||
current_items = new_items;
|
||||
current_on_ready = Some(carry_on_ready);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("queue-sync pending re-fetch error: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::debug!(thread = %std::thread::current().name().unwrap_or("?"), "queue-sync thread done");
|
||||
})
|
||||
.expect("Failed to spawn queue-sync thread");
|
||||
|
||||
SyncScheduleOutcome::Scheduled
|
||||
}
|
||||
}
|
||||
|
||||
impl QueueBackend for MusicQueue {
|
||||
// Primitives
|
||||
fn len(&self) -> Result<usize, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.len(),
|
||||
MusicQueue::OpenHome(q) => q.len(),
|
||||
match &self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.len(),
|
||||
MusicQueueBackend::OpenHome(q) => q.len(),
|
||||
}
|
||||
}
|
||||
|
||||
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.track_ids(),
|
||||
MusicQueue::OpenHome(q) => q.track_ids(),
|
||||
match &self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.track_ids(),
|
||||
MusicQueueBackend::OpenHome(q) => q.track_ids(),
|
||||
}
|
||||
}
|
||||
|
||||
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.id_to_position(id),
|
||||
MusicQueue::OpenHome(q) => q.id_to_position(id),
|
||||
match &self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.id_to_position(id),
|
||||
MusicQueueBackend::OpenHome(q) => q.id_to_position(id),
|
||||
}
|
||||
}
|
||||
|
||||
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.position_to_id(id),
|
||||
MusicQueue::OpenHome(q) => q.position_to_id(id),
|
||||
match &self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.position_to_id(id),
|
||||
MusicQueueBackend::OpenHome(q) => q.position_to_id(id),
|
||||
}
|
||||
}
|
||||
|
||||
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.current_track(),
|
||||
MusicQueue::OpenHome(q) => q.current_track(),
|
||||
match &self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.current_track(),
|
||||
MusicQueueBackend::OpenHome(q) => q.current_track(),
|
||||
}
|
||||
}
|
||||
|
||||
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.current_index(),
|
||||
MusicQueue::OpenHome(q) => q.current_index(),
|
||||
match &self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.current_index(),
|
||||
MusicQueueBackend::OpenHome(q) => q.current_index(),
|
||||
}
|
||||
}
|
||||
|
||||
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.queue_snapshot(),
|
||||
MusicQueue::OpenHome(q) => q.queue_snapshot(),
|
||||
match &self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.queue_snapshot(),
|
||||
MusicQueueBackend::OpenHome(q) => q.queue_snapshot(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.set_index(index),
|
||||
MusicQueue::OpenHome(q) => q.set_index(index),
|
||||
match &mut self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.set_index(index),
|
||||
MusicQueueBackend::OpenHome(q) => q.set_index(index),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,30 +308,35 @@ impl QueueBackend for MusicQueue {
|
||||
items: Vec<PlaybackItem>,
|
||||
current_index: Option<usize>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.replace_queue(items, current_index),
|
||||
MusicQueue::OpenHome(q) => q.replace_queue(items, current_index),
|
||||
match &mut self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.replace_queue(items, current_index),
|
||||
MusicQueueBackend::OpenHome(q) => q.replace_queue(items, current_index),
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.sync_queue(items),
|
||||
MusicQueue::OpenHome(q) => q.sync_queue(items),
|
||||
fn sync_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
match &mut self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.sync_queue(items, cancel_token, on_ready),
|
||||
MusicQueueBackend::OpenHome(q) => q.sync_queue(items, cancel_token, on_ready),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.get_item(index),
|
||||
MusicQueue::OpenHome(q) => q.get_item(index),
|
||||
match &self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.get_item(index),
|
||||
MusicQueueBackend::OpenHome(q) => q.get_item(index),
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.replace_item(index, item),
|
||||
MusicQueue::OpenHome(q) => q.replace_item(index, item),
|
||||
match &mut self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.replace_item(index, item),
|
||||
MusicQueueBackend::OpenHome(q) => q.replace_item(index, item),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,59 +345,59 @@ impl QueueBackend for MusicQueue {
|
||||
items: Vec<PlaybackItem>,
|
||||
mode: EnqueueMode,
|
||||
) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.enqueue_items(items, mode),
|
||||
MusicQueue::OpenHome(q) => q.enqueue_items(items, mode),
|
||||
match &mut self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.enqueue_items(items, mode),
|
||||
MusicQueueBackend::OpenHome(q) => q.enqueue_items(items, mode),
|
||||
}
|
||||
}
|
||||
|
||||
// Optimized helpers
|
||||
fn clear_queue(&mut self) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.clear_queue(),
|
||||
MusicQueue::OpenHome(q) => q.clear_queue(),
|
||||
match &mut self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.clear_queue(),
|
||||
MusicQueueBackend::OpenHome(q) => q.clear_queue(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> Result<bool, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.is_empty(),
|
||||
MusicQueue::OpenHome(q) => q.is_empty(),
|
||||
match &self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.is_empty(),
|
||||
MusicQueueBackend::OpenHome(q) => q.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
fn upcoming_len(&self) -> Result<usize, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.upcoming_len(),
|
||||
MusicQueue::OpenHome(q) => q.upcoming_len(),
|
||||
match &self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.upcoming_len(),
|
||||
MusicQueueBackend::OpenHome(q) => q.upcoming_len(),
|
||||
}
|
||||
}
|
||||
|
||||
fn upcoming_items(&self) -> Result<Vec<PlaybackItem>, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.upcoming_items(),
|
||||
MusicQueue::OpenHome(q) => q.upcoming_items(),
|
||||
match &self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.upcoming_items(),
|
||||
MusicQueueBackend::OpenHome(q) => q.upcoming_items(),
|
||||
}
|
||||
}
|
||||
|
||||
fn peek_current(&mut self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.peek_current(),
|
||||
MusicQueue::OpenHome(q) => q.peek_current(),
|
||||
match &mut self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.peek_current(),
|
||||
MusicQueueBackend::OpenHome(q) => q.peek_current(),
|
||||
}
|
||||
}
|
||||
|
||||
fn dequeue_next(&mut self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.dequeue_next(),
|
||||
MusicQueue::OpenHome(q) => q.dequeue_next(),
|
||||
match &mut self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.dequeue_next(),
|
||||
MusicQueueBackend::OpenHome(q) => q.dequeue_next(),
|
||||
}
|
||||
}
|
||||
|
||||
fn append_or_init_index(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.append_or_init_index(items),
|
||||
MusicQueue::OpenHome(q) => q.append_or_init_index(items),
|
||||
match &mut self.backend {
|
||||
MusicQueueBackend::Internal(q) => q.append_or_init_index(items),
|
||||
MusicQueueBackend::OpenHome(q) => q.append_or_init_index(items),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{atomic::AtomicBool, Arc, Mutex};
|
||||
use std::time::SystemTime;
|
||||
use std::usize;
|
||||
|
||||
@@ -170,6 +170,8 @@ pub struct OpenHomeQueue {
|
||||
/// Permet de maintenir des métadonnées à jour même si le service OpenHome
|
||||
/// ne permet pas de les modifier directement.
|
||||
metadata_cache: Mutex<HashMap<u32, Option<crate::model::TrackMetadata>>>,
|
||||
/// Cache URI by track ID for fast path matching
|
||||
uri_by_id: Mutex<HashMap<u32, String>>,
|
||||
/// Cache for track IDs to avoid redundant IdArray SOAP calls
|
||||
track_ids_cache: Arc<Mutex<TrackIdsCache>>,
|
||||
/// Cache for current track ID to avoid redundant Id SOAP calls
|
||||
@@ -178,6 +180,16 @@ pub struct OpenHomeQueue {
|
||||
read_list_cache: Arc<Mutex<ReadListCache>>,
|
||||
}
|
||||
|
||||
/// Fast path result for queue sync optimization
|
||||
enum FastPathResult {
|
||||
/// New items are an append to the current queue
|
||||
AppendOnly { new_items: Vec<PlaybackItem> },
|
||||
/// Items were deleted from the end
|
||||
DeleteFromEnd { delete_ids: Vec<u32> },
|
||||
/// No fast path possible - need full LCS sync
|
||||
NeedFullSync,
|
||||
}
|
||||
|
||||
impl OpenHomeQueue {
|
||||
pub fn new(
|
||||
renderer_id: DeviceId,
|
||||
@@ -191,6 +203,7 @@ impl OpenHomeQueue {
|
||||
info_client,
|
||||
product_client,
|
||||
metadata_cache: Mutex::new(HashMap::new()),
|
||||
uri_by_id: Mutex::new(HashMap::new()),
|
||||
track_ids_cache: Arc::new(Mutex::new(TrackIdsCache::new())),
|
||||
current_track_id_cache: Arc::new(Mutex::new(CurrentTrackIdCache::new())),
|
||||
read_list_cache: Arc::new(Mutex::new(ReadListCache::new())),
|
||||
@@ -218,6 +231,130 @@ impl OpenHomeQueue {
|
||||
}
|
||||
}
|
||||
|
||||
/// Invalide les caches track_ids et read_list (après insert/delete sans impact sur la piste courante).
|
||||
fn invalidate_track_caches(&self) {
|
||||
self.track_ids_cache.lock().unwrap().invalidate();
|
||||
self.read_list_cache.lock().unwrap().invalidate();
|
||||
}
|
||||
|
||||
/// Invalide tous les caches (après delete_all, seek, stop — opérations qui changent la piste courante).
|
||||
fn invalidate_all_caches(&self) {
|
||||
self.track_ids_cache.lock().unwrap().invalidate();
|
||||
self.read_list_cache.lock().unwrap().invalidate();
|
||||
self.current_track_id_cache.lock().unwrap().invalidate();
|
||||
self.uri_by_id.lock().unwrap().clear();
|
||||
}
|
||||
|
||||
/// Tries to detect a simple append-only or delete-from-end pattern without ReadList.
|
||||
///
|
||||
/// This is a fast path optimization that avoids the expensive ReadList calls when:
|
||||
/// 1. New items are an append to existing queue (only inserts needed)
|
||||
/// 2. Items were deleted from the end (only deletes needed)
|
||||
///
|
||||
/// Returns `FastPathResult::NeedFullSync` if the pattern doesn't match or
|
||||
/// if cache data is insufficient to verify.
|
||||
fn try_fast_path(&self, new_items: &[PlaybackItem]) -> FastPathResult {
|
||||
let current_ids = match self.track_ids() {
|
||||
Ok(ids) => ids,
|
||||
Err(e) => {
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
error = %e,
|
||||
"try_fast_path: failed to get current IDs, falling back to full sync"
|
||||
);
|
||||
return FastPathResult::NeedFullSync;
|
||||
}
|
||||
};
|
||||
|
||||
let current_len = current_ids.len();
|
||||
let new_len = new_items.len();
|
||||
|
||||
if new_len > current_len {
|
||||
let prefix_matches = self.check_prefix_matches(¤t_ids, &new_items[..current_len]);
|
||||
if prefix_matches {
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
current_len, new_len, "try_fast_path: detected append-only pattern"
|
||||
);
|
||||
return FastPathResult::AppendOnly {
|
||||
new_items: new_items[current_len..].to_vec(),
|
||||
};
|
||||
}
|
||||
} else if new_len < current_len {
|
||||
let prefix_matches = self.check_prefix_matches(¤t_ids[..new_len], new_items);
|
||||
if prefix_matches {
|
||||
let delete_ids: Vec<u32> = current_ids[new_len..].to_vec();
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
current_len,
|
||||
new_len,
|
||||
delete_count = delete_ids.len(),
|
||||
"try_fast_path: detected delete-from-end pattern"
|
||||
);
|
||||
return FastPathResult::DeleteFromEnd { delete_ids };
|
||||
}
|
||||
}
|
||||
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
current_len, new_len, "try_fast_path: no fast path detected, falling back to full sync"
|
||||
);
|
||||
FastPathResult::NeedFullSync
|
||||
}
|
||||
|
||||
/// Checks if the prefix of the new items matches the current queue items.
|
||||
/// Uses local metadata cache or falls back to URI comparison.
|
||||
fn check_prefix_matches(&self, current_ids: &[u32], new_items: &[PlaybackItem]) -> bool {
|
||||
if current_ids.len() != new_items.len() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let metadata_cache = match self.metadata_cache.lock() {
|
||||
Ok(cache) => cache,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
let uri_cache = match self.uri_by_id.lock() {
|
||||
Ok(cache) => cache,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
for (idx, new_item) in new_items.iter().enumerate() {
|
||||
let current_id = current_ids[idx];
|
||||
|
||||
// First check if we have cached metadata for this track_id that matches
|
||||
if let Some(cached_metadata) = metadata_cache.get(¤t_id) {
|
||||
if let Some(cached) = cached_metadata {
|
||||
// Compare using title + artist as identity (for streams)
|
||||
if let Some(ref new_metadata) = new_item.metadata {
|
||||
let same_title = cached.title.as_ref() == new_metadata.title.as_ref();
|
||||
let same_artist = cached.artist.as_ref() == new_metadata.artist.as_ref();
|
||||
if same_title && same_artist {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No metadata match - fall back to URI comparison using uri_by_id cache
|
||||
if new_item.uri.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Compare against cached URI for this track_id
|
||||
if let Some(cached_uri) = uri_cache.get(¤t_id) {
|
||||
if cached_uri == &new_item.uri {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// No match found - fall back to full sync
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Met à jour les métadonnées d'un item de la queue à l'index spécifié.
|
||||
///
|
||||
/// Contrairement au service OpenHome qui ne permet pas de modifier les métadonnées,
|
||||
@@ -237,7 +374,7 @@ impl OpenHomeQueue {
|
||||
metadata: Option<crate::model::TrackMetadata>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
let track_id = self.position_to_id(index)?;
|
||||
self.cache_metadata(track_id, metadata);
|
||||
self.cache_metadata(track_id, metadata, "");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -247,8 +384,19 @@ impl OpenHomeQueue {
|
||||
/// chanson donc toute durée est acceptée.
|
||||
/// Pour les fichiers normaux (non-streams), les métadonnées sont acceptées telles quelles.
|
||||
/// Cette fonction est le SEUL point d'entrée pour modifier le cache.
|
||||
fn cache_metadata(&self, track_id: u32, new_metadata: Option<crate::model::TrackMetadata>) {
|
||||
fn cache_metadata(
|
||||
&self,
|
||||
track_id: u32,
|
||||
new_metadata: Option<crate::model::TrackMetadata>,
|
||||
uri: &str,
|
||||
) {
|
||||
let mut cache = self.metadata_cache.lock().unwrap();
|
||||
let mut uri_cache = self.uri_by_id.lock().unwrap();
|
||||
|
||||
// Update URI cache
|
||||
if !uri.is_empty() {
|
||||
uri_cache.insert(track_id, uri.to_string());
|
||||
}
|
||||
|
||||
// Vérifier s'il y a déjà des métadonnées en cache
|
||||
if let Some(cached_meta) = cache.get(&track_id) {
|
||||
@@ -274,45 +422,23 @@ impl OpenHomeQueue {
|
||||
new_metadata.as_ref().and_then(|m| m.duration.as_ref()),
|
||||
) {
|
||||
(Some(cached_dur), Some(new_dur)) => {
|
||||
// Parser les durées (format HH:MM:SS)
|
||||
let parse_duration = |dur: &str| -> Option<u32> {
|
||||
let parts: Vec<&str> = dur.split(':').collect();
|
||||
if parts.len() == 3 {
|
||||
let h: u32 = parts[0].parse().ok()?;
|
||||
let m: u32 = parts[1].parse().ok()?;
|
||||
let s: u32 = parts[2].parse().ok()?;
|
||||
Some(h * 3600 + m * 60 + s)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let (Some(cached_secs), Some(new_secs)) =
|
||||
(parse_duration(cached_dur), parse_duration(new_dur))
|
||||
{
|
||||
if new_secs < cached_secs {
|
||||
// Durée a diminué pour la même chanson: refuser
|
||||
tracing::trace!(
|
||||
"OpenHome cache_metadata: track_id={}, REJECTING update (same track, duration decreased): {} -> {}",
|
||||
if super::stream_duration_decreased(cached_dur, new_dur) {
|
||||
tracing::trace!(
|
||||
"OpenHome cache_metadata: track_id={}, REJECTING update (same track, duration decreased): {} -> {}",
|
||||
track_id,
|
||||
cached_dur,
|
||||
new_dur
|
||||
);
|
||||
false
|
||||
} else {
|
||||
if super::stream_duration_increased(cached_dur, new_dur) {
|
||||
tracing::debug!(
|
||||
"OpenHome cache_metadata: track_id={}, same track, duration increased: {} -> {}",
|
||||
track_id,
|
||||
cached_dur,
|
||||
new_dur
|
||||
);
|
||||
false
|
||||
} else {
|
||||
// Durée a augmenté ou est égale: accepter
|
||||
if new_secs > cached_secs {
|
||||
tracing::debug!(
|
||||
"OpenHome cache_metadata: track_id={}, same track, duration increased: {} -> {}",
|
||||
track_id,
|
||||
cached_dur,
|
||||
new_dur
|
||||
);
|
||||
}
|
||||
true
|
||||
}
|
||||
} else {
|
||||
// Impossible de parser: accepter par défaut
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -375,7 +501,7 @@ impl OpenHomeQueue {
|
||||
);
|
||||
drop(cache); // Libérer le lock avant d'appeler cache_metadata
|
||||
// Mettre en cache pour éviter les oscillations sur les flux radio
|
||||
self.cache_metadata(entry.id, fresh.clone());
|
||||
self.cache_metadata(entry.id, fresh.clone(), entry.uri());
|
||||
fresh
|
||||
}
|
||||
};
|
||||
@@ -406,7 +532,7 @@ impl OpenHomeQueue {
|
||||
.insert(after_id, &item.uri, &metadata_xml)?;
|
||||
|
||||
// Enregistrer les métadonnées dans le cache
|
||||
self.cache_metadata(new_id, item.metadata);
|
||||
self.cache_metadata(new_id, item.metadata, &item.uri);
|
||||
|
||||
Ok(new_id)
|
||||
}
|
||||
@@ -418,32 +544,44 @@ impl OpenHomeQueue {
|
||||
&mut self,
|
||||
new_items: Vec<PlaybackItem>,
|
||||
playing_id: usize,
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
on_ready: &mut Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
// Get current track IDs from OpenHome
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
|
||||
let current_track_ids = self.track_ids()?;
|
||||
|
||||
// Delete everything except the currently playing item
|
||||
// Using delete_id_if_exists() to handle cases where another control point
|
||||
// may have already modified the playlist
|
||||
for &track_id in current_track_ids.iter().rev() {
|
||||
if cancel_token.load(SeqCst) {
|
||||
return Err(ControlPointError::SyncCancelled);
|
||||
}
|
||||
if track_id as usize != playing_id {
|
||||
self.playlist_client.delete_id_if_exists(track_id)?;
|
||||
self.metadata_cache.lock().unwrap().remove(&track_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Insert new items after the currently playing track
|
||||
let mut previous_id = playing_id as u32;
|
||||
let mut first_insert_done = false;
|
||||
for item in new_items {
|
||||
if cancel_token.load(SeqCst) {
|
||||
return Err(ControlPointError::SyncCancelled);
|
||||
}
|
||||
let metadata = build_metadata_xml(&item);
|
||||
let new_id = self
|
||||
.playlist_client
|
||||
.insert(previous_id, &item.uri, &metadata)?;
|
||||
|
||||
// Enregistrer les métadonnées dans le cache
|
||||
self.cache_metadata(new_id, item.metadata);
|
||||
self.cache_metadata(new_id, item.metadata, &item.uri);
|
||||
|
||||
previous_id = new_id;
|
||||
|
||||
if !first_insert_done && on_ready.is_some() {
|
||||
first_insert_done = true;
|
||||
if let Some(f) = on_ready.take() {
|
||||
f();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!(
|
||||
@@ -451,9 +589,7 @@ impl OpenHomeQueue {
|
||||
"Gentle sync completed: preserved playing track as first item (not in new playlist)"
|
||||
);
|
||||
|
||||
// Invalidate cache after playlist modifications
|
||||
self.track_ids_cache.lock().unwrap().invalidate();
|
||||
self.read_list_cache.lock().unwrap().invalidate();
|
||||
self.invalidate_track_caches();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -464,8 +600,13 @@ impl OpenHomeQueue {
|
||||
old_ids: &[u32],
|
||||
keep_flags: &[bool],
|
||||
position_label: &str,
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
for (idx, &track_id) in old_ids.iter().enumerate().rev() {
|
||||
if cancel_token.load(SeqCst) {
|
||||
return Err(ControlPointError::SyncCancelled);
|
||||
}
|
||||
if !keep_flags[idx] {
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
@@ -490,8 +631,11 @@ impl OpenHomeQueue {
|
||||
keep_old_flags: &[bool],
|
||||
mut previous_id: u32,
|
||||
position_label: &str,
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
on_ready: &mut Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<u32, ControlPointError> {
|
||||
// Collect IDs of kept items (in order)
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
|
||||
let remaining_ids: Vec<u32> = old_ids
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -499,16 +643,18 @@ impl OpenHomeQueue {
|
||||
.collect();
|
||||
|
||||
let mut remaining_idx = 0;
|
||||
let mut first_insert_done = false;
|
||||
|
||||
// Rebuild section
|
||||
for (idx, item) in new_items.iter().enumerate() {
|
||||
if cancel_token.load(SeqCst) {
|
||||
return Err(ControlPointError::SyncCancelled);
|
||||
}
|
||||
if keep_new_flags[idx] {
|
||||
let existing_id = remaining_ids[remaining_idx];
|
||||
remaining_idx += 1;
|
||||
previous_id = existing_id;
|
||||
|
||||
// Mettre à jour les métadonnées de l'item existant conservé
|
||||
self.cache_metadata(existing_id, item.metadata.clone());
|
||||
self.cache_metadata(existing_id, item.metadata.clone(), &item.uri);
|
||||
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
@@ -523,8 +669,7 @@ impl OpenHomeQueue {
|
||||
.playlist_client
|
||||
.insert(previous_id, &item.uri, &metadata)?;
|
||||
|
||||
// Enregistrer les métadonnées du nouvel item
|
||||
self.cache_metadata(new_id, item.metadata.clone());
|
||||
self.cache_metadata(new_id, item.metadata.clone(), &item.uri);
|
||||
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
@@ -536,6 +681,13 @@ impl OpenHomeQueue {
|
||||
new_id
|
||||
);
|
||||
previous_id = new_id;
|
||||
|
||||
if !first_insert_done && on_ready.is_some() {
|
||||
first_insert_done = true;
|
||||
if let Some(f) = on_ready.take() {
|
||||
f();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -550,11 +702,11 @@ impl OpenHomeQueue {
|
||||
new_items: Vec<PlaybackItem>,
|
||||
pivot_idx_new: usize,
|
||||
pivot_id: usize,
|
||||
snapshot: &QueueSnapshot,
|
||||
current_track_ids: &[u32],
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
on_ready: &mut Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
// Get current state from OpenHome
|
||||
let snapshot = self.queue_snapshot()?;
|
||||
let current_track_ids = self.track_ids()?;
|
||||
|
||||
// Find the pivot index in our current state
|
||||
let pivot_idx = current_track_ids
|
||||
.iter()
|
||||
@@ -576,16 +728,21 @@ impl OpenHomeQueue {
|
||||
let new_after = &new_items[pivot_idx_new + 1..];
|
||||
|
||||
// LCS on the AFTER part (using fresh data from OpenHome)
|
||||
let (keep_old_after, keep_new_after) = lcs_flags(&old_after, new_after);
|
||||
let (keep_old_after, keep_new_after) = lcs_flags_optimized(&old_after, new_after);
|
||||
|
||||
// LCS on the BEFORE part (using fresh data from OpenHome)
|
||||
let (keep_old_before, keep_new_before) = lcs_flags(&old_before, new_before);
|
||||
let (keep_old_before, keep_new_before) = lcs_flags_optimized(&old_before, new_before);
|
||||
|
||||
// Delete items marked for deletion in AFTER part (reverse order)
|
||||
self.delete_marked_items(&old_ids_after, &keep_old_after, "AFTER pivot")?;
|
||||
self.delete_marked_items(&old_ids_after, &keep_old_after, "AFTER pivot", cancel_token)?;
|
||||
|
||||
// Delete items marked for deletion in BEFORE part (reverse order)
|
||||
self.delete_marked_items(&old_ids_before, &keep_old_before, "BEFORE pivot")?;
|
||||
self.delete_marked_items(
|
||||
&old_ids_before,
|
||||
&keep_old_before,
|
||||
"BEFORE pivot",
|
||||
cancel_token,
|
||||
)?;
|
||||
|
||||
// Rebuild the playlist: [BEFORE, PIVOT, AFTER]
|
||||
// Rebuild BEFORE part (we don't need the returned previous_id)
|
||||
@@ -596,13 +753,19 @@ impl OpenHomeQueue {
|
||||
&keep_old_before,
|
||||
OPENHOME_PLAYLIST_HEAD_ID,
|
||||
"BEFORE pivot",
|
||||
cancel_token,
|
||||
on_ready,
|
||||
)?;
|
||||
|
||||
// PIVOT keeps its ID and position - it's the anchor point
|
||||
let previous_id = pivot_id as u32;
|
||||
|
||||
// Mettre à jour les métadonnées du pivot
|
||||
self.cache_metadata(previous_id, new_items[pivot_idx_new].metadata.clone());
|
||||
self.cache_metadata(
|
||||
previous_id,
|
||||
new_items[pivot_idx_new].metadata.clone(),
|
||||
&new_items[pivot_idx_new].uri,
|
||||
);
|
||||
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
@@ -621,6 +784,8 @@ impl OpenHomeQueue {
|
||||
&keep_old_after,
|
||||
previous_id,
|
||||
"AFTER pivot",
|
||||
cancel_token,
|
||||
on_ready,
|
||||
)?;
|
||||
|
||||
debug!(
|
||||
@@ -631,8 +796,7 @@ impl OpenHomeQueue {
|
||||
);
|
||||
|
||||
// Invalidate cache after playlist modifications
|
||||
self.track_ids_cache.lock().unwrap().invalidate();
|
||||
self.read_list_cache.lock().unwrap().invalidate();
|
||||
self.invalidate_track_caches();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -641,11 +805,12 @@ impl OpenHomeQueue {
|
||||
fn replace_queue_standard_lcs(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
_current_index: Option<usize>,
|
||||
snapshot: &QueueSnapshot,
|
||||
current_track_ids: &[u32],
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
on_ready: &mut Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
// Get current state from OpenHome
|
||||
let snapshot = self.queue_snapshot()?;
|
||||
let current_track_ids = self.track_ids()?;
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
@@ -658,7 +823,7 @@ impl OpenHomeQueue {
|
||||
"LCS input: current vs desired items"
|
||||
);
|
||||
|
||||
let (keep_current, keep_desired) = lcs_flags(&snapshot.items, &items);
|
||||
let (keep_current, keep_desired) = lcs_flags_optimized(&snapshot.items, &items);
|
||||
|
||||
let items_to_keep = keep_current.iter().filter(|&&k| k).count();
|
||||
let items_to_delete = keep_current.iter().filter(|&k| !k).count();
|
||||
@@ -672,32 +837,22 @@ impl OpenHomeQueue {
|
||||
"LCS computed: minimizing OpenHome playlist operations"
|
||||
);
|
||||
|
||||
// Get current playing track ID BEFORE any modifications
|
||||
let current_track_id = self.playlist_client.id().ok().filter(|&id| id != 0);
|
||||
|
||||
// Check if the currently playing track is in the new playlist
|
||||
// If so, we should NOT use delete_all() - we must preserve it
|
||||
let current_track_in_new_playlist = current_track_id.and_then(|current_id| {
|
||||
items
|
||||
.iter()
|
||||
.position(|item| item.backend_id as u32 == current_id)
|
||||
});
|
||||
|
||||
// If we're replacing everything (keep=0), use delete_all() BUT only if
|
||||
// there's no currently playing track, OR if the current track is not in the new playlist.
|
||||
// If current track IS in new playlist, we must preserve it using insert/delete operations.
|
||||
if items_to_keep == 0 && items_to_delete > 0 {
|
||||
if current_track_in_new_playlist.is_some() {
|
||||
// Current track is in new playlist - use insert/delete instead of delete_all
|
||||
// to preserve playback
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
current_track_in_playlist = true,
|
||||
"Preserving currently playing track - using insert/delete instead of delete_all"
|
||||
);
|
||||
// Fall through to selective deletion below
|
||||
} else {
|
||||
// No current track or not in new playlist - safe to use delete_all
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
"Using delete_all() for complete replacement (safe - no current track or not in new playlist)"
|
||||
@@ -706,19 +861,18 @@ impl OpenHomeQueue {
|
||||
self.metadata_cache.lock().unwrap().clear();
|
||||
}
|
||||
} else {
|
||||
// Selective deletion when keeping some items
|
||||
for idx in (0..current_track_ids.len()).rev() {
|
||||
if cancel_token.load(SeqCst) {
|
||||
return Err(ControlPointError::SyncCancelled);
|
||||
}
|
||||
if !keep_current[idx] {
|
||||
let track_id = current_track_ids[idx];
|
||||
// Use delete_id_if_exists() to handle cases where another control point
|
||||
// may have already modified the playlist
|
||||
self.playlist_client.delete_id_if_exists(track_id)?;
|
||||
self.metadata_cache.lock().unwrap().remove(&track_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild by inserting new items
|
||||
let remaining_ids: Vec<u32> = current_track_ids
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -733,8 +887,12 @@ impl OpenHomeQueue {
|
||||
|
||||
let mut remaining_idx = 0usize;
|
||||
let mut previous_id = OPENHOME_PLAYLIST_HEAD_ID;
|
||||
let mut first_insert_done = false;
|
||||
|
||||
for (idx, item) in items.into_iter().enumerate() {
|
||||
if cancel_token.load(SeqCst) {
|
||||
return Err(ControlPointError::SyncCancelled);
|
||||
}
|
||||
if keep_desired[idx] {
|
||||
if remaining_idx >= remaining_ids.len() {
|
||||
return Err(ControlPointError::OpenHomeError(format!(
|
||||
@@ -745,19 +903,23 @@ impl OpenHomeQueue {
|
||||
remaining_idx += 1;
|
||||
previous_id = existing_id;
|
||||
|
||||
// Mettre à jour les métadonnées de l'item existant conservé
|
||||
// La fonction cache_metadata gère la protection contre la diminution de durée
|
||||
self.cache_metadata(existing_id, item.metadata);
|
||||
self.cache_metadata(existing_id, item.metadata, &item.uri);
|
||||
} else {
|
||||
let metadata = build_metadata_xml(&item);
|
||||
let new_id = self
|
||||
.playlist_client
|
||||
.insert(previous_id, &item.uri, &metadata)?;
|
||||
|
||||
// Enregistrer les métadonnées du nouvel item
|
||||
self.cache_metadata(new_id, item.metadata);
|
||||
self.cache_metadata(new_id, item.metadata, &item.uri);
|
||||
|
||||
previous_id = new_id;
|
||||
|
||||
if !first_insert_done && on_ready.is_some() {
|
||||
first_insert_done = true;
|
||||
if let Some(f) = on_ready.take() {
|
||||
f();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -767,9 +929,7 @@ impl OpenHomeQueue {
|
||||
)));
|
||||
}
|
||||
|
||||
// Invalidate cache after playlist modifications
|
||||
self.track_ids_cache.lock().unwrap().invalidate();
|
||||
self.read_list_cache.lock().unwrap().invalidate();
|
||||
self.invalidate_track_caches();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -883,6 +1043,52 @@ fn lcs_flags(current: &[PlaybackItem], desired: &[PlaybackItem]) -> (Vec<bool>,
|
||||
(keep_current, keep_desired)
|
||||
}
|
||||
|
||||
fn lcs_flags_optimized(
|
||||
current: &[PlaybackItem],
|
||||
desired: &[PlaybackItem],
|
||||
) -> (Vec<bool>, Vec<bool>) {
|
||||
if current.is_empty() {
|
||||
return (vec![], vec![true; desired.len()]);
|
||||
}
|
||||
if desired.is_empty() {
|
||||
return (vec![true; current.len()], vec![]);
|
||||
}
|
||||
|
||||
let leading = current
|
||||
.iter()
|
||||
.zip(desired.iter())
|
||||
.take_while(|(c, d)| items_match(c, d))
|
||||
.count();
|
||||
|
||||
let c_tail = ¤t[leading..];
|
||||
let d_tail = &desired[leading..];
|
||||
let trailing = c_tail
|
||||
.iter()
|
||||
.rev()
|
||||
.zip(d_tail.iter().rev())
|
||||
.take_while(|(c, d)| items_match(c, d))
|
||||
.count();
|
||||
|
||||
let c_mid = &c_tail[..c_tail.len().saturating_sub(trailing)];
|
||||
let d_mid = &d_tail[..d_tail.len().saturating_sub(trailing)];
|
||||
|
||||
if c_mid.is_empty() && d_mid.is_empty() {
|
||||
return (vec![true; current.len()], vec![true; desired.len()]);
|
||||
}
|
||||
|
||||
let (keep_c_mid, keep_d_mid) = lcs_flags(c_mid, d_mid);
|
||||
|
||||
let mut keep_current = vec![true; leading];
|
||||
keep_current.extend(keep_c_mid);
|
||||
keep_current.extend(vec![true; trailing]);
|
||||
|
||||
let mut keep_desired = vec![true; leading];
|
||||
keep_desired.extend(keep_d_mid);
|
||||
keep_desired.extend(vec![true; trailing]);
|
||||
|
||||
(keep_current, keep_desired)
|
||||
}
|
||||
|
||||
impl QueueBackend for OpenHomeQueue {
|
||||
fn len(&self) -> Result<usize, ControlPointError> {
|
||||
Ok(self.track_ids()?.len())
|
||||
@@ -983,7 +1189,7 @@ impl QueueBackend for OpenHomeQueue {
|
||||
|
||||
// Read metadata for all tracks (batched), with 500ms cache to avoid
|
||||
// redundant SOAP calls during sync_queue (which calls queue_snapshot twice).
|
||||
const MAX_BATCH: usize = 64;
|
||||
const MAX_BATCH: usize = 256;
|
||||
let mut entries = Vec::with_capacity(ids.len());
|
||||
for chunk in ids.chunks(MAX_BATCH) {
|
||||
if let Some(cached) = self.read_list_cache.lock().unwrap().get(chunk) {
|
||||
@@ -1056,9 +1262,7 @@ impl QueueBackend for OpenHomeQueue {
|
||||
self.playlist_client.stop()?;
|
||||
}
|
||||
// Invalidate caches (seek_id/stop modifies playlist state and current track)
|
||||
self.track_ids_cache.lock().unwrap().invalidate();
|
||||
self.read_list_cache.lock().unwrap().invalidate();
|
||||
self.current_track_id_cache.lock().unwrap().invalidate();
|
||||
self.invalidate_all_caches();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1082,9 +1286,7 @@ impl QueueBackend for OpenHomeQueue {
|
||||
self.metadata_cache.lock().unwrap().clear();
|
||||
|
||||
// Invalidate caches after delete_all (clears queue and current track)
|
||||
self.track_ids_cache.lock().unwrap().invalidate();
|
||||
self.read_list_cache.lock().unwrap().invalidate();
|
||||
self.current_track_id_cache.lock().unwrap().invalidate();
|
||||
self.invalidate_all_caches();
|
||||
|
||||
if items.is_empty() {
|
||||
return Ok(());
|
||||
@@ -1099,22 +1301,31 @@ impl QueueBackend for OpenHomeQueue {
|
||||
.insert(previous_id, &item.uri, &metadata)?;
|
||||
|
||||
// Enregistrer les métadonnées dans le cache
|
||||
self.cache_metadata(new_id, item.metadata);
|
||||
self.cache_metadata(new_id, item.metadata, &item.uri);
|
||||
|
||||
previous_id = new_id;
|
||||
}
|
||||
|
||||
// Invalidate cache after insertions
|
||||
self.track_ids_cache.lock().unwrap().invalidate();
|
||||
self.read_list_cache.lock().unwrap().invalidate();
|
||||
self.invalidate_track_caches();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||
fn sync_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
cancel_token: &Arc<AtomicBool>,
|
||||
mut on_ready: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
|
||||
self.ensure_playlist_source_selected()?;
|
||||
|
||||
// DIAGNOSTIC: Log current track state before any modifications
|
||||
if cancel_token.load(SeqCst) {
|
||||
return Err(ControlPointError::SyncCancelled);
|
||||
}
|
||||
|
||||
let pre_current_track = self.playlist_client.id().ok();
|
||||
tracing::warn!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
@@ -1130,12 +1341,8 @@ impl QueueBackend for OpenHomeQueue {
|
||||
);
|
||||
self.playlist_client.delete_all()?;
|
||||
self.metadata_cache.lock().unwrap().clear();
|
||||
// Invalidate caches after delete_all (clears queue and current track)
|
||||
self.track_ids_cache.lock().unwrap().invalidate();
|
||||
self.read_list_cache.lock().unwrap().invalidate();
|
||||
self.current_track_id_cache.lock().unwrap().invalidate();
|
||||
self.invalidate_all_caches();
|
||||
|
||||
// DIAGNOSTIC: Log state after delete_all
|
||||
let post_current_track = self.playlist_client.id().ok();
|
||||
tracing::warn!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
@@ -1145,9 +1352,58 @@ impl QueueBackend for OpenHomeQueue {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Synchronize local state with the actual OpenHome playlist before computing
|
||||
// differences. Without this, any drift between our cache and the renderer
|
||||
// (e.g., manual edits from another control point) would keep the stale items.
|
||||
match self.try_fast_path(&items) {
|
||||
FastPathResult::AppendOnly { new_items } => {
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
new_items_count = new_items.len(),
|
||||
"sync_queue: fast path - append-only detected"
|
||||
);
|
||||
let current_ids = self.track_ids()?;
|
||||
let mut after_id = current_ids
|
||||
.last()
|
||||
.copied()
|
||||
.unwrap_or(OPENHOME_PLAYLIST_HEAD_ID);
|
||||
let mut uri_cache = self.uri_by_id.lock().unwrap();
|
||||
for item in &new_items {
|
||||
if cancel_token.load(SeqCst) {
|
||||
return Err(ControlPointError::SyncCancelled);
|
||||
}
|
||||
let metadata = build_metadata_xml(item);
|
||||
let uri = item.uri.as_str();
|
||||
let metadata_xml = metadata.as_str();
|
||||
after_id = self.playlist_client.insert(after_id, uri, metadata_xml)?;
|
||||
if !item.uri.is_empty() {
|
||||
uri_cache.insert(after_id, item.uri.clone());
|
||||
}
|
||||
}
|
||||
drop(uri_cache);
|
||||
self.invalidate_track_caches();
|
||||
return Ok(());
|
||||
}
|
||||
FastPathResult::DeleteFromEnd { delete_ids } => {
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
delete_count = delete_ids.len(),
|
||||
"sync_queue: fast path - delete-from-end detected"
|
||||
);
|
||||
for id in delete_ids.iter().rev() {
|
||||
if cancel_token.load(SeqCst) {
|
||||
return Err(ControlPointError::SyncCancelled);
|
||||
}
|
||||
self.playlist_client.delete_id(*id)?;
|
||||
}
|
||||
self.invalidate_all_caches();
|
||||
return Ok(());
|
||||
}
|
||||
FastPathResult::NeedFullSync => {
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
"sync_queue: fast path not applicable, proceeding with full sync"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let snapshot = self.queue_snapshot()?;
|
||||
|
||||
debug!(
|
||||
@@ -1158,9 +1414,6 @@ impl QueueBackend for OpenHomeQueue {
|
||||
"sync_queue: snapshot vs new items comparison"
|
||||
);
|
||||
|
||||
// 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((
|
||||
@@ -1187,8 +1440,14 @@ impl QueueBackend for OpenHomeQueue {
|
||||
"OpenHome playlist state"
|
||||
);
|
||||
|
||||
let has_pivot = playing_info.is_some();
|
||||
if has_pivot {
|
||||
if let Some(f) = on_ready.take() {
|
||||
f();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((playing_idx, playing_id, playing_uri, playing_didl_id)) = playing_info {
|
||||
// Find if the currently playing item is in the new playlist (by URI first, then by didl_id)
|
||||
let new_playing_idx = items
|
||||
.iter()
|
||||
.position(|item| item.uri == playing_uri)
|
||||
@@ -1208,8 +1467,6 @@ impl QueueBackend for OpenHomeQueue {
|
||||
);
|
||||
|
||||
if let Some(pivot_idx) = new_playing_idx {
|
||||
// CASE 2: Currently playing item IS in the new playlist
|
||||
// Use gentle double-LCS strategy: preserve the pivot and sync before/after separately
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
playing_idx,
|
||||
@@ -1218,22 +1475,32 @@ impl QueueBackend for OpenHomeQueue {
|
||||
pivot_idx
|
||||
);
|
||||
|
||||
self.replace_queue_with_pivot(items, pivot_idx, playing_id)?;
|
||||
let current_ids_for_pivot: Vec<u32> =
|
||||
snapshot.items.iter().map(|i| i.backend_id as u32).collect();
|
||||
self.replace_queue_with_pivot(
|
||||
items,
|
||||
pivot_idx,
|
||||
playing_id,
|
||||
&snapshot,
|
||||
¤t_ids_for_pivot,
|
||||
cancel_token,
|
||||
&mut on_ready,
|
||||
)?;
|
||||
} else {
|
||||
// CASE 1: Currently playing item NOT in the new playlist
|
||||
// Keep it as first item and append the new playlist after it
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
playing_idx,
|
||||
"Gentle sync: currently playing item not in new playlist, preserving as first item"
|
||||
);
|
||||
|
||||
self.replace_queue_preserve_current(items, playing_id)?;
|
||||
self.replace_queue_preserve_current(
|
||||
items,
|
||||
playing_id,
|
||||
cancel_token,
|
||||
&mut on_ready,
|
||||
)?;
|
||||
}
|
||||
} else {
|
||||
// No currently playing item or can't determine it - use standard LCS
|
||||
// BUT first check if this is because the OpenHome device returned empty playlist
|
||||
// This could cause the queue to be cleared incorrectly
|
||||
if snapshot.items.is_empty() && !items.is_empty() {
|
||||
tracing::warn!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
@@ -1241,8 +1508,6 @@ impl QueueBackend for OpenHomeQueue {
|
||||
new_items = items.len(),
|
||||
"OpenHome playlist appears empty - possible stale cache or device issue, NOT clearing queue"
|
||||
);
|
||||
// Don't call replace_queue_standard_lcs with empty snapshot - it would clear our queue
|
||||
// Instead, just add the new items without deleting existing ones
|
||||
return self.enqueue_items(items, crate::queue::EnqueueMode::AppendToEnd);
|
||||
}
|
||||
|
||||
@@ -1250,10 +1515,17 @@ impl QueueBackend for OpenHomeQueue {
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
"No currently playing item, using standard LCS sync"
|
||||
);
|
||||
self.replace_queue_standard_lcs(items, Some(0))?;
|
||||
let current_ids_for_lcs: Vec<u32> =
|
||||
snapshot.items.iter().map(|i| i.backend_id as u32).collect();
|
||||
self.replace_queue_standard_lcs(
|
||||
items,
|
||||
&snapshot,
|
||||
¤t_ids_for_lcs,
|
||||
cancel_token,
|
||||
&mut on_ready,
|
||||
)?;
|
||||
}
|
||||
|
||||
// DIAGNOSTIC: Log state after sync completes
|
||||
let post_current_track = self.playlist_client.id().ok();
|
||||
let post_ids = self.track_ids();
|
||||
tracing::warn!(
|
||||
@@ -1312,15 +1584,15 @@ impl QueueBackend for OpenHomeQueue {
|
||||
|
||||
// Mettre à jour le cache avec les nouvelles métadonnées
|
||||
self.metadata_cache.lock().unwrap().remove(&track_id);
|
||||
self.cache_metadata(new_id, item.metadata);
|
||||
self.uri_by_id.lock().unwrap().remove(&track_id);
|
||||
self.cache_metadata(new_id, item.metadata, &item.uri);
|
||||
|
||||
if ci == Some(index) {
|
||||
self.playlist_client.seek_id(new_id)?;
|
||||
}
|
||||
|
||||
// Invalidate cache after playlist modifications
|
||||
self.track_ids_cache.lock().unwrap().invalidate();
|
||||
self.read_list_cache.lock().unwrap().invalidate();
|
||||
self.invalidate_track_caches();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1365,8 +1637,7 @@ impl QueueBackend for OpenHomeQueue {
|
||||
}
|
||||
|
||||
// Invalidate cache after playlist modifications (except ReplaceAll which already does it)
|
||||
self.track_ids_cache.lock().unwrap().invalidate();
|
||||
self.read_list_cache.lock().unwrap().invalidate();
|
||||
self.invalidate_track_caches();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1378,9 +1649,7 @@ impl QueueBackend for OpenHomeQueue {
|
||||
self.ensure_playlist_source_selected()?;
|
||||
self.playlist_client.delete_all()?;
|
||||
// Invalidate caches after clearing playlist (clears queue and current track)
|
||||
self.track_ids_cache.lock().unwrap().invalidate();
|
||||
self.read_list_cache.lock().unwrap().invalidate();
|
||||
self.current_track_id_cache.lock().unwrap().invalidate();
|
||||
self.invalidate_all_caches();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1475,6 +1744,6 @@ impl QueueFromRendererInfo for OpenHomeQueue {
|
||||
}
|
||||
|
||||
fn to_backend(self) -> MusicQueue {
|
||||
MusicQueue::OpenHome(self)
|
||||
MusicQueue::from_openhome(self)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::{DeviceId, model::TrackMetadata};
|
||||
use crate::{model::TrackMetadata, DeviceId};
|
||||
|
||||
/// Canonical representation of a track in a renderer queue.
|
||||
///
|
||||
|
||||
@@ -136,6 +136,14 @@ impl DeviceRegistry {
|
||||
self.devices.get(id)?.as_music_renderer().ok()
|
||||
}
|
||||
|
||||
pub fn get_renderer_queue_arc(
|
||||
&self,
|
||||
id: &DeviceId,
|
||||
) -> Option<std::sync::Arc<std::sync::Mutex<crate::queue::MusicQueue>>> {
|
||||
let renderer = self.get_renderer(id)?;
|
||||
Some(renderer.queue())
|
||||
}
|
||||
|
||||
pub fn get_server(&self, id: &DeviceId) -> Option<Arc<MusicServer>> {
|
||||
self.devices.get(id)?.as_music_server().ok()
|
||||
}
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use pmoupnp::soap::{SoapEnvelope, build_soap_request, parse_soap_envelope};
|
||||
use pmoupnp::soap::{build_soap_request, parse_soap_envelope, SoapEnvelope};
|
||||
use tracing::{debug, trace, warn};
|
||||
use ureq::Agent;
|
||||
|
||||
use crate::errors::ControlPointError;
|
||||
|
||||
static SOAP_AGENT: OnceLock<Arc<Agent>> = OnceLock::new();
|
||||
|
||||
fn get_soap_agent() -> Arc<Agent> {
|
||||
SOAP_AGENT
|
||||
.get_or_init(|| {
|
||||
Arc::new(
|
||||
Agent::config_builder()
|
||||
.http_status_as_error(false)
|
||||
.timeout_global(Some(Duration::from_secs(30)))
|
||||
.build()
|
||||
.into(),
|
||||
)
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Result of a SOAP call:
|
||||
/// - HTTP status code
|
||||
/// - raw XML body (always)
|
||||
@@ -62,21 +80,20 @@ pub fn invoke_upnp_action_with_timeout(
|
||||
|
||||
trace!(body = body_xml.as_str(), "SOAP request body");
|
||||
|
||||
let mut builder = Agent::config_builder();
|
||||
builder = builder.http_status_as_error(false);
|
||||
if let Some(duration) = timeout {
|
||||
builder = builder.timeout_global(Some(duration));
|
||||
}
|
||||
let request = if let Some(duration) = timeout {
|
||||
let config = Agent::config_builder()
|
||||
.http_status_as_error(false)
|
||||
.timeout_global(Some(duration))
|
||||
.build();
|
||||
let agent: Agent = config.into();
|
||||
agent.post(control_url)
|
||||
} else {
|
||||
get_soap_agent().post(control_url)
|
||||
};
|
||||
|
||||
let config = builder.build();
|
||||
let agent: Agent = config.into();
|
||||
|
||||
// 3. SOAPAction header
|
||||
let soap_action_header = format!(r#""{}#{}""#, service_type, action);
|
||||
|
||||
// 4. HTTP POST
|
||||
let mut response = agent
|
||||
.post(control_url)
|
||||
let mut response = request
|
||||
.header("Content-Type", r#"text/xml; charset="utf-8""#)
|
||||
.header("SOAPAction", &soap_action_header)
|
||||
.send(body_xml)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user