diff --git a/Blackboard/Done/shuffle_play.md b/Blackboard/Done/shuffle_play.md new file mode 100644 index 00000000..21e28946 --- /dev/null +++ b/Blackboard/Done/shuffle_play.md @@ -0,0 +1,138 @@ +# Rapport Final : Implémentation du Shuffle de la Queue de Lecture + +## Résumé + +Implémentation complète de la fonctionnalité de shuffle (mélange aléatoire) de la queue de lecture pour les Music Renderers dans PMOMusic. Cette fonctionnalité permet de randomiser l'ordre des morceaux dans la queue et de redémarrer la lecture depuis le premier morceau. + +Au-delà de la fonctionnalité shuffle, ce travail a permis d'améliorer l'architecture en centralisant l'émission des événements SSE dans le `MusicRenderer` plutôt que dans le `ControlPoint`. + +## Travail effectué + +### Étape 1 : Implémentation de la méthode shuffle dans MusicRenderer + +**Fichier modifié** : `pmocontrol/src/music_renderer/musicrenderer.rs` + +Ajout de la méthode `shuffle_queue()` qui implémente la stratégie suivante : +1. Détache la queue de lecture d'une playlist si celle-ci est attachée +2. Arrête la lecture en cours +3. Prend un snapshot de la queue actuelle +4. Randomise l'ordre des morceaux avec `rand::seq::SliceRandom` +5. Remplace la queue avec les items mélangés +6. Redémarre la lecture au premier morceau + +**Dépendances ajoutées** : +- `rand = "0.9"` dans `Cargo.toml` (workspace) +- `rand = { workspace = true }` dans `pmocontrol/Cargo.toml` + +### Étape 2 : API REST et documentation OpenAPI + +**Fichiers modifiés** : +- `pmocontrol/src/pmoserver_ext.rs` : Ajout du handler `shuffle_queue` +- `pmocontrol/src/openapi.rs` : Ajout du path dans la documentation OpenAPI + +**Endpoint créé** : +``` +POST /api/control/renderers/{renderer_id}/queue/shuffle +``` + +**Réponses** : +- `200` : Queue mélangée et lecture démarrée +- `400` : Queue vide +- `404` : Renderer non trouvé +- `504` : Timeout de la commande +- `500` : Erreur interne + +### Étape 3 : Interface Vue.js + +**Fichiers créés** : +- `pmoapp/webapp/src/components/pmocontrol/ShuffleControl.vue` : Nouveau composant bouton shuffle + +**Fichiers modifiés** : +- `pmoapp/webapp/src/services/pmocontrol/api.ts` : Ajout de la méthode `shuffleQueue()` +- `pmoapp/webapp/src/components/unified/BottomTabBar.vue` : Intégration du bouton shuffle à côté du timer + +**Design** : +- Bouton circulaire avec icône Shuffle (lucide-vue-next) +- Style cohérent avec le bouton Timer existant +- Animation de chargement pendant l'exécution +- Responsive (taille réduite sur mobile) + +### Étape 4 : Émission automatique des événements SSE (Round 3) + +**Problème identifié** : L'interface utilisateur ne se mettait pas à jour après un shuffle car aucun événement `QueueUpdated` n'était émis. + +**Solution implémentée** : Le `MusicRenderer` stocke maintenant une référence optionnelle au `RendererEventBus` et émet automatiquement un événement `QueueUpdated` après chaque modification de la queue. + +**Fichiers modifiés** : + +| Fichier | Modification | +|---------|--------------| +| `pmocontrol/src/music_renderer/musicrenderer.rs` | Ajout du champ `event_bus: Option`, constructeur `from_renderer_info_with_bus()`, méthode helper `emit_queue_updated()`, implémentation manuelle de `Debug` | +| `pmocontrol/src/registry.rs` | Passage du `RendererEventBus` lors de la création des renderers via `from_renderer_info_with_bus()` | + +**Méthodes qui émettent désormais `QueueUpdated`** : +- `enqueue_items()` - Ajout d'items à la queue +- `sync_queue()` - Synchronisation de la queue +- `clear_queue()` - Vidage de la queue +- `replace_queue()` - Remplacement complet de la queue (utilisé par `shuffle_queue()`) +- `play_next_from_queue()` - Passage au morceau suivant +- `play_from_index()` - Lecture à un index spécifique + +### Étape 5 : Refactoring des émissions d'événements (Round 4) + +**Objectif** : Centraliser les émissions d'événements dans le `MusicRenderer` et supprimer les émissions redondantes du `ControlPoint`. + +**Principe** : Puisque le `MusicRenderer` a maintenant accès au `RendererEventBus`, il est plus cohérent et maintenable que les événements soient émis au niveau du renderer plutôt que dispersés dans le `ControlPoint`. + +#### Événements `QueueUpdated` + +**Modifications dans `ControlPoint`** - Suppression des émissions redondantes dans : +- `clear_queue()` +- `enqueue_items_with_mode()` +- `shuffle_queue()` +- `play_next_from_queue()` + +#### Événements `BindingChanged` + +**Modifications dans `MusicRenderer`** : +- `set_playlist_binding()` : Émet `BindingChanged` uniquement si le binding change réellement +- `clear_playlist_binding()` : Émet `BindingChanged` uniquement s'il y avait un binding à supprimer +- Ajout de la méthode helper `emit_binding_changed()` + +**Modifications dans `ControlPoint`** : +- `attach_queue_to_playlist_internal()` : Suppression de l'émission manuelle de `BindingChanged` +- `detach_playlist_binding()` : Suppression de l'émission manuelle, utilisation de `clear_playlist_binding()` au lieu de `set_playlist_binding(None)` + +## Liste complète des fichiers modifiés + +| Fichier | Type de modification | +|---------|---------------------| +| `Cargo.toml` (workspace) | Ajout dépendance `rand` | +| `pmocontrol/Cargo.toml` | Ajout dépendance `rand` | +| `pmocontrol/src/music_renderer/musicrenderer.rs` | Ajout `shuffle_queue()`, `event_bus`, émission d'événements automatique | +| `pmocontrol/src/control_point.rs` | Suppression des émissions d'événements redondantes | +| `pmocontrol/src/registry.rs` | Passage du `RendererEventBus` lors de la création des renderers | +| `pmocontrol/src/pmoserver_ext.rs` | Ajout handler REST `shuffle_queue` | +| `pmocontrol/src/openapi.rs` | Ajout documentation OpenAPI | +| `pmoapp/webapp/src/services/pmocontrol/api.ts` | Ajout méthode API `shuffleQueue()` | +| `pmoapp/webapp/src/components/unified/BottomTabBar.vue` | Intégration du bouton shuffle | + +## Fichiers créés + +| Fichier | Description | +|---------|-------------| +| `pmoapp/webapp/src/components/pmocontrol/ShuffleControl.vue` | Composant Vue.js du bouton shuffle | + +## Notes techniques + +- La méthode `shuffle_queue` détache automatiquement la playlist liée pour éviter que la queue soit écrasée par une mise à jour de la playlist +- Le shuffle utilise `rand::thread_rng()` pour une génération aléatoire de qualité +- L'endpoint REST utilise le même pattern async que les autres commandes de transport (spawn_blocking + timeout) +- Le timeout utilisé est `QUEUE_COMMAND_TIMEOUT` (10 secondes) +- L'émission des événements SSE est automatique via le `RendererEventBus` intégré au `MusicRenderer` +- L'implémentation manuelle de `Debug` pour `MusicRenderer` est nécessaire car `RendererEventBus` n'implémente pas `Debug` +- Les événements ne sont émis que lorsqu'il y a un changement effectif (pas d'événement `BindingChanged` si le binding était déjà `None`) + +## Améliorations architecturales + +Ce travail a posé les bases d'une meilleure architecture où le `MusicRenderer` est responsable de l'émission de ses propres événements. Une tâche de suivi a été créée (`Blackboard/Todo/stateful_music_renderer.md`) pour aller plus loin et rendre le `MusicRenderer` complètement stateful avec son propre thread de surveillance. diff --git a/Blackboard/Rules.md b/Blackboard/Rules.md index ed485317..08ed5eac 100644 --- a/Blackboard/Rules.md +++ b/Blackboard/Rules.md @@ -14,6 +14,46 @@ --- +## Règles Rust (Cargo workspace) + +### Gestion des dépendances + +**⚠️ OBLIGATOIRE** : Les dépendances doivent être ajoutées au niveau **workspace** autant que possible. + +1. Ajouter la dépendance dans `Cargo.toml` racine (section `[workspace.dependencies]`) +2. Référencer avec `{ workspace = true }` dans le `Cargo.toml` de la crate + +**Exemple** : +```toml +# Cargo.toml (racine workspace) +[workspace.dependencies] +rand = "0.9" + +# pmocontrol/Cargo.toml +[dependencies] +rand = { workspace = true } +``` + +**Exceptions** : Dépendances spécifiques à une seule crate avec version très particulière. + +--- + +## Prérequis des tâches + +### Spécification des crates cibles + +**⚠️ CRITIQUE** : Le LLM doit **REFUSER** d'exécuter une tâche si la ou les crates concernées ne sont pas explicitement spécifiées dans le fichier `Todo/{nom}.md`. + +**Informations requises** : +- Nom de la ou des crates à modifier +- Chemin relatif si nécessaire (ex: `pmocontrol/src/...`) + +**En cas d'absence** : +- Le LLM demande clarification à l'humain +- Ne pas deviner ou supposer les crates concernées + +--- + ## Workflow Blackboard ### Structure diff --git a/Blackboard/Todo/shuffle_play.md b/Blackboard/Todo/shuffle_play.md deleted file mode 100644 index e69de29b..00000000 diff --git a/Blackboard/Todo/stateful_music_renderer.md b/Blackboard/Todo/stateful_music_renderer.md new file mode 100644 index 00000000..2ac6bf72 --- /dev/null +++ b/Blackboard/Todo/stateful_music_renderer.md @@ -0,0 +1,154 @@ +# 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, + // Nouveau : état surveillé + watched_state: Arc>, + // Nouveau : handle du thread de surveillance + watcher_handle: Option>, +} + +struct WatchedState { + last_playback_state: Option, + last_position: Option, + last_volume: Option, + last_mute: Option, +} +``` + +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) diff --git a/Cargo.lock b/Cargo.lock index 51b1ef4d..1f868e6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3730,6 +3730,7 @@ dependencies = [ "pmoserver", "pmoupnp", "quick-xml", + "rand 0.9.2", "ratatui", "rust_cast", "rustls", diff --git a/Cargo.toml b/Cargo.toml index a2989378..8d468211 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,7 @@ quick-xml = { version = "0.38", features = ["serialize"] } # ⚠️ Unifier 0.3 chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1.18", features = ["v4"] } crossbeam-channel = "0.5" +rand = "0.9" # Testing -tokio-test = "0.4" \ No newline at end of file +tokio-test = "0.4" diff --git a/pmoapp/webapp/src/components/pmocontrol/ShuffleControl.vue b/pmoapp/webapp/src/components/pmocontrol/ShuffleControl.vue new file mode 100644 index 00000000..d7e559da --- /dev/null +++ b/pmoapp/webapp/src/components/pmocontrol/ShuffleControl.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/pmoapp/webapp/src/components/unified/BottomTabBar.vue b/pmoapp/webapp/src/components/unified/BottomTabBar.vue index 19c648fd..67d616a2 100644 --- a/pmoapp/webapp/src/components/unified/BottomTabBar.vue +++ b/pmoapp/webapp/src/components/unified/BottomTabBar.vue @@ -3,6 +3,7 @@ import { computed } from "vue"; import { Server, Music2 } from "lucide-vue-next"; import StatusBadge from "@/components/pmocontrol/StatusBadge.vue"; import TimerControl from "@/components/pmocontrol/TimerControl.vue"; +import ShuffleControl from "@/components/pmocontrol/ShuffleControl.vue"; import type { RendererSummary, RendererState, @@ -113,8 +114,9 @@ function handleRendererDrawerClick() { - -
+ +
+
@@ -342,6 +344,14 @@ function handleRendererDrawerClick() { border: 1px solid rgba(239, 68, 68, 0.3); } +/* Controls section (shuffle + timer) */ +.controls-section { + display: flex; + align-items: center; + gap: var(--spacing-sm); + flex-shrink: 0; +} + /* Mobile responsive */ @media (max-width: 768px) { .bottom-bar { diff --git a/pmoapp/webapp/src/services/pmocontrol/api.ts b/pmoapp/webapp/src/services/pmocontrol/api.ts index e7311bf0..cded82f7 100644 --- a/pmoapp/webapp/src/services/pmocontrol/api.ts +++ b/pmoapp/webapp/src/services/pmocontrol/api.ts @@ -197,6 +197,19 @@ class PMOControlAPI { ); } + /** + * Mélange la queue de lecture et démarre au premier morceau + * POST /api/control/renderers/{id}/queue/shuffle + */ + async shuffleQueue(id: string): Promise { + return this.request( + `/renderers/${encodeURIComponent(id)}/queue/shuffle`, + { + method: "POST", + }, + ); + } + // ============================================================================ // CONTRÔLE VOLUME // ============================================================================ diff --git a/pmocontrol/Cargo.toml b/pmocontrol/Cargo.toml index b9bc2543..00333d8b 100644 --- a/pmocontrol/Cargo.toml +++ b/pmocontrol/Cargo.toml @@ -25,6 +25,7 @@ futures-util = "0.3" smol = "2.0" serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +rand = { workspace = true } # pmoserver extension support (optional) pmoserver = { path = "../pmoserver", optional = true } diff --git a/pmocontrol/src/control_point.rs b/pmocontrol/src/control_point.rs index c0c6cd4b..155111a1 100644 --- a/pmocontrol/src/control_point.rs +++ b/pmocontrol/src/control_point.rs @@ -657,11 +657,7 @@ impl ControlPoint { "Cleared playback queue" ); - // Emit QueueUpdated event - self.emit_renderer_event(RendererEvent::QueueUpdated { - id: renderer_id.clone(), - queue_length: 0, - }); + // Note: QueueUpdated event is emitted automatically by MusicRenderer::clear_queue() Ok(()) } @@ -711,11 +707,34 @@ impl ControlPoint { "Enqueued playback items" ); - // Emit QueueUpdated event - self.emit_renderer_event(RendererEvent::QueueUpdated { - id: renderer_id.clone(), - queue_length: new_len, - }); + // Note: QueueUpdated event is emitted automatically by MusicRenderer::enqueue_items() + + Ok(()) + } + + /// Shuffles the queue of a renderer and restarts playback from the first track. + /// + /// This method: + /// 1. Detaches the queue from any attached playlist + /// 2. Stops playback + /// 3. Randomizes the order of tracks in the queue + /// 4. Starts playback from the first track + /// + /// Note: QueueUpdated event is emitted automatically by MusicRenderer::shuffle_queue() + /// via its internal call to replace_queue(). + pub fn shuffle_queue(&self, renderer_id: &DeviceId) -> Result<(), ControlPointError> { + let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| { + ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0)) + })?; + + // Perform the shuffle (this also detaches playlist and restarts playback) + renderer.shuffle_queue()?; + + debug!( + renderer = renderer_id.0.as_str(), + queue_len = renderer.len().unwrap_or(0), + "Shuffled playback queue" + ); Ok(()) } @@ -1045,12 +1064,7 @@ impl ControlPoint { // Prefetch next track if supported self.prefetch_next_track(&renderer, renderer_id); - // Emit QueueUpdated event - let queue_length = renderer.len().unwrap_or(0); - self.emit_renderer_event(RendererEvent::QueueUpdated { - id: renderer_id.clone(), - queue_length, - }); + // Note: QueueUpdated event is emitted automatically by MusicRenderer::play_next_from_queue() Ok(()) } @@ -1265,7 +1279,7 @@ impl ControlPoint { ControlPointError::ControlPoint(format!("Renderer {} not found", renderer_id.0)) })?; - renderer.set_playlist_binding(Some(binding.clone())); + renderer.set_playlist_binding(Some(binding)); info!( renderer = renderer_id.0.as_str(), server = server_id.0.as_str(), @@ -1274,10 +1288,7 @@ impl ControlPoint { "Queue attached to playlist container" ); - self.emit_renderer_event(RendererEvent::BindingChanged { - id: renderer_id.clone(), - binding: Some(binding), - }); + // 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| { @@ -1446,10 +1457,13 @@ impl ControlPoint { None => return, }; - let removed = renderer.get_playlist_binding(); - renderer.set_playlist_binding(None); + let had_binding = renderer.get_playlist_binding(); + renderer.clear_playlist_binding(); - if let Some(binding) = removed { + // Note: BindingChanged event is emitted automatically by MusicRenderer::clear_playlist_binding() + // only if there was a binding to remove + + if let Some(binding) = had_binding { info!( renderer = renderer_id.0.as_str(), server = binding.server_id.0.as_str(), @@ -1457,10 +1471,6 @@ impl ControlPoint { reason = reason, "Playlist binding detached" ); - self.emit_renderer_event(RendererEvent::BindingChanged { - id: renderer_id.clone(), - binding: None, - }); } else { debug!( renderer = renderer_id.0.as_str(), diff --git a/pmocontrol/src/music_renderer/musicrenderer.rs b/pmocontrol/src/music_renderer/musicrenderer.rs index 25b28f45..0b7b6fcf 100644 --- a/pmocontrol/src/music_renderer/musicrenderer.rs +++ b/pmocontrol/src/music_renderer/musicrenderer.rs @@ -10,6 +10,8 @@ use std::sync::{Arc, Mutex}; use std::time::SystemTime; use crate::errors::ControlPointError; +use crate::events::RendererEventBus; +use crate::model::RendererEvent; use crate::model::{PlaybackSource, PlaybackState, RendererInfo, RendererProtocol, TrackMetadata}; use crate::music_renderer::RendererFromMediaRendererInfo; use crate::music_renderer::arylic_tcp::ArylicTcpRenderer; @@ -83,13 +85,31 @@ struct MusicRendererState { sleep_timer: SleepTimer, } -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct MusicRenderer { info: RendererInfo, connection: Arc>, backend: Arc>, playlist_binding: Arc>>, state: Arc>, + /// Optional event bus for emitting queue change events. + event_bus: Option, +} + +impl std::fmt::Debug for MusicRenderer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MusicRenderer") + .field("info", &self.info) + .field("connection", &self.connection) + .field("backend", &self.backend) + .field("playlist_binding", &self.playlist_binding) + .field("state", &self.state) + .field( + "event_bus", + &self.event_bus.as_ref().map(|_| "RendererEventBus"), + ) + .finish() + } } impl MusicRenderer { @@ -105,12 +125,20 @@ impl MusicRenderer { backend, playlist_binding: Arc::new(Mutex::new(None)), state: Arc::new(Mutex::new(MusicRendererState::default())), + event_bus: None, }; Arc::new(renderer) } pub fn from_renderer_info(info: &RendererInfo) -> Result { + Self::from_renderer_info_with_bus(info, None) + } + + pub fn from_renderer_info_with_bus( + info: &RendererInfo, + event_bus: Option, + ) -> Result { let connection = Arc::new(Mutex::new(DeviceConnectionState::new())); let backend = MusicRendererBackend::make_from_renderer_info(info)?; @@ -120,10 +148,22 @@ impl MusicRenderer { backend, playlist_binding: Arc::new(Mutex::new(None)), state: Arc::new(Mutex::new(MusicRendererState::default())), + event_bus, }; Ok(renderer) } + /// Helper method to emit a QueueUpdated event if an event bus is available. + fn emit_queue_updated(&self) { + if let Some(ref bus) = self.event_bus { + let queue_length = self.len().unwrap_or(0); + bus.broadcast(RendererEvent::QueueUpdated { + id: self.id(), + queue_length, + }); + } + } + pub fn info(&self) -> &RendererInfo { &self.info } @@ -255,7 +295,9 @@ impl MusicRenderer { self.backend .lock() .expect("Backend mutex poisoned") - .play_next() + .play_next()?; + self.emit_queue_updated(); + Ok(()) } /// Play from a specific index in the queue. @@ -263,7 +305,9 @@ impl MusicRenderer { self.backend .lock() .expect("Backend mutex poisoned") - .play_from_index(index) + .play_from_index(index)?; + self.emit_queue_updated(); + Ok(()) } /// Transport control: play @@ -375,11 +419,24 @@ impl MusicRenderer { } /// Sets the playlist binding for this renderer. + /// Emits a `BindingChanged` event only if the binding actually changes. pub fn set_playlist_binding(&self, binding: Option) { - *self + let mut guard = self .playlist_binding .lock() - .expect("Playlist binding mutex poisoned") = binding; + .expect("Playlist binding mutex poisoned"); + + // Check if there's an actual change (both None, or different Some values) + let old_is_some = guard.is_some(); + let new_is_some = binding.is_some(); + let has_changed = old_is_some != new_is_some || (old_is_some && new_is_some); + + *guard = binding.clone(); + drop(guard); + + if has_changed { + self.emit_binding_changed(binding); + } } /// Gets the current playlist binding, if any. @@ -391,11 +448,30 @@ impl MusicRenderer { } /// Clears the playlist binding. + /// Emits a `BindingChanged` event with `None` only if there was a binding to clear. pub fn clear_playlist_binding(&self) { - *self + let mut guard = self .playlist_binding .lock() - .expect("Playlist binding mutex poisoned") = None; + .expect("Playlist binding mutex poisoned"); + + let had_binding = guard.is_some(); + *guard = None; + drop(guard); + + if had_binding { + self.emit_binding_changed(None); + } + } + + /// Helper method to emit a BindingChanged event if an event bus is available. + fn emit_binding_changed(&self, binding: Option) { + if let Some(ref bus) = self.event_bus { + bus.broadcast(RendererEvent::BindingChanged { + id: self.id(), + binding, + }); + } } /// Marks the current playlist binding for refresh if it matches the given server and container. @@ -483,7 +559,10 @@ impl MusicRenderer { mode: EnqueueMode, ) -> Result<(), ControlPointError> { let mut backend = self.backend.lock().expect("Backend mutex poisoned"); - backend.enqueue_items(items, mode) + backend.enqueue_items(items, mode)?; + drop(backend); + self.emit_queue_updated(); + Ok(()) } /// Synchronize the queue with new items while preserving the current track. @@ -494,7 +573,10 @@ impl MusicRenderer { /// - If there's no current track, the queue is simply replaced pub fn sync_queue(&self, items: Vec) -> Result<(), ControlPointError> { let mut backend = self.backend.lock().expect("Backend mutex poisoned"); - backend.sync_queue(items) + backend.sync_queue(items)?; + drop(backend); + self.emit_queue_updated(); + Ok(()) } /// Set the current queue index (for advanced use). @@ -507,7 +589,10 @@ impl MusicRenderer { /// Clears the renderer's queue using the generic QueueBackend trait. pub fn clear_queue(&self) -> Result<(), ControlPointError> { let mut backend = self.backend.lock().expect("Backend mutex poisoned"); - backend.clear_queue() + backend.clear_queue()?; + drop(backend); + self.emit_queue_updated(); + Ok(()) } /// Dequeues and returns the next item from the queue. @@ -530,7 +615,10 @@ impl MusicRenderer { current_index: Option, ) -> Result<(), ControlPointError> { let mut backend = self.backend.lock().expect("Backend mutex poisoned"); - backend.replace_queue(items, current_index) + backend.replace_queue(items, current_index)?; + drop(backend); + self.emit_queue_updated(); + Ok(()) } /// Adds a track to the queue. @@ -726,6 +814,53 @@ impl MusicRenderer { state.sleep_timer.remaining_seconds(), ) } + + // --- Queue Shuffle --- + + /// Shuffles the current queue and restarts playback from the first track. + /// + /// This method: + /// 1. Detaches the queue from any attached playlist + /// 2. Stops playback + /// 3. Takes a snapshot of the current queue + /// 4. Randomizes the order of tracks + /// 5. Replaces the queue with the shuffled items + /// 6. Starts playback from the first track + /// + /// # Errors + /// Returns an error if the queue is empty or if any backend operation fails. + pub fn shuffle_queue(&self) -> Result<(), ControlPointError> { + use rand::seq::SliceRandom; + use rand::thread_rng; + + // 1. Clear the playlist binding (detach from playlist) + self.clear_playlist_binding(); + + // 2. Stop playback (ignore errors if already stopped) + let _ = self.stop(); + + // 3. Get a snapshot of the current queue + let snapshot = self.queue_snapshot()?; + + if snapshot.items.is_empty() { + return Err(ControlPointError::QueueError( + "Cannot shuffle an empty queue".to_string(), + )); + } + + // 4. Shuffle the items + let mut shuffled_items = snapshot.items; + let mut rng = thread_rng(); + shuffled_items.shuffle(&mut rng); + + // 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)?; + + Ok(()) + } } /// Helper function to build DIDL-Lite metadata XML from TrackMetadata diff --git a/pmocontrol/src/openapi.rs b/pmocontrol/src/openapi.rs index 1176c6aa..11c468f0 100644 --- a/pmocontrol/src/openapi.rs +++ b/pmocontrol/src/openapi.rs @@ -389,6 +389,11 @@ POST /control/renderers/{renderer_id}/queue/seek Body: {"index": 5} ``` +### Mélanger la queue +``` +POST /control/renderers/{renderer_id}/queue/shuffle +``` + ### Contrôler le volume ``` POST /control/renderers/{renderer_id}/volume/set @@ -442,6 +447,7 @@ GET /control/servers/{server_id}/containers/{container_id} crate::pmoserver_ext::next_renderer, crate::pmoserver_ext::seek_renderer, crate::pmoserver_ext::seek_queue_index, + crate::pmoserver_ext::shuffle_queue, crate::pmoserver_ext::set_renderer_volume, crate::pmoserver_ext::volume_up_renderer, crate::pmoserver_ext::volume_down_renderer, diff --git a/pmocontrol/src/pmoserver_ext.rs b/pmocontrol/src/pmoserver_ext.rs index 8da92766..f577595f 100644 --- a/pmocontrol/src/pmoserver_ext.rs +++ b/pmocontrol/src/pmoserver_ext.rs @@ -1333,6 +1333,100 @@ async fn get_sleep_timer_state( })) } +// ============================================================================ +// HANDLERS - QUEUE SHUFFLE +// ============================================================================ + +/// POST /control/renderers/{renderer_id}/queue/shuffle - Mélange la queue de lecture +#[cfg(feature = "pmoserver")] +#[utoipa::path( + post, + path = "/renderers/{renderer_id}/queue/shuffle", + params( + ("renderer_id" = String, Path, description = "ID unique du renderer") + ), + responses( + (status = 200, description = "Queue mélangée et lecture démarrée", body = SuccessResponse), + (status = 404, description = "Renderer non trouvé", body = ErrorResponse), + (status = 400, description = "Queue vide", body = ErrorResponse), + (status = 504, description = "Timeout de la commande", body = ErrorResponse), + (status = 500, description = "Erreur lors de l'exécution", body = ErrorResponse) + ), + tag = "control" +)] +async fn shuffle_queue( + State(state): State, + Path(renderer_id): Path, +) -> Result, (StatusCode, Json)> { + let rid = DeviceId(renderer_id.clone()); + + // Verify renderer exists + state + .control_point + .music_renderer_by_id(&rid) + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!("Renderer {} not found", renderer_id), + }), + ) + })?; + + let control_point = Arc::clone(&state.control_point); + let rid_for_task = rid.clone(); + let shuffle_task = + tokio::task::spawn_blocking(move || control_point.shuffle_queue(&rid_for_task)); + + time::timeout(QUEUE_COMMAND_TIMEOUT, shuffle_task) + .await + .map_err(|_| { + warn!( + "Shuffle command for renderer {} exceeded {:?}", + renderer_id, QUEUE_COMMAND_TIMEOUT + ); + ( + StatusCode::GATEWAY_TIMEOUT, + Json(ErrorResponse { + error: format!( + "Shuffle command timed out after {}s", + QUEUE_COMMAND_TIMEOUT.as_secs() + ), + }), + ) + })? + .map_err(|e| { + warn!("Task join error during shuffle: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Internal task error: {}", e), + }), + ) + })? + .map_err(|e| { + warn!( + "Failed to shuffle queue for renderer {}: {}", + renderer_id, e + ); + ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: format!("Failed to shuffle queue: {}", e), + }), + ) + })?; + + debug!( + renderer = renderer_id.as_str(), + "Queue shuffled via HTTP API" + ); + + Ok(Json(SuccessResponse { + message: "Queue shuffled and playback started".to_string(), + })) +} + // ============================================================================ // HANDLERS - BINDING PLAYLIST // ============================================================================ @@ -2140,6 +2234,10 @@ pub fn create_api_router(state: ControlPointState, control_point: Arc on l'ajoute - if let Ok(new_renderer) = MusicRenderer::from_renderer_info(info) { + if let Ok(new_renderer) = + MusicRenderer::from_renderer_info_with_bus(info, Some(self.renderer_bus.clone())) + { entry.music_renderer = Some(Arc::new(new_renderer)); self.udn_index .insert(info.udn().to_ascii_lowercase(), device_id.clone()); @@ -188,7 +190,9 @@ impl DeviceRegistry { } } else { // Entry n'existe pas -> on crée - if let Ok(new_renderer) = MusicRenderer::from_renderer_info(info) { + if let Ok(new_renderer) = + MusicRenderer::from_renderer_info_with_bus(info, Some(self.renderer_bus.clone())) + { let new_entry = DeviceItem { music_renderer: Some(Arc::new(new_renderer)), music_server: None,