Refactoriser MusicRenderer pour un comportement stateful complet #57
114
Blackboard/Done/stateful_music_renderer.md
Normal file
114
Blackboard/Done/stateful_music_renderer.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# Tâche terminée : Rendre MusicRenderer complètement stateful
|
||||
|
||||
## Objectif
|
||||
|
||||
Refactoriser l'architecture pour que chaque `MusicRenderer` gère son propre thread de surveillance (watcher), au lieu de déléguer le polling au `ControlPoint` centralisé.
|
||||
|
||||
## Motivation
|
||||
|
||||
1. **Encapsulation** - Tout l'état et le comportement d'un renderer au même endroit
|
||||
2. **Cohérence** - Les événements sont émis là où l'état change
|
||||
3. **Adaptabilité par backend** - Chaque backend peut avoir sa propre stratégie de surveillance (polling vs push)
|
||||
4. **Auto-advance spécifique** - La logique d'auto-advance peut être adaptée par backend
|
||||
5. **Simplicité du ControlPoint** - Il devient un simple registry/coordinateur
|
||||
|
||||
---
|
||||
|
||||
## Résumé de l'implémentation
|
||||
|
||||
### Fichiers créés
|
||||
|
||||
| Fichier | Description |
|
||||
|---------|-------------|
|
||||
| `pmocontrol/src/music_renderer/watcher.rs` | Module watcher avec `WatchStrategy`, `WatchedState` et fonctions helper |
|
||||
|
||||
### Fichiers modifiés
|
||||
|
||||
| Fichier | Modification |
|
||||
|---------|--------------|
|
||||
| `pmocontrol/src/music_renderer/musicrenderer.rs` | Champs watcher, méthodes `start/stop_watching()`, logique auto-advance, gestion automatique dans constructeur et `DeviceOnline` |
|
||||
| `pmocontrol/src/music_renderer/mod.rs` | Export du module `watcher` |
|
||||
| `pmocontrol/src/registry.rs` | Simplifié : plus d'appels manuels watcher |
|
||||
| `pmocontrol/src/control_point.rs` | Suppression polling central (~140 lignes), `RendererRuntimeSnapshot`, `handle_renderer_event()` |
|
||||
|
||||
---
|
||||
|
||||
## Architecture finale
|
||||
|
||||
### WatchStrategy
|
||||
|
||||
```rust
|
||||
pub enum WatchStrategy {
|
||||
Polling { interval_ms: u64 }, // UPnP, LinkPlay, Arylic (500ms)
|
||||
Push, // Futur : notifications push
|
||||
Hybrid { polling_interval_ms: u64 }, // OpenHome, Chromecast
|
||||
}
|
||||
```
|
||||
|
||||
### Gestion automatique du watcher
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ GESTION AUTOMATIQUE DU WATCHER │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Création MusicRenderer ──► constructeur ──► start_watching() │
|
||||
│ │
|
||||
│ has_been_seen_now() ──► si !was_online ──► start_watching() │
|
||||
│ │
|
||||
│ mark_as_offline() ──► stop_watching() ──► online = false │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Flux offline/online
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ FLUX ONLINE │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ SSDP Discovery ──► push_renderer() ──► constructeur │
|
||||
│ ──► start_watching() │
|
||||
│ │
|
||||
│ SSDP Alive (offline→online) ──► has_been_seen_now() │
|
||||
│ ──► start_watching() │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ FLUX OFFLINE │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ SSDP ByeBye / Timeout ──► mark_as_offline() │
|
||||
│ ──► stop_watching() │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Points techniques clés
|
||||
|
||||
- **Thread safety** : `AtomicBool` avec `Ordering::SeqCst` pour le signal d'arrêt
|
||||
- **Idempotence** : `start_watching()` et `stop_watching()` sont idempotents
|
||||
- **Nommage** : Thread nommé `watcher-{friendly_name}` pour debug
|
||||
- **Polling** : 500ms pour position/état, 1s pour volume/mute
|
||||
- **Auto-advance** : Géré dans `handle_state_change()` du MusicRenderer
|
||||
- **Compensation bugs** : `compute_logical_playback_state()` corrige les comportements Arylic/LinkPlay
|
||||
|
||||
---
|
||||
|
||||
## Rounds de vérification
|
||||
|
||||
| Round | Objectif | Résultat |
|
||||
|-------|----------|----------|
|
||||
| 1 | Implémentation initiale | OK |
|
||||
| 2 | Vérifier transition offline→online | Bug trouvé et corrigé dans `refresh_device_presence()` |
|
||||
| 3 | Audit complet des chemins offline/online | Tous les chemins vérifiés OK |
|
||||
| 4 | Centralisation dans `MusicRenderer` | Gestion automatique dans constructeur et `DeviceOnline` |
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
L'architecture est maintenant plus robuste :
|
||||
- Impossible d'oublier de démarrer/arrêter le watcher
|
||||
- Le `registry.rs` est simplifié
|
||||
- Préparation pour le support futur des notifications push (OpenHome, Chromecast)
|
||||
229
Blackboard/Report/stateful_music_renderer.md
Normal file
229
Blackboard/Report/stateful_music_renderer.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# Rapport : Rendre MusicRenderer complètement stateful
|
||||
|
||||
## Résumé
|
||||
|
||||
Refactorisation de l'architecture pour que chaque `MusicRenderer` gère son propre thread de surveillance (watcher), au lieu de déléguer le polling au `ControlPoint` centralisé. Cette modification améliore l'encapsulation, la cohérence des événements et prépare le terrain pour le support futur des notifications push (OpenHome, Chromecast).
|
||||
|
||||
## Travail effectué
|
||||
|
||||
### Phase 1 : Création du module watcher.rs
|
||||
|
||||
**Fichier créé** : `pmocontrol/src/music_renderer/watcher.rs`
|
||||
|
||||
Nouveau module contenant :
|
||||
- `WatchStrategy` enum avec trois variantes :
|
||||
- `Polling { interval_ms: u64 }` - pour UPnP, LinkPlay, Arylic (500ms)
|
||||
- `Push` - pour support futur des notifications push
|
||||
- `Hybrid { polling_interval_ms: u64 }` - pour OpenHome et Chromecast
|
||||
- `WatchedState` struct pour le cache de détection des changements
|
||||
- Fonctions helper déplacées depuis `control_point.rs` :
|
||||
- `playback_state_equal()`
|
||||
- `playback_position_equal()`
|
||||
- `compute_logical_playback_state()`
|
||||
- `extract_track_metadata()`
|
||||
- `parse_hms_to_secs()`
|
||||
- Tests unitaires pour les fonctions helper
|
||||
|
||||
### Phase 2 : Extension de MusicRenderer
|
||||
|
||||
**Fichier modifié** : `pmocontrol/src/music_renderer/musicrenderer.rs`
|
||||
|
||||
Nouveaux champs ajoutés à la struct `MusicRenderer` :
|
||||
- `watched_state: Arc<Mutex<WatchedState>>` - cache pour détection des changements
|
||||
- `watcher_stop_flag: Arc<AtomicBool>` - signal d'arrêt du thread
|
||||
- `watcher_handle: Arc<Mutex<Option<JoinHandle<()>>>>` - handle du thread watcher
|
||||
|
||||
Nouvelles méthodes publiques :
|
||||
- `start_watching()` - démarre le thread de surveillance (idempotent)
|
||||
- `stop_watching()` - arrête le thread gracieusement (idempotent)
|
||||
- `is_watching()` - retourne l'état du watcher
|
||||
|
||||
Nouvelles méthodes internes :
|
||||
- `spawn_watcher_thread()` - crée le thread avec la stratégie appropriée
|
||||
- `watcher_loop()` - boucle principale de polling
|
||||
- `poll_and_emit_changes()` - poll le backend et émet les événements
|
||||
- `handle_state_change()` - logique d'auto-advance (déplacée depuis ControlPoint)
|
||||
- `emit_event()` - helper pour émettre un événement via le bus
|
||||
|
||||
### Phase 3 : Modification du Registry
|
||||
|
||||
**Fichier modifié** : `pmocontrol/src/registry.rs`
|
||||
|
||||
Ajout des appels `start_watching()` / `stop_watching()` :
|
||||
- `push_renderer()` : appelle `start_watching()` quand un renderer arrive en ligne ou est créé
|
||||
- `device_says_byebye()` : appelle `stop_watching()` avant de marquer offline
|
||||
- `check_timeouts()` : appelle `stop_watching()` avant de marquer offline sur timeout
|
||||
|
||||
### Phase 4 : Simplification du ControlPoint
|
||||
|
||||
**Fichier modifié** : `pmocontrol/src/control_point.rs`
|
||||
|
||||
Suppressions :
|
||||
- Thread de polling central (~140 lignes)
|
||||
- Struct `RendererRuntimeSnapshot`
|
||||
- Méthodes `emit_renderer_event()` et `handle_renderer_event()`
|
||||
- Fonctions helper déplacées vers `watcher.rs`
|
||||
|
||||
### Phase 5 : Mise à jour du module
|
||||
|
||||
**Fichier modifié** : `pmocontrol/src/music_renderer/mod.rs`
|
||||
|
||||
Ajout de `pub mod watcher;` pour exposer le nouveau module.
|
||||
|
||||
## Liste des fichiers
|
||||
|
||||
### Fichiers créés
|
||||
|
||||
| Fichier | Description |
|
||||
|---------|-------------|
|
||||
| `pmocontrol/src/music_renderer/watcher.rs` | Module watcher avec WatchStrategy, WatchedState et fonctions helper |
|
||||
|
||||
### Fichiers modifiés
|
||||
|
||||
| Fichier | Modification |
|
||||
|---------|--------------|
|
||||
| `pmocontrol/src/music_renderer/musicrenderer.rs` | Ajout champs watcher, méthodes start/stop_watching, logique auto-advance |
|
||||
| `pmocontrol/src/music_renderer/mod.rs` | Ajout `pub mod watcher;` |
|
||||
| `pmocontrol/src/registry.rs` | Appels start/stop_watching dans push_renderer, device_says_byebye, check_timeouts |
|
||||
| `pmocontrol/src/control_point.rs` | Suppression polling central, RendererRuntimeSnapshot, handle_renderer_event, fonctions helper |
|
||||
|
||||
## Notes techniques
|
||||
|
||||
- Le signal d'arrêt utilise `AtomicBool` avec `Ordering::SeqCst` pour garantir la visibilité entre threads
|
||||
- Les méthodes `start_watching()` et `stop_watching()` sont idempotentes
|
||||
- Le thread watcher est nommé `watcher-{friendly_name}` pour faciliter le debug
|
||||
- L'intervalle de polling est de 500ms (volume/mute toutes les 2 ticks = 1s)
|
||||
- La logique `compute_logical_playback_state()` compense les bugs des devices Arylic/LinkPlay
|
||||
- L'auto-advance est maintenant géré directement dans le watcher du MusicRenderer
|
||||
|
||||
## Round 2 : Vérification transition offline → online
|
||||
|
||||
### Problème identifié
|
||||
|
||||
La méthode `refresh_device_presence()` dans `registry.rs` n'appelait pas `start_watching()` quand un renderer passait de offline à online. Cette méthode est appelée lors de la réception de messages SSDP Alive.
|
||||
|
||||
### Correction appliquée
|
||||
|
||||
**Fichier modifié** : `pmocontrol/src/registry.rs`
|
||||
|
||||
Ajout de l'appel `renderer.start_watching()` dans `refresh_device_presence()` quand `was_online == false`.
|
||||
|
||||
### Points de démarrage du watcher vérifiés
|
||||
|
||||
| Méthode | Situation | `start_watching()` appelé |
|
||||
|---------|-----------|---------------------------|
|
||||
| `push_renderer()` | Nouveau renderer | Oui |
|
||||
| `push_renderer()` | Renderer existant, était offline | Oui |
|
||||
| `refresh_device_presence()` | Renderer existant, était offline | Oui (corrigé) |
|
||||
|
||||
### Points d'arrêt du watcher vérifiés
|
||||
|
||||
| Méthode | Situation | `stop_watching()` appelé |
|
||||
|---------|-----------|--------------------------|
|
||||
| `device_says_byebye()` | SSDP ByeBye reçu | Oui |
|
||||
| `check_timeouts()` | Timeout dépassé | Oui |
|
||||
|
||||
## Round 3 : Audit complet de la logique offline/online
|
||||
|
||||
Suite à la découverte du manque dans le Round 2, un audit complet de tous les chemins offline/online a été effectué.
|
||||
|
||||
### Chemins qui appellent `start_watching()`
|
||||
|
||||
| Chemin | Fonction | Ligne | Condition | Status |
|
||||
|--------|----------|-------|-----------|--------|
|
||||
| Nouveau renderer découvert | `push_renderer()` | 180, 194 | Création nouvelle entry | ✅ OK |
|
||||
| Renderer existant, ajout renderer à entry | `push_renderer()` | 169 | Entry existe sans renderer | ✅ OK |
|
||||
| Renderer existant revient online | `push_renderer()` | 160 | `!was_online` | ✅ OK |
|
||||
| SSDP Alive pour device connu | `refresh_device_presence()` | 269 | `!was_online` | ✅ OK (corrigé Round 2) |
|
||||
|
||||
### Chemins qui appellent `stop_watching()`
|
||||
|
||||
| Chemin | Fonction | Ligne | Condition | Status |
|
||||
|--------|----------|-------|-----------|--------|
|
||||
| SSDP ByeBye reçu | `device_says_byebye()` | 289 | Renderer présent | ✅ OK |
|
||||
| Timeout dépassé | `check_timeouts()` | 308 | `elapsed > max_age` | ✅ OK |
|
||||
|
||||
### Analyse des flux
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ FLUX ONLINE │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ SSDP Discovery ──► push_renderer() ──► start_watching() ✅ │
|
||||
│ │
|
||||
│ SSDP Alive (nouveau UDN) ──► push_renderer() ──► start_watching() ✅ │
|
||||
│ │
|
||||
│ SSDP Alive (UDN connu, online) ──► refresh_device_presence() │
|
||||
│ (pas de start car déjà en marche) │
|
||||
│ │
|
||||
│ SSDP Alive (UDN connu, offline) ──► refresh_device_presence() │
|
||||
│ ──► start_watching() ✅ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ FLUX OFFLINE │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ SSDP ByeBye ──► device_says_byebye() ──► stop_watching() ✅ │
|
||||
│ │
|
||||
│ Timeout ──► check_timeouts() ──► stop_watching() ✅ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Conclusion
|
||||
|
||||
**Tous les chemins sont correctement gérés.** Chaque transition offline→online appelle `start_watching()` et chaque transition online→offline appelle `stop_watching()`.
|
||||
|
||||
L'idempotence des méthodes `start_watching()` et `stop_watching()` garantit qu'aucun problème ne survient en cas d'appels multiples.
|
||||
|
||||
## Round 4 : Centralisation de la gestion du watcher
|
||||
|
||||
### Problème identifié
|
||||
|
||||
Les appels à `start_watching()` et `stop_watching()` étaient dispersés dans `registry.rs` (6 emplacements), augmentant le risque d'oubli (comme découvert en Round 2).
|
||||
|
||||
### Solution implémentée
|
||||
|
||||
Centralisation de la gestion du watcher dans `MusicRenderer` lui-même :
|
||||
|
||||
1. **Constructeur** (`from_renderer_info_with_bus()`) : appelle automatiquement `start_watching()` à la fin, car le renderer est créé avec `online = true`
|
||||
|
||||
2. **`has_been_seen_now()`** : appelle automatiquement `start_watching()` si transition offline→online
|
||||
|
||||
3. **`mark_as_offline()`** : appelle automatiquement `stop_watching()` avant de passer offline
|
||||
|
||||
### Fichiers modifiés
|
||||
|
||||
| Fichier | Modification |
|
||||
|---------|--------------|
|
||||
| `pmocontrol/src/music_renderer/musicrenderer.rs` | Ajout `start_watching()` dans constructeur, dans `has_been_seen_now()` et `stop_watching()` dans `mark_as_offline()` |
|
||||
| `pmocontrol/src/registry.rs` | Suppression de tous les appels manuels à `start_watching()` et `stop_watching()` |
|
||||
|
||||
### Avantages
|
||||
|
||||
- **Encapsulation** : la logique watcher est entièrement gérée par `MusicRenderer`
|
||||
- **Impossible d'oublier** : les transitions sont automatiquement gérées
|
||||
- **Code simplifié** : `registry.rs` ne contient plus de logique watcher
|
||||
- **Idempotence** : les appels multiples sont sans effet grâce aux guards existants
|
||||
|
||||
### Nouvelle architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ GESTION AUTOMATIQUE DU WATCHER │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Création MusicRenderer ──► constructeur ──► start_watching() │
|
||||
│ │
|
||||
│ has_been_seen_now() ──► si !was_online ──► start_watching() │
|
||||
│ │
|
||||
│ mark_as_offline() ──► stop_watching() ──► online = false │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Compilation
|
||||
|
||||
Le projet compile sans erreur.
|
||||
@@ -1,154 +0,0 @@
|
||||
# Tâche : Rendre MusicRenderer complètement stateful
|
||||
|
||||
## Objectif
|
||||
|
||||
Refactoriser l'architecture pour que chaque `MusicRenderer` gère son propre état complet et son thread de surveillance, au lieu de déléguer le polling au `ControlPoint`.
|
||||
|
||||
## Motivation
|
||||
|
||||
1. **Encapsulation** - Tout l'état et le comportement d'un renderer au même endroit
|
||||
2. **Cohérence** - Les événements sont émis là où l'état change
|
||||
3. **Adaptabilité par backend** - Chaque backend peut avoir sa propre stratégie de surveillance (polling vs push pour OpenHome/Chromecast)
|
||||
4. **Auto-advance spécifique** - La logique d'auto-advance peut être adaptée par backend
|
||||
5. **Simplicité du ControlPoint** - Il devient un simple registry/coordinateur
|
||||
|
||||
## Contraintes
|
||||
|
||||
- Moins de 10 renderers simultanés → 10 threads de surveillance n'est pas un problème
|
||||
- Le transfert de queue et autres opérations multi-renderers n'impliquent pas de surveillance
|
||||
|
||||
## Architecture cible
|
||||
|
||||
### MusicRenderer
|
||||
|
||||
Responsabilités :
|
||||
- Maintenir l'état complet du renderer (transport, volume, position, queue, binding)
|
||||
- Gérer son propre thread de surveillance (polling ou push selon le backend)
|
||||
- Émettre tous les événements (StateChanged, PositionChanged, VolumeChanged, MuteChanged, QueueUpdated, BindingChanged)
|
||||
- Gérer l'auto-advance de la queue (logique adaptée par backend)
|
||||
|
||||
Nouveau champ :
|
||||
```rust
|
||||
pub struct MusicRenderer {
|
||||
// ... champs existants ...
|
||||
event_bus: Option<RendererEventBus>,
|
||||
// Nouveau : état surveillé
|
||||
watched_state: Arc<Mutex<WatchedState>>,
|
||||
// Nouveau : handle du thread de surveillance
|
||||
watcher_handle: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
struct WatchedState {
|
||||
last_playback_state: Option<PlaybackState>,
|
||||
last_position: Option<PlaybackPositionInfo>,
|
||||
last_volume: Option<u16>,
|
||||
last_mute: Option<bool>,
|
||||
}
|
||||
```
|
||||
|
||||
Nouvelles méthodes :
|
||||
```rust
|
||||
impl MusicRenderer {
|
||||
/// Démarre le thread de surveillance
|
||||
pub fn start_watching(&self) -> Result<(), ControlPointError>;
|
||||
|
||||
/// Arrête le thread de surveillance
|
||||
pub fn stop_watching(&self);
|
||||
|
||||
/// Logique d'auto-advance (appelée quand state passe à Stopped)
|
||||
fn handle_playback_stopped(&self);
|
||||
}
|
||||
```
|
||||
|
||||
### ControlPoint
|
||||
|
||||
Responsabilités simplifiées :
|
||||
- Registry des devices (renderers et servers)
|
||||
- Coordination des opérations multi-renderers (transfer_queue)
|
||||
- Point d'entrée API pour les couches supérieures
|
||||
- Démarrage/arrêt des watchers lors de l'ajout/suppression de renderers
|
||||
|
||||
Supprimer :
|
||||
- Le polling loop centralisé (`start_polling_loop`)
|
||||
- Les snapshots de surveillance (`RendererSnapshot`)
|
||||
- La logique d'auto-advance centralisée
|
||||
|
||||
### MusicRendererBackend
|
||||
|
||||
Enrichir le trait pour supporter différentes stratégies de surveillance :
|
||||
```rust
|
||||
pub trait BackendWatcher {
|
||||
/// Retourne la stratégie de surveillance pour ce backend
|
||||
fn watch_strategy(&self) -> WatchStrategy;
|
||||
}
|
||||
|
||||
pub enum WatchStrategy {
|
||||
/// Polling à intervalle fixe (UPnP, LinkPlay, Arylic)
|
||||
Polling { interval_ms: u64 },
|
||||
/// Notifications push (OpenHome, Chromecast)
|
||||
Push,
|
||||
/// Hybride : push avec polling de secours
|
||||
Hybrid { polling_interval_ms: u64 },
|
||||
}
|
||||
```
|
||||
|
||||
## Étapes d'implémentation
|
||||
|
||||
### Étape 1 : Préparer MusicRenderer
|
||||
|
||||
**Crate** : `pmocontrol`
|
||||
|
||||
1. Ajouter `WatchedState` et les champs associés à `MusicRenderer`
|
||||
2. Implémenter `start_watching()` et `stop_watching()`
|
||||
3. Implémenter la boucle de surveillance interne avec émission d'événements
|
||||
4. Implémenter `handle_playback_stopped()` pour l'auto-advance
|
||||
|
||||
### Étape 2 : Adapter par backend
|
||||
|
||||
**Crate** : `pmocontrol`
|
||||
|
||||
1. Définir le trait `BackendWatcher` et `WatchStrategy`
|
||||
2. Implémenter pour chaque backend :
|
||||
- `UpnpRenderer` : Polling 500ms
|
||||
- `OpenHomeRenderer` : Push (via subscriptions UPnP) avec fallback polling
|
||||
- `LinkPlayRenderer` : Polling 500ms
|
||||
- `ArylicTcpRenderer` : Polling 500ms
|
||||
- `ChromecastRenderer` : Push avec fallback polling
|
||||
- `HybridUpnpArylic` : Polling 500ms
|
||||
|
||||
### Étape 3 : Simplifier ControlPoint
|
||||
|
||||
**Crate** : `pmocontrol`
|
||||
|
||||
1. Supprimer `start_polling_loop()` et code associé
|
||||
2. Supprimer `RendererSnapshot` et la gestion des snapshots
|
||||
3. Modifier `push_renderer()` dans Registry pour appeler `start_watching()`
|
||||
4. Modifier la gestion offline pour appeler `stop_watching()`
|
||||
5. Supprimer la logique d'auto-advance du `handle_renderer_event()`
|
||||
|
||||
### Étape 4 : Tests et validation
|
||||
|
||||
1. Vérifier que les événements SSE sont toujours émis correctement
|
||||
2. Vérifier l'auto-advance pour chaque type de backend
|
||||
3. Vérifier le comportement online/offline
|
||||
4. Tests de performance avec plusieurs renderers
|
||||
|
||||
## Fichiers impactés
|
||||
|
||||
| Fichier | Modification |
|
||||
|---------|--------------|
|
||||
| `pmocontrol/src/music_renderer/musicrenderer.rs` | Ajout état surveillé, thread watcher, auto-advance |
|
||||
| `pmocontrol/src/music_renderer/mod.rs` | Ajout trait `BackendWatcher` |
|
||||
| `pmocontrol/src/music_renderer/upnp_renderer.rs` | Impl `BackendWatcher` (Polling) |
|
||||
| `pmocontrol/src/music_renderer/openhome_renderer.rs` | Impl `BackendWatcher` (Push/Hybrid) |
|
||||
| `pmocontrol/src/music_renderer/linkplay_renderer.rs` | Impl `BackendWatcher` (Polling) |
|
||||
| `pmocontrol/src/music_renderer/arylic_tcp.rs` | Impl `BackendWatcher` (Polling) |
|
||||
| `pmocontrol/src/music_renderer/chromecast_renderer.rs` | Impl `BackendWatcher` (Push/Hybrid) |
|
||||
| `pmocontrol/src/control_point.rs` | Suppression polling loop, simplification |
|
||||
| `pmocontrol/src/registry.rs` | Appel start/stop watching |
|
||||
|
||||
## Notes
|
||||
|
||||
- Ce refactoring est significatif mais améliore la maintenabilité à long terme
|
||||
- La migration peut être faite de manière incrémentale en gardant temporairement les deux systèmes
|
||||
- Les backends OpenHome et Chromecast bénéficieront particulièrement de cette architecture (notifications push natives)
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "PMOMusic"
|
||||
version = "0.3.3"
|
||||
version = "0.3.5"
|
||||
dependencies = [
|
||||
"axum 0.8.7",
|
||||
"console-subscriber",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "PMOMusic"
|
||||
version = "0.3.4"
|
||||
version = "0.3.5"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -194,150 +194,9 @@ impl ControlPoint {
|
||||
});
|
||||
});
|
||||
|
||||
let polling_cp = ControlPoint {
|
||||
registry: Arc::clone(®istry),
|
||||
// udn_cache: udn_cache.clone(),
|
||||
event_bus: event_bus.clone(),
|
||||
media_event_bus: media_event_bus.clone(),
|
||||
};
|
||||
|
||||
thread::spawn(move || {
|
||||
use std::collections::HashMap;
|
||||
|
||||
// Local cache for change detection (not a source of truth)
|
||||
let mut polling_cache: HashMap<DeviceId, RendererRuntimeSnapshot> = HashMap::new();
|
||||
let mut tick: u32 = 0;
|
||||
|
||||
loop {
|
||||
// Get renderers directly from registry - they already contain backends
|
||||
let renderers = {
|
||||
let reg = polling_cp.registry.read().unwrap();
|
||||
reg.list_renderers().unwrap_or_else(|_| vec![])
|
||||
};
|
||||
|
||||
for renderer in renderers {
|
||||
if !renderer.is_online() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let renderer_id = renderer.id();
|
||||
|
||||
// Get previous snapshot from local cache
|
||||
let prev_snapshot =
|
||||
polling_cache.get(&renderer_id).cloned().unwrap_or_default();
|
||||
let mut new_snapshot = prev_snapshot.clone();
|
||||
let prev_position = prev_snapshot.position.clone();
|
||||
|
||||
// Poll position every tick (1s) for smooth UI progress
|
||||
if let Ok(position) = renderer.playback_position() {
|
||||
let has_changed = match prev_snapshot.position.as_ref() {
|
||||
Some(prev) => !playback_position_equal(prev, &position),
|
||||
None => true,
|
||||
};
|
||||
|
||||
if has_changed {
|
||||
polling_cp.emit_renderer_event(RendererEvent::PositionChanged {
|
||||
id: renderer_id.clone(),
|
||||
position: position.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
// Extract and emit metadata changes
|
||||
match extract_track_metadata(&position) {
|
||||
Some(metadata) => {
|
||||
let metadata_changed = match prev_snapshot.last_metadata.as_ref() {
|
||||
Some(prev) => prev != &metadata,
|
||||
None => true,
|
||||
};
|
||||
|
||||
if metadata_changed {
|
||||
debug!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
title = metadata.title.as_deref(),
|
||||
artist = metadata.artist.as_deref(),
|
||||
"Emitting metadata changed event"
|
||||
);
|
||||
polling_cp.emit_renderer_event(
|
||||
RendererEvent::MetadataChanged {
|
||||
id: renderer_id.clone(),
|
||||
metadata: metadata.clone(),
|
||||
},
|
||||
);
|
||||
new_snapshot.last_metadata = Some(metadata);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
debug!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
has_track_metadata = position.track_metadata.is_some(),
|
||||
"No metadata extracted from position info"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
new_snapshot.position = Some(position);
|
||||
}
|
||||
|
||||
// Poll state every tick to ensure responsive playback control
|
||||
if let Ok(raw_state) = renderer.playback_state() {
|
||||
let logical_state = compute_logical_playback_state(
|
||||
&raw_state,
|
||||
prev_position.as_ref(),
|
||||
new_snapshot.position.as_ref(),
|
||||
);
|
||||
|
||||
let has_changed = match prev_snapshot.state.as_ref() {
|
||||
Some(prev) => !playback_state_equal(prev, &logical_state),
|
||||
None => true,
|
||||
};
|
||||
|
||||
// Emit event only for non-transient states to reduce noise
|
||||
// and avoid overwhelming the renderer during track changes
|
||||
if has_changed && !matches!(logical_state, PlaybackState::Transitioning) {
|
||||
polling_cp.emit_renderer_event(RendererEvent::StateChanged {
|
||||
id: renderer_id.clone(),
|
||||
state: logical_state.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
new_snapshot.state = Some(logical_state);
|
||||
}
|
||||
|
||||
// Poll volume and mute every second (every 2 ticks at 500ms)
|
||||
// for responsive volume control feedback
|
||||
if tick % 2 == 0 {
|
||||
if let Ok(volume) = renderer.volume() {
|
||||
if prev_snapshot.last_volume != Some(volume) {
|
||||
polling_cp.emit_renderer_event(RendererEvent::VolumeChanged {
|
||||
id: renderer_id.clone(),
|
||||
volume,
|
||||
});
|
||||
}
|
||||
|
||||
new_snapshot.last_volume = Some(volume);
|
||||
}
|
||||
|
||||
if let Ok(mute) = renderer.mute() {
|
||||
if prev_snapshot.last_mute != Some(mute) {
|
||||
polling_cp.emit_renderer_event(RendererEvent::MuteChanged {
|
||||
id: renderer_id.clone(),
|
||||
mute,
|
||||
});
|
||||
}
|
||||
|
||||
new_snapshot.last_mute = Some(mute);
|
||||
}
|
||||
}
|
||||
|
||||
// Update local cache
|
||||
polling_cache.insert(renderer_id, new_snapshot);
|
||||
}
|
||||
|
||||
tick = tick.wrapping_add(1);
|
||||
// 250ms polling for smoother UI updates and fluid progress bar
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
});
|
||||
// Note: Renderer polling is now handled by each MusicRenderer's own watcher thread.
|
||||
// The central polling loop has been removed in favor of per-renderer watchers
|
||||
// that are started/stopped by the Registry when devices come online/offline.
|
||||
|
||||
spawn_media_server_event_runtime(
|
||||
Arc::clone(®istry),
|
||||
@@ -1480,50 +1339,9 @@ impl ControlPoint {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn emit_renderer_event(&self, event: RendererEvent) {
|
||||
self.handle_renderer_event(&event);
|
||||
self.event_bus.broadcast(event);
|
||||
}
|
||||
|
||||
fn handle_renderer_event(&self, event: &RendererEvent) {
|
||||
if let RendererEvent::StateChanged { id, state } = event {
|
||||
let Some(renderer) = self.music_renderer_by_id(id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
match state {
|
||||
PlaybackState::Stopped => {
|
||||
// Check if user requested stop (via Stop button in UI)
|
||||
if renderer.check_and_clear_user_stop_requested() {
|
||||
debug!(
|
||||
renderer = id.0.as_str(),
|
||||
"Renderer stopped by user request; not auto-advancing"
|
||||
);
|
||||
renderer.set_playback_source(PlaybackSource::None);
|
||||
} else if renderer.is_playing_from_queue() {
|
||||
debug!(
|
||||
renderer = id.0.as_str(),
|
||||
"Renderer stopped after queue-driven playback; advancing"
|
||||
);
|
||||
if let Err(err) = self.play_next_from_queue(id) {
|
||||
error!(
|
||||
renderer = id.0.as_str(),
|
||||
error = %err,
|
||||
"Auto-advance failed; clearing queue playback state"
|
||||
);
|
||||
renderer.set_playback_source(PlaybackSource::None);
|
||||
}
|
||||
} else {
|
||||
renderer.set_playback_source(PlaybackSource::None);
|
||||
}
|
||||
}
|
||||
PlaybackState::Playing => {
|
||||
renderer.mark_external_if_idle();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Note: emit_renderer_event and handle_renderer_event have been removed.
|
||||
// Renderer events are now emitted directly by each MusicRenderer's watcher thread,
|
||||
// and auto-advance logic is handled internally by MusicRenderer::handle_state_change().
|
||||
}
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
@@ -1564,16 +1382,8 @@ fn parse_hms_to_ms(hms: Option<&str>) -> Option<u64> {
|
||||
Some((hours * 3600 + minutes * 60 + seconds) * 1000)
|
||||
}
|
||||
|
||||
/// Snapshot of renderer state used for change detection in the polling thread.
|
||||
/// This is a local cache, not a source of truth.
|
||||
#[derive(Clone, Default)]
|
||||
struct RendererRuntimeSnapshot {
|
||||
state: Option<PlaybackState>,
|
||||
position: Option<PlaybackPositionInfo>,
|
||||
last_volume: Option<u16>,
|
||||
last_mute: Option<bool>,
|
||||
last_metadata: Option<TrackMetadata>,
|
||||
}
|
||||
// Note: RendererRuntimeSnapshot has been removed.
|
||||
// Each MusicRenderer now maintains its own WatchedState for change detection.
|
||||
|
||||
/// Internal helper to refresh a renderer's playback queue from its bound
|
||||
/// playlist container.
|
||||
@@ -1866,74 +1676,14 @@ fn playback_item_track_metadata(item: &PlaybackItem) -> TrackMetadata {
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_optional_hms_to_secs(value: &Option<String>) -> Option<u64> {
|
||||
value.as_ref().and_then(|s| parse_hms_to_secs(s))
|
||||
}
|
||||
|
||||
/// Compute a logical playback state by combining the raw AVTransport state
|
||||
/// with previous and current position information.
|
||||
///
|
||||
/// This is designed to compensate for buggy LinkPlay/Arylic devices that
|
||||
/// report:
|
||||
/// - STOPPED while the time actually advances,
|
||||
/// - NO_MEDIA_PRESENT while track duration is known.
|
||||
fn compute_logical_playback_state(
|
||||
raw: &PlaybackState,
|
||||
prev_position: Option<&PlaybackPositionInfo>,
|
||||
current_position: Option<&PlaybackPositionInfo>,
|
||||
) -> PlaybackState {
|
||||
// Rule 1: Arylic / LinkPlay sometimes report STOPPED while the stream is
|
||||
// actually playing. If we detect that the relative time advances between
|
||||
// two polls, we treat this as Playing.
|
||||
if let PlaybackState::Stopped = raw {
|
||||
if let (Some(prev), Some(curr)) = (prev_position, current_position) {
|
||||
if let (Some(prev_rel), Some(curr_rel)) = (
|
||||
parse_optional_hms_to_secs(&prev.rel_time),
|
||||
parse_optional_hms_to_secs(&curr.rel_time),
|
||||
) {
|
||||
if curr_rel > prev_rel {
|
||||
let delta = curr_rel - prev_rel;
|
||||
// Our poll loop runs every 1s; accept small jitter in the delta.
|
||||
if delta <= 5 {
|
||||
return PlaybackState::Playing;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 2: Some devices report NO_MEDIA_PRESENT while exposing a non-zero
|
||||
// track duration. In practice this behaves like a stopped transport with
|
||||
// a loaded track.
|
||||
if let PlaybackState::NoMedia = raw {
|
||||
let duration_secs = current_position
|
||||
.and_then(|p| parse_optional_hms_to_secs(&p.track_duration))
|
||||
.or_else(|| prev_position.and_then(|p| parse_optional_hms_to_secs(&p.track_duration)));
|
||||
|
||||
if matches!(duration_secs, Some(d) if d > 0) {
|
||||
return PlaybackState::Stopped;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: keep the raw (already normalized) state.
|
||||
raw.clone()
|
||||
}
|
||||
|
||||
fn playback_state_equal(a: &PlaybackState, b: &PlaybackState) -> bool {
|
||||
match (a, b) {
|
||||
(PlaybackState::Unknown(lhs), PlaybackState::Unknown(rhs)) => lhs == rhs,
|
||||
_ => std::mem::discriminant(a) == std::mem::discriminant(b),
|
||||
}
|
||||
}
|
||||
|
||||
fn playback_position_equal(a: &PlaybackPositionInfo, b: &PlaybackPositionInfo) -> bool {
|
||||
a.track == b.track
|
||||
&& a.rel_time == b.rel_time
|
||||
&& a.abs_time == b.abs_time
|
||||
&& a.track_duration == b.track_duration
|
||||
&& a.track_metadata == b.track_metadata
|
||||
&& a.track_uri == b.track_uri
|
||||
}
|
||||
// Note: The following helper functions have been moved to music_renderer/watcher.rs:
|
||||
// - parse_optional_hms_to_secs
|
||||
// - compute_logical_playback_state
|
||||
// - playback_state_equal
|
||||
// - playback_position_equal
|
||||
// - extract_track_metadata
|
||||
// - parse_hms_to_secs
|
||||
// They are now used by each MusicRenderer's watcher thread for change detection.
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
fn current_track_from_playback_item(item: &PlaybackItem) -> CurrentTrackMetadata {
|
||||
@@ -1945,76 +1695,3 @@ fn current_track_from_playback_item(item: &PlaybackItem) -> CurrentTrackMetadata
|
||||
album_art_uri: meta.and_then(|m| m.album_art_uri.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract TrackMetadata from DIDL-Lite XML in PlaybackPositionInfo.
|
||||
fn extract_track_metadata(position: &PlaybackPositionInfo) -> Option<TrackMetadata> {
|
||||
let didl_xml = match position.track_metadata.as_ref() {
|
||||
Some(xml) => xml,
|
||||
None => {
|
||||
debug!("Position info has no track_metadata (DIDL-Lite XML)");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// Parse DIDL-Lite XML
|
||||
let didl = match pmodidl::parse_metadata::<pmodidl::DIDLLite>(didl_xml) {
|
||||
Ok(parsed) => parsed.data,
|
||||
Err(err) => {
|
||||
debug!(error = %err, "Failed to parse DIDL-Lite metadata from GetPositionInfo");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// Extract first item metadata
|
||||
let item = match didl.items.first() {
|
||||
Some(item) => item,
|
||||
None => {
|
||||
debug!("DIDL-Lite has no items");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
debug!(
|
||||
title = item.title.as_str(),
|
||||
has_album_art = item.album_art.is_some(),
|
||||
album_art_uri = item.album_art.as_deref(),
|
||||
"Extracted metadata from position info"
|
||||
);
|
||||
|
||||
Some(TrackMetadata {
|
||||
title: Some(item.title.clone()),
|
||||
artist: item.artist.clone(),
|
||||
album: item.album.clone(),
|
||||
genre: item.genre.clone(),
|
||||
album_art_uri: item.album_art.clone(),
|
||||
date: item.date.clone(),
|
||||
track_number: item.original_track_number.clone(),
|
||||
creator: item.creator.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse "HH:MM:SS" style time strings to seconds.
|
||||
///
|
||||
/// Returns None for empty or sentinel values such as "NOT_IMPLEMENTED" or "-:--:--".
|
||||
fn parse_hms_to_secs(s: &str) -> Option<u64> {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Common sentinel values for "no information" in UPnP implementations.
|
||||
if s == "NOT_IMPLEMENTED" || s == "-:--:--" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let parts: Vec<_> = s.split(':').collect();
|
||||
if parts.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let hours: u64 = parts[0].parse().ok()?;
|
||||
let minutes: u64 = parts[1].parse().ok()?;
|
||||
let seconds: u64 = parts[2].parse().ok()?;
|
||||
|
||||
Some(hours * 3600 + minutes * 60 + seconds)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ mod chromecast_renderer;
|
||||
mod musicrenderer;
|
||||
mod sleep_timer;
|
||||
pub mod time_utils;
|
||||
pub mod watcher;
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
|
||||
@@ -6,9 +6,13 @@
|
||||
//! renderers through this type so that transport, volume, and state queries
|
||||
//! stay backend-neutral.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use tracing::{debug, error};
|
||||
|
||||
use crate::errors::ControlPointError;
|
||||
use crate::events::RendererEventBus;
|
||||
use crate::model::RendererEvent;
|
||||
@@ -24,6 +28,10 @@ use crate::music_renderer::linkplay_renderer::LinkPlayRenderer;
|
||||
use crate::music_renderer::openhome_renderer::OpenHomeRenderer;
|
||||
use crate::music_renderer::sleep_timer::SleepTimer;
|
||||
use crate::music_renderer::upnp_renderer::UpnpRenderer;
|
||||
use crate::music_renderer::watcher::{
|
||||
WatchStrategy, WatchedState, compute_logical_playback_state, extract_track_metadata,
|
||||
playback_position_equal, playback_state_equal,
|
||||
};
|
||||
use crate::online::DeviceConnectionState;
|
||||
use crate::queue::{
|
||||
EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueFromRendererInfo, QueueSnapshot,
|
||||
@@ -94,6 +102,12 @@ pub struct MusicRenderer {
|
||||
state: Arc<Mutex<MusicRendererState>>,
|
||||
/// Optional event bus for emitting queue change events.
|
||||
event_bus: Option<RendererEventBus>,
|
||||
/// Cached state from last poll, used for change detection by the watcher.
|
||||
watched_state: Arc<Mutex<WatchedState>>,
|
||||
/// Flag to signal the watcher thread to stop.
|
||||
watcher_stop_flag: Arc<AtomicBool>,
|
||||
/// Handle to the watcher thread, if running.
|
||||
watcher_handle: Arc<Mutex<Option<JoinHandle<()>>>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for MusicRenderer {
|
||||
@@ -108,6 +122,7 @@ impl std::fmt::Debug for MusicRenderer {
|
||||
"event_bus",
|
||||
&self.event_bus.as_ref().map(|_| "RendererEventBus"),
|
||||
)
|
||||
.field("is_watching", &self.is_watching())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -126,6 +141,9 @@ impl MusicRenderer {
|
||||
playlist_binding: Arc::new(Mutex::new(None)),
|
||||
state: Arc::new(Mutex::new(MusicRendererState::default())),
|
||||
event_bus: None,
|
||||
watched_state: Arc::new(Mutex::new(WatchedState::default())),
|
||||
watcher_stop_flag: Arc::new(AtomicBool::new(false)),
|
||||
watcher_handle: Arc::new(Mutex::new(None)),
|
||||
};
|
||||
|
||||
Arc::new(renderer)
|
||||
@@ -149,7 +167,14 @@ impl MusicRenderer {
|
||||
playlist_binding: Arc::new(Mutex::new(None)),
|
||||
state: Arc::new(Mutex::new(MusicRendererState::default())),
|
||||
event_bus,
|
||||
watched_state: Arc::new(Mutex::new(WatchedState::default())),
|
||||
watcher_stop_flag: Arc::new(AtomicBool::new(false)),
|
||||
watcher_handle: Arc::new(Mutex::new(None)),
|
||||
};
|
||||
|
||||
// Start watching immediately since the renderer is created online
|
||||
renderer.start_watching();
|
||||
|
||||
Ok(renderer)
|
||||
}
|
||||
|
||||
@@ -164,6 +189,263 @@ impl MusicRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper method to emit any RendererEvent if an event bus is available.
|
||||
fn emit_event(&self, event: RendererEvent) {
|
||||
if let Some(ref bus) = self.event_bus {
|
||||
bus.broadcast(event);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Watcher Thread Management
|
||||
// =========================================================================
|
||||
|
||||
/// Starts the watcher thread for this renderer.
|
||||
///
|
||||
/// The watcher thread polls the backend at regular intervals and emits
|
||||
/// events when state changes are detected. This method is idempotent:
|
||||
/// calling it when already watching is a no-op.
|
||||
pub fn start_watching(&self) {
|
||||
let mut handle_guard = self
|
||||
.watcher_handle
|
||||
.lock()
|
||||
.expect("Watcher handle mutex poisoned");
|
||||
if handle_guard.is_some() {
|
||||
return; // Already watching
|
||||
}
|
||||
|
||||
// Reset the stop flag before starting
|
||||
self.watcher_stop_flag.store(false, Ordering::SeqCst);
|
||||
|
||||
// Determine the watch strategy based on backend type
|
||||
let strategy = {
|
||||
let backend = self.backend.lock().expect("Backend mutex poisoned");
|
||||
WatchStrategy::for_backend(&*backend)
|
||||
};
|
||||
|
||||
debug!(
|
||||
renderer = self.info.friendly_name(),
|
||||
strategy = ?strategy,
|
||||
"Starting watcher thread"
|
||||
);
|
||||
|
||||
let handle = self.spawn_watcher_thread(strategy);
|
||||
*handle_guard = Some(handle);
|
||||
}
|
||||
|
||||
/// Stops the watcher thread gracefully.
|
||||
///
|
||||
/// This method is idempotent: calling it when not watching is a no-op.
|
||||
/// The method will block until the watcher thread terminates.
|
||||
pub fn stop_watching(&self) {
|
||||
// Signal the watcher to stop
|
||||
self.watcher_stop_flag.store(true, Ordering::SeqCst);
|
||||
|
||||
// Take the handle and wait for the thread to finish
|
||||
let mut handle_guard = self
|
||||
.watcher_handle
|
||||
.lock()
|
||||
.expect("Watcher handle mutex poisoned");
|
||||
if let Some(handle) = handle_guard.take() {
|
||||
debug!(
|
||||
renderer = self.info.friendly_name(),
|
||||
"Stopping watcher thread"
|
||||
);
|
||||
// Wait for the thread to finish (ignore join errors)
|
||||
let _ = handle.join();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if the watcher thread is currently running.
|
||||
pub fn is_watching(&self) -> bool {
|
||||
self.watcher_handle
|
||||
.lock()
|
||||
.expect("Watcher handle mutex poisoned")
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// Spawns the watcher thread with the given strategy.
|
||||
fn spawn_watcher_thread(&self, strategy: WatchStrategy) -> JoinHandle<()> {
|
||||
let renderer = self.clone();
|
||||
let stop_flag = Arc::clone(&self.watcher_stop_flag);
|
||||
|
||||
thread::Builder::new()
|
||||
.name(format!("watcher-{}", self.info.friendly_name()))
|
||||
.spawn(move || {
|
||||
renderer.watcher_loop(strategy, stop_flag);
|
||||
})
|
||||
.expect("Failed to spawn watcher thread")
|
||||
}
|
||||
|
||||
/// Main loop for the watcher thread.
|
||||
fn watcher_loop(&self, strategy: WatchStrategy, stop_flag: Arc<AtomicBool>) {
|
||||
let Some(interval) = strategy.polling_interval() else {
|
||||
// Pure push strategy - no polling needed (future implementation)
|
||||
return;
|
||||
};
|
||||
|
||||
let mut tick: u32 = 0;
|
||||
|
||||
while !stop_flag.load(Ordering::SeqCst) {
|
||||
if self.is_online() {
|
||||
self.poll_and_emit_changes(tick);
|
||||
}
|
||||
|
||||
tick = tick.wrapping_add(1);
|
||||
thread::sleep(interval);
|
||||
}
|
||||
|
||||
debug!(
|
||||
renderer = self.info.friendly_name(),
|
||||
"Watcher thread exiting"
|
||||
);
|
||||
}
|
||||
|
||||
/// Polls the backend and emits events for any detected changes.
|
||||
fn poll_and_emit_changes(&self, tick: u32) {
|
||||
let mut watched = self
|
||||
.watched_state
|
||||
.lock()
|
||||
.expect("WatchedState mutex poisoned");
|
||||
let prev_position = watched.position.clone();
|
||||
|
||||
// Poll position every tick
|
||||
if let Ok(position) = self.playback_position() {
|
||||
let changed = watched
|
||||
.position
|
||||
.as_ref()
|
||||
.map(|prev| !playback_position_equal(prev, &position))
|
||||
.unwrap_or(true);
|
||||
|
||||
if changed {
|
||||
self.emit_event(RendererEvent::PositionChanged {
|
||||
id: self.id(),
|
||||
position: position.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
// Extract and emit metadata changes
|
||||
if let Some(metadata) = extract_track_metadata(&position) {
|
||||
let metadata_changed = watched
|
||||
.metadata
|
||||
.as_ref()
|
||||
.map(|prev| prev != &metadata)
|
||||
.unwrap_or(true);
|
||||
|
||||
if metadata_changed {
|
||||
debug!(
|
||||
renderer = self.info.friendly_name(),
|
||||
title = metadata.title.as_deref(),
|
||||
artist = metadata.artist.as_deref(),
|
||||
"Emitting metadata changed event"
|
||||
);
|
||||
self.emit_event(RendererEvent::MetadataChanged {
|
||||
id: self.id(),
|
||||
metadata: metadata.clone(),
|
||||
});
|
||||
watched.metadata = Some(metadata);
|
||||
}
|
||||
}
|
||||
|
||||
watched.position = Some(position);
|
||||
}
|
||||
|
||||
// Poll state every tick
|
||||
if let Ok(raw_state) = self.playback_state() {
|
||||
let logical_state = compute_logical_playback_state(
|
||||
&raw_state,
|
||||
prev_position.as_ref(),
|
||||
watched.position.as_ref(),
|
||||
);
|
||||
|
||||
let changed = watched
|
||||
.state
|
||||
.as_ref()
|
||||
.map(|prev| !playback_state_equal(prev, &logical_state))
|
||||
.unwrap_or(true);
|
||||
|
||||
// Emit event only for non-transient states to reduce noise
|
||||
if changed && !matches!(logical_state, PlaybackState::Transitioning) {
|
||||
self.emit_event(RendererEvent::StateChanged {
|
||||
id: self.id(),
|
||||
state: logical_state.clone(),
|
||||
});
|
||||
|
||||
// Handle auto-advance logic internally
|
||||
// Release the lock before calling handle_state_change to avoid deadlock
|
||||
drop(watched);
|
||||
self.handle_state_change(&logical_state);
|
||||
watched = self
|
||||
.watched_state
|
||||
.lock()
|
||||
.expect("WatchedState mutex poisoned");
|
||||
}
|
||||
|
||||
watched.state = Some(logical_state);
|
||||
}
|
||||
|
||||
// Poll volume and mute every other tick (1 second at 500ms interval)
|
||||
if tick % 2 == 0 {
|
||||
if let Ok(volume) = self.volume() {
|
||||
if watched.volume != Some(volume) {
|
||||
self.emit_event(RendererEvent::VolumeChanged {
|
||||
id: self.id(),
|
||||
volume,
|
||||
});
|
||||
watched.volume = Some(volume);
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(mute) = self.mute() {
|
||||
if watched.mute != Some(mute) {
|
||||
self.emit_event(RendererEvent::MuteChanged {
|
||||
id: self.id(),
|
||||
mute,
|
||||
});
|
||||
watched.mute = Some(mute);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles playback state changes internally (auto-advance logic).
|
||||
///
|
||||
/// This method is called by the watcher when a state change is detected.
|
||||
/// It handles the auto-advance behavior when playback stops.
|
||||
fn handle_state_change(&self, state: &PlaybackState) {
|
||||
match state {
|
||||
PlaybackState::Stopped => {
|
||||
// Check if user requested stop (via Stop button in UI)
|
||||
if self.check_and_clear_user_stop_requested() {
|
||||
debug!(
|
||||
renderer = self.info.friendly_name(),
|
||||
"Renderer stopped by user request; not auto-advancing"
|
||||
);
|
||||
self.set_playback_source(PlaybackSource::None);
|
||||
} else if self.is_playing_from_queue() {
|
||||
debug!(
|
||||
renderer = self.info.friendly_name(),
|
||||
"Renderer stopped after queue-driven playback; advancing"
|
||||
);
|
||||
if let Err(err) = self.play_next_from_queue() {
|
||||
error!(
|
||||
renderer = self.info.friendly_name(),
|
||||
error = %err,
|
||||
"Auto-advance failed; clearing queue playback state"
|
||||
);
|
||||
self.set_playback_source(PlaybackSource::None);
|
||||
}
|
||||
} else {
|
||||
self.set_playback_source(PlaybackSource::None);
|
||||
}
|
||||
}
|
||||
PlaybackState::Playing => {
|
||||
self.mark_external_if_idle();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn info(&self) -> &RendererInfo {
|
||||
&self.info
|
||||
}
|
||||
@@ -941,13 +1223,21 @@ impl DeviceOnline for MusicRenderer {
|
||||
}
|
||||
|
||||
fn has_been_seen_now(&self, max_age: u32) {
|
||||
let was_online = self.is_online();
|
||||
self.connection
|
||||
.lock()
|
||||
.expect("Connection mutex poisoned")
|
||||
.has_been_seen_now(max_age)
|
||||
.has_been_seen_now(max_age);
|
||||
|
||||
// Start watching if transitioning from offline to online
|
||||
if !was_online {
|
||||
self.start_watching();
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_as_offline(&self) {
|
||||
// Stop watching before marking offline
|
||||
self.stop_watching();
|
||||
self.connection
|
||||
.lock()
|
||||
.expect("Connection mutex poisoned")
|
||||
|
||||
276
pmocontrol/src/music_renderer/watcher.rs
Normal file
276
pmocontrol/src/music_renderer/watcher.rs
Normal file
@@ -0,0 +1,276 @@
|
||||
//! Watcher module for MusicRenderer state surveillance.
|
||||
//!
|
||||
//! This module provides the infrastructure for each MusicRenderer to maintain
|
||||
//! its own polling/watching thread, instead of relying on a centralized
|
||||
//! polling loop in ControlPoint.
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! Each MusicRenderer can have an associated watcher thread that:
|
||||
//! - Polls the backend at regular intervals (or receives push notifications)
|
||||
//! - Detects state changes by comparing with cached values
|
||||
//! - Emits events when changes are detected
|
||||
//! - Handles auto-advance logic when playback stops
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::model::{PlaybackState, TrackMetadata};
|
||||
use crate::music_renderer::capabilities::PlaybackPositionInfo;
|
||||
use crate::music_renderer::musicrenderer::MusicRendererBackend;
|
||||
|
||||
/// Strategy for monitoring renderer state changes.
|
||||
///
|
||||
/// Different backends may support different monitoring strategies:
|
||||
/// - Pure polling for simple protocols (UPnP AVTransport, LinkPlay, Arylic)
|
||||
/// - Push notifications for more advanced protocols (OpenHome subscriptions, Chromecast)
|
||||
/// - Hybrid approaches combining both
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum WatchStrategy {
|
||||
/// Poll the backend at regular intervals.
|
||||
/// Used for UPnP, LinkPlay, Arylic backends.
|
||||
Polling { interval_ms: u64 },
|
||||
|
||||
/// Backend supports push notifications (future implementation).
|
||||
/// The watcher thread would wait on a channel instead of polling.
|
||||
Push,
|
||||
|
||||
/// Hybrid: use push when available, fall back to polling.
|
||||
/// Used for OpenHome and Chromecast which support subscriptions
|
||||
/// but may need polling as a fallback.
|
||||
Hybrid { polling_interval_ms: u64 },
|
||||
}
|
||||
|
||||
impl WatchStrategy {
|
||||
/// Returns the recommended strategy for a given backend type.
|
||||
pub fn for_backend(backend: &MusicRendererBackend) -> Self {
|
||||
match backend {
|
||||
MusicRendererBackend::Upnp(_) => WatchStrategy::Polling { interval_ms: 500 },
|
||||
MusicRendererBackend::LinkPlay(_) => WatchStrategy::Polling { interval_ms: 500 },
|
||||
MusicRendererBackend::ArylicTcp(_) => WatchStrategy::Polling { interval_ms: 500 },
|
||||
MusicRendererBackend::HybridUpnpArylic { .. } => {
|
||||
WatchStrategy::Polling { interval_ms: 500 }
|
||||
}
|
||||
// Future push support - for now use hybrid with polling fallback
|
||||
MusicRendererBackend::OpenHome(_) => WatchStrategy::Hybrid {
|
||||
polling_interval_ms: 500,
|
||||
},
|
||||
MusicRendererBackend::Chromecast(_) => WatchStrategy::Hybrid {
|
||||
polling_interval_ms: 500,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the polling interval if this strategy involves polling.
|
||||
/// Returns None for pure Push strategy.
|
||||
pub fn polling_interval(&self) -> Option<Duration> {
|
||||
match self {
|
||||
WatchStrategy::Polling { interval_ms } => Some(Duration::from_millis(*interval_ms)),
|
||||
WatchStrategy::Hybrid {
|
||||
polling_interval_ms,
|
||||
} => Some(Duration::from_millis(*polling_interval_ms)),
|
||||
WatchStrategy::Push => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cached state from last poll, used for change detection.
|
||||
///
|
||||
/// The watcher maintains this state to detect changes between polls
|
||||
/// and only emit events when something actually changed.
|
||||
#[derive(Clone, Default, Debug)]
|
||||
pub struct WatchedState {
|
||||
/// Last known playback state (Playing, Paused, Stopped, etc.)
|
||||
pub state: Option<PlaybackState>,
|
||||
/// Last known playback position info
|
||||
pub position: Option<PlaybackPositionInfo>,
|
||||
/// Last known volume level (0-100)
|
||||
pub volume: Option<u16>,
|
||||
/// Last known mute state
|
||||
pub mute: Option<bool>,
|
||||
/// Last known track metadata
|
||||
pub metadata: Option<TrackMetadata>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper functions for change detection
|
||||
// ============================================================================
|
||||
|
||||
/// Compares two PlaybackState values for equality.
|
||||
///
|
||||
/// Handles the Unknown variant specially by comparing the inner string.
|
||||
pub fn playback_state_equal(a: &PlaybackState, b: &PlaybackState) -> bool {
|
||||
match (a, b) {
|
||||
(PlaybackState::Unknown(lhs), PlaybackState::Unknown(rhs)) => lhs == rhs,
|
||||
_ => std::mem::discriminant(a) == std::mem::discriminant(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// Compares two PlaybackPositionInfo values for equality.
|
||||
pub fn playback_position_equal(a: &PlaybackPositionInfo, b: &PlaybackPositionInfo) -> bool {
|
||||
a.track == b.track
|
||||
&& a.rel_time == b.rel_time
|
||||
&& a.abs_time == b.abs_time
|
||||
&& a.track_duration == b.track_duration
|
||||
&& a.track_metadata == b.track_metadata
|
||||
&& a.track_uri == b.track_uri
|
||||
}
|
||||
|
||||
/// Compute a logical playback state by combining the raw AVTransport state
|
||||
/// with previous and current position information.
|
||||
///
|
||||
/// This is designed to compensate for buggy LinkPlay/Arylic devices that
|
||||
/// report:
|
||||
/// - STOPPED while the time actually advances,
|
||||
/// - NO_MEDIA_PRESENT while track duration is known.
|
||||
pub fn compute_logical_playback_state(
|
||||
raw: &PlaybackState,
|
||||
prev_position: Option<&PlaybackPositionInfo>,
|
||||
current_position: Option<&PlaybackPositionInfo>,
|
||||
) -> PlaybackState {
|
||||
// Rule 1: Arylic / LinkPlay sometimes report STOPPED while the stream is
|
||||
// actually playing. If we detect that the relative time advances between
|
||||
// two polls, we treat this as Playing.
|
||||
if let PlaybackState::Stopped = raw {
|
||||
if let (Some(prev), Some(curr)) = (prev_position, current_position) {
|
||||
if let (Some(prev_rel), Some(curr_rel)) = (
|
||||
parse_optional_hms_to_secs(&prev.rel_time),
|
||||
parse_optional_hms_to_secs(&curr.rel_time),
|
||||
) {
|
||||
if curr_rel > prev_rel {
|
||||
let delta = curr_rel - prev_rel;
|
||||
// Our poll loop runs every 500ms; accept small jitter in the delta.
|
||||
if delta <= 5 {
|
||||
return PlaybackState::Playing;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 2: Some devices report NO_MEDIA_PRESENT while exposing a non-zero
|
||||
// track duration. In practice this behaves like a stopped transport with
|
||||
// a loaded track.
|
||||
if let PlaybackState::NoMedia = raw {
|
||||
let duration_secs = current_position
|
||||
.and_then(|p| parse_optional_hms_to_secs(&p.track_duration))
|
||||
.or_else(|| prev_position.and_then(|p| parse_optional_hms_to_secs(&p.track_duration)));
|
||||
|
||||
if matches!(duration_secs, Some(d) if d > 0) {
|
||||
return PlaybackState::Stopped;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: keep the raw (already normalized) state.
|
||||
raw.clone()
|
||||
}
|
||||
|
||||
/// Parse an optional HH:MM:SS time string to seconds.
|
||||
fn parse_optional_hms_to_secs(value: &Option<String>) -> Option<u64> {
|
||||
value.as_ref().and_then(|s| parse_hms_to_secs(s))
|
||||
}
|
||||
|
||||
/// Parse "HH:MM:SS" style time strings to seconds.
|
||||
///
|
||||
/// Returns None for empty or sentinel values such as "NOT_IMPLEMENTED" or "-:--:--".
|
||||
fn parse_hms_to_secs(s: &str) -> Option<u64> {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Common sentinel values for "no information" in UPnP implementations.
|
||||
if s == "NOT_IMPLEMENTED" || s == "-:--:--" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let parts: Vec<_> = s.split(':').collect();
|
||||
if parts.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let hours: u64 = parts[0].parse().ok()?;
|
||||
let minutes: u64 = parts[1].parse().ok()?;
|
||||
let seconds: u64 = parts[2].parse().ok()?;
|
||||
|
||||
Some(hours * 3600 + minutes * 60 + seconds)
|
||||
}
|
||||
|
||||
/// Extract TrackMetadata from DIDL-Lite XML in PlaybackPositionInfo.
|
||||
pub fn extract_track_metadata(position: &PlaybackPositionInfo) -> Option<TrackMetadata> {
|
||||
let didl_xml = position.track_metadata.as_ref()?;
|
||||
|
||||
// Parse DIDL-Lite XML
|
||||
let didl = match pmodidl::parse_metadata::<pmodidl::DIDLLite>(didl_xml) {
|
||||
Ok(parsed) => parsed.data,
|
||||
Err(_) => return None,
|
||||
};
|
||||
|
||||
// Extract first item metadata
|
||||
let item = didl.items.first()?;
|
||||
|
||||
Some(TrackMetadata {
|
||||
title: Some(item.title.clone()),
|
||||
artist: item.artist.clone(),
|
||||
album: item.album.clone(),
|
||||
genre: item.genre.clone(),
|
||||
album_art_uri: item.album_art.clone(),
|
||||
date: item.date.clone(),
|
||||
track_number: item.original_track_number.clone(),
|
||||
creator: item.creator.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_hms_to_secs() {
|
||||
assert_eq!(parse_hms_to_secs("00:00:00"), Some(0));
|
||||
assert_eq!(parse_hms_to_secs("00:01:30"), Some(90));
|
||||
assert_eq!(parse_hms_to_secs("01:00:00"), Some(3600));
|
||||
assert_eq!(parse_hms_to_secs("01:30:45"), Some(5445));
|
||||
assert_eq!(parse_hms_to_secs("NOT_IMPLEMENTED"), None);
|
||||
assert_eq!(parse_hms_to_secs("-:--:--"), None);
|
||||
assert_eq!(parse_hms_to_secs(""), None);
|
||||
assert_eq!(parse_hms_to_secs("invalid"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_playback_state_equal() {
|
||||
assert!(playback_state_equal(
|
||||
&PlaybackState::Playing,
|
||||
&PlaybackState::Playing
|
||||
));
|
||||
assert!(playback_state_equal(
|
||||
&PlaybackState::Stopped,
|
||||
&PlaybackState::Stopped
|
||||
));
|
||||
assert!(!playback_state_equal(
|
||||
&PlaybackState::Playing,
|
||||
&PlaybackState::Stopped
|
||||
));
|
||||
assert!(playback_state_equal(
|
||||
&PlaybackState::Unknown("foo".to_string()),
|
||||
&PlaybackState::Unknown("foo".to_string())
|
||||
));
|
||||
assert!(!playback_state_equal(
|
||||
&PlaybackState::Unknown("foo".to_string()),
|
||||
&PlaybackState::Unknown("bar".to_string())
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_watch_strategy_polling_interval() {
|
||||
let polling = WatchStrategy::Polling { interval_ms: 500 };
|
||||
assert_eq!(polling.polling_interval(), Some(Duration::from_millis(500)));
|
||||
|
||||
let hybrid = WatchStrategy::Hybrid {
|
||||
polling_interval_ms: 1000,
|
||||
};
|
||||
assert_eq!(hybrid.polling_interval(), Some(Duration::from_millis(1000)));
|
||||
|
||||
let push = WatchStrategy::Push;
|
||||
assert_eq!(push.polling_interval(), None);
|
||||
}
|
||||
}
|
||||
@@ -164,6 +164,7 @@ impl DeviceRegistry {
|
||||
if let Some(entry) = self.devices.get_mut(&device_id) {
|
||||
if let Some(renderer) = &entry.music_renderer {
|
||||
let was_online = renderer.is_online();
|
||||
// has_been_seen_now() automatically calls start_watching() if offline→online
|
||||
renderer.has_been_seen_now(max_age);
|
||||
|
||||
if !was_online {
|
||||
@@ -175,6 +176,7 @@ impl DeviceRegistry {
|
||||
return;
|
||||
}
|
||||
// Entry existe mais pas de renderer -> on l'ajoute
|
||||
// Constructor automatically calls start_watching()
|
||||
if let Ok(new_renderer) =
|
||||
MusicRenderer::from_renderer_info_with_bus(info, Some(self.renderer_bus.clone()))
|
||||
{
|
||||
@@ -182,7 +184,6 @@ impl DeviceRegistry {
|
||||
self.udn_index
|
||||
.insert(info.udn().to_ascii_lowercase(), device_id.clone());
|
||||
|
||||
// Broadcast sur le bon bus
|
||||
self.renderer_bus.broadcast(RendererEvent::Online {
|
||||
id: device_id.clone(),
|
||||
info: info.basic_info(),
|
||||
@@ -190,6 +191,7 @@ impl DeviceRegistry {
|
||||
}
|
||||
} else {
|
||||
// Entry n'existe pas -> on crée
|
||||
// Constructor automatically calls start_watching()
|
||||
if let Ok(new_renderer) =
|
||||
MusicRenderer::from_renderer_info_with_bus(info, Some(self.renderer_bus.clone()))
|
||||
{
|
||||
@@ -202,7 +204,6 @@ impl DeviceRegistry {
|
||||
self.udn_index
|
||||
.insert(info.udn().to_ascii_lowercase(), device_id.clone());
|
||||
|
||||
// Broadcast sur le bon bus
|
||||
self.renderer_bus.broadcast(RendererEvent::Online {
|
||||
id: device_id,
|
||||
info: info.basic_info(),
|
||||
@@ -265,15 +266,19 @@ impl DeviceRegistry {
|
||||
///
|
||||
/// This is critical for keeping devices online when SSDP Alive messages arrive
|
||||
/// more frequently than the UDN cache refresh interval (max_age/2).
|
||||
///
|
||||
/// Note: has_been_seen_now() automatically calls start_watching() for renderers
|
||||
/// when transitioning from offline to online.
|
||||
pub fn refresh_device_presence(&mut self, udn: &str, max_age: u32) {
|
||||
let lookup = udn.to_ascii_lowercase();
|
||||
|
||||
if let Some(id) = self.udn_index.get(&lookup) {
|
||||
if let Some(device) = self.devices.get(id) {
|
||||
let was_online = device.is_online();
|
||||
// has_been_seen_now() automatically calls start_watching() if offline→online
|
||||
device.has_been_seen_now(max_age);
|
||||
|
||||
// If device was offline and came back online, broadcast Online event
|
||||
// Broadcast Online event if device came back online
|
||||
if !was_online {
|
||||
if device.is_a_music_renderer() {
|
||||
if let Ok(renderer) = device.as_music_renderer() {
|
||||
@@ -301,9 +306,9 @@ impl DeviceRegistry {
|
||||
|
||||
if let Some(id) = self.udn_index.get(&lookup) {
|
||||
if let Some(device) = self.devices.get(id) {
|
||||
// mark_as_offline() automatically calls stop_watching() for renderers
|
||||
device.mark_as_offline();
|
||||
|
||||
// Broadcast sur le bon bus
|
||||
if device.is_a_music_renderer() {
|
||||
self.renderer_bus
|
||||
.broadcast(RendererEvent::Offline { id: id.clone() });
|
||||
@@ -323,9 +328,9 @@ impl DeviceRegistry {
|
||||
for (id, device) in &self.devices {
|
||||
if let Ok(elapsed) = now.duration_since(device.last_seen()) {
|
||||
if elapsed.as_secs() > device.max_age() as u64 {
|
||||
// mark_as_offline() automatically calls stop_watching() for renderers
|
||||
device.mark_as_offline();
|
||||
|
||||
// Broadcast sur le bon bus
|
||||
if device.is_a_music_renderer() {
|
||||
self.renderer_bus
|
||||
.broadcast(RendererEvent::Offline { id: id.clone() });
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.3.4
|
||||
0.3.5
|
||||
|
||||
Reference in New Issue
Block a user