From 58ac28489c1401bf76176ecaea2a61309fe222ec Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Fri, 9 Jan 2026 19:59:04 +0100 Subject: [PATCH] Optimize UPnP discovery and server response handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor UPnP discovery to use a thread pool for fetching device descriptions, improving performance and responsiveness. Also, use spawn_blocking in server endpoints to prevent blocking the async runtime when performing sync operations. Additionally, add read timeout to SSDP socket and handle WouldBlock errors gracefully. Refactorisation du contrôle des renderers et amélioration de l'interface utilisateur Refactorisation complète du composant VolumeControl avec gestion d'erreur améliorée et debounce. Migration de l'interface de contrôle des renderers : - Suppression de l'ancienne barre d'onglets en bas - Intégration d'une nouvelle barre d'infos en bas avec les détails du renderer actif - Création d'un nouveau drawer pour la sélection des renderers - Déplacement des informations du renderer de l'onglet vers la barre d'infos Améliorations UI/UX : - Nouvelle interface de contrôle des renderers dans le drawer avec boutons de lecture/pause - Mise à jour des styles et animations pour une meilleure expérience utilisateur - Adaptation responsive pour les appareils mobiles --- .../components/pmocontrol/VolumeControl.vue | 380 ++++----- .../src/components/unified/BottomTabBar.vue | 725 ++++++++--------- .../src/components/unified/RendererDrawer.vue | 762 ++++++++++++++++++ .../components/unified/RendererTabContent.vue | 174 +--- .../webapp/src/views/UnifiedControlView.vue | 54 +- pmocontrol/src/discovery/upnp_discovery.rs | 69 +- pmocontrol/src/pmoserver_ext.rs | 83 +- pmoupnp/src/ssdp/server.rs | 5 + 8 files changed, 1452 insertions(+), 800 deletions(-) create mode 100644 pmoapp/webapp/src/components/unified/RendererDrawer.vue diff --git a/pmoapp/webapp/src/components/pmocontrol/VolumeControl.vue b/pmoapp/webapp/src/components/pmocontrol/VolumeControl.vue index 05b3f867..d7787592 100644 --- a/pmoapp/webapp/src/components/pmocontrol/VolumeControl.vue +++ b/pmoapp/webapp/src/components/pmocontrol/VolumeControl.vue @@ -1,286 +1,292 @@ diff --git a/pmoapp/webapp/src/components/unified/BottomTabBar.vue b/pmoapp/webapp/src/components/unified/BottomTabBar.vue index 3dfec76a..4d617c0f 100644 --- a/pmoapp/webapp/src/components/unified/BottomTabBar.vue +++ b/pmoapp/webapp/src/components/unified/BottomTabBar.vue @@ -1,452 +1,377 @@ diff --git a/pmoapp/webapp/src/components/unified/RendererDrawer.vue b/pmoapp/webapp/src/components/unified/RendererDrawer.vue new file mode 100644 index 00000000..763c93e5 --- /dev/null +++ b/pmoapp/webapp/src/components/unified/RendererDrawer.vue @@ -0,0 +1,762 @@ + + + + + diff --git a/pmoapp/webapp/src/components/unified/RendererTabContent.vue b/pmoapp/webapp/src/components/unified/RendererTabContent.vue index 804eac38..12950107 100644 --- a/pmoapp/webapp/src/components/unified/RendererTabContent.vue +++ b/pmoapp/webapp/src/components/unified/RendererTabContent.vue @@ -6,9 +6,7 @@ import CurrentTrack from "@/components/pmocontrol/CurrentTrack.vue"; import TransportControls from "@/components/pmocontrol/TransportControls.vue"; import VolumeControl from "@/components/pmocontrol/VolumeControl.vue"; import QueueViewer from "@/components/pmocontrol/QueueViewer.vue"; -import StatusBadge from "@/components/pmocontrol/StatusBadge.vue"; -import { ChevronUp, ChevronDown, Link } from "lucide-vue-next"; -import { useRenderers } from "@/composables/useRenderers"; +import { ChevronUp, ChevronDown } from "lucide-vue-next"; import { useUIStore } from "@/stores/ui"; import { api } from "@/services/pmocontrol/api"; import type { QueueItem } from "@/services/pmocontrol/types"; @@ -17,10 +15,9 @@ const props = defineProps<{ rendererId: string; }>(); -const { renderer, state, queue, binding, refresh } = useRenderer( +const { renderer, state, queue, refresh } = useRenderer( toRef(props, "rendererId"), ); -const { detachPlaylist } = useRenderers(); const uiStore = useUIStore(); // Détection mobile portrait pour afficher le drawer au lieu de la colonne @@ -42,22 +39,6 @@ onMounted(async () => { // État du renderer pour affichage const isOnline = computed(() => renderer.value?.online ?? false); -const transportState = computed( - () => state.value?.transport_state ?? "STOPPED", -); -const hasPlaylistBinding = computed(() => !!binding.value); - -// Détacher la playlist -async function handleDetachPlaylist() { - try { - await detachPlaylist(props.rendererId); - uiStore.notifySuccess("Playlist détachée"); - } catch (error) { - uiStore.notifyError( - `Erreur: ${error instanceof Error ? error.message : "Erreur inconnue"}`, - ); - } -} // Gérer le clic sur un item de la queue async function handleQueueItemClick(item: QueueItem) { @@ -86,42 +67,7 @@ async function handleQueueItemClick(item: QueueItem) { diff --git a/pmocontrol/src/discovery/upnp_discovery.rs b/pmocontrol/src/discovery/upnp_discovery.rs index b39c52cf..39571819 100644 --- a/pmocontrol/src/discovery/upnp_discovery.rs +++ b/pmocontrol/src/discovery/upnp_discovery.rs @@ -1,14 +1,25 @@ use crate::{DeviceRegistry, discovery::upnp_provider::ParsedDeviceDescription}; +use crossbeam_channel::{Sender, bounded}; use pmoupnp::ssdp::SsdpEvent; use std::sync::{Arc, Mutex, RwLock}; +use std::thread; use crate::discovery::manager::UDNRegistry; -/// Gestionnaire des événements SSDP -> DeviceUpdate. +/// Task to fetch a device description +struct FetchTask { + udn: String, + location: String, + server_header: String, + max_age: u32, + registry: Arc>, +} +/// Gestionnaire des événements SSDP -> DeviceUpdate. pub struct UpnpDiscoveryManager { device_registry: Arc>, udn_cache: Arc>, + fetch_sender: Sender, } impl UpnpDiscoveryManager { @@ -16,9 +27,39 @@ impl UpnpDiscoveryManager { device_registry: Arc>, udn_cache: Arc>, ) -> Self { + // Create a bounded channel for fetch tasks (max 10 pending tasks) + let (sender, receiver) = bounded::(10); + + // Spawn a pool of 3 worker threads to process fetch tasks + for _ in 0..3 { + let receiver = receiver.clone(); + thread::spawn(move || { + while let Ok(task) = receiver.recv() { + // Fetch + parse the device description (may take up to 5 seconds) + if let Ok(info) = ParsedDeviceDescription::new( + &task.udn, + &task.location, + &task.server_header, + 5, + ) { + if let Some(renderer_info) = info.build_renderer() { + if let Ok(mut reg) = task.registry.write() { + reg.push_renderer(&renderer_info, task.max_age); + } + } else if let Some(server_info) = info.build_server() { + if let Ok(mut reg) = task.registry.write() { + reg.push_server(&server_info, task.max_age); + } + } + } + } + }); + } + Self { device_registry, udn_cache, + fetch_sender: sender, } } @@ -48,20 +89,18 @@ impl UpnpDiscoveryManager { UDNRegistry::should_fetch(self.udn_cache.clone(), &udn, max_age as u64); if should_fetch { - // Fetch + parse the device description - if let Ok(info) = - ParsedDeviceDescription::new(&udn, &location, &server_header, 5) - { - if let Some(renderer_info) = info.build_renderer() { - if let Ok(mut reg) = self.device_registry.write() { - reg.push_renderer(&renderer_info, max_age); - } - } else if let Some(server_info) = info.build_server() { - if let Ok(mut reg) = self.device_registry.write() { - reg.push_server(&server_info, max_age); - } - } - } + // Send fetch task to worker pool (non-blocking) + // If the channel is full, try_send will fail and we skip this fetch + let task = FetchTask { + udn: udn.clone(), + location: location.clone(), + server_header: server_header.clone(), + max_age, + registry: Arc::clone(&self.device_registry), + }; + + // Use try_send to avoid blocking if the queue is full + let _ = self.fetch_sender.try_send(task); } else { // Even if we don't fetch, we MUST update last_seen to prevent timeout // This is critical: SSDP Alive messages arrive more frequently than max_age/2, diff --git a/pmocontrol/src/pmoserver_ext.rs b/pmocontrol/src/pmoserver_ext.rs index 8329cc52..8bbb8906 100644 --- a/pmocontrol/src/pmoserver_ext.rs +++ b/pmocontrol/src/pmoserver_ext.rs @@ -91,22 +91,29 @@ impl ControlPointState { tag = "control" )] async fn list_renderers(State(state): State) -> Json> { - let renderers = state.control_point.list_music_renderers(); + // Use spawn_blocking to avoid blocking the tokio runtime + // This is critical because list_music_renderers acquires a RwLock + let control_point = state.control_point.clone(); + let summaries = tokio::task::spawn_blocking(move || { + let renderers = control_point.list_music_renderers(); - let summaries: Vec = renderers - .into_iter() - .map(|r| { - let info = r.info(); - RendererSummary { - id: r.id().0.clone(), - friendly_name: r.friendly_name().to_string(), - model_name: r.model_name().to_string(), - protocol: protocol_summary(&info.protocol()), - capabilities: capability_summary(&info.capabilities()), - online: r.is_online(), - } - }) - .collect(); + renderers + .into_iter() + .map(|r| { + let info = r.info(); + RendererSummary { + id: r.id().0.clone(), + friendly_name: r.friendly_name().to_string(), + model_name: r.model_name().to_string(), + protocol: protocol_summary(&info.protocol()), + capabilities: capability_summary(&info.capabilities()), + online: r.is_online(), + } + }) + .collect::>() + }) + .await + .unwrap_or_default(); Json(summaries) } @@ -156,10 +163,22 @@ async fn get_renderer_full_snapshot( Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { let rid = DeviceId(renderer_id.clone()); - let snapshot = state - .control_point - .renderer_full_snapshot(&rid) - .map_err(|err| map_snapshot_error(renderer_id, err))?; + + // Use spawn_blocking because renderer_full_snapshot does sync UPnP calls + let control_point = state.control_point.clone(); + let rid_clone = rid.clone(); + let snapshot = + tokio::task::spawn_blocking(move || control_point.renderer_full_snapshot(&rid_clone)) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Task error: {}", e), + }), + ) + })? + .map_err(|err| map_snapshot_error(renderer_id, err))?; Ok(Json(snapshot)) } @@ -1519,17 +1538,23 @@ async fn add_after_current( tag = "control" )] async fn list_servers(State(state): State) -> Json> { - let servers = state.control_point.list_media_servers().unwrap_or_default(); + // Use spawn_blocking to avoid blocking the tokio runtime + let control_point = state.control_point.clone(); + let summaries = tokio::task::spawn_blocking(move || { + let servers = control_point.list_media_servers().unwrap_or_default(); - let summaries: Vec = servers - .into_iter() - .map(|s| MediaServerSummary { - id: s.id().0.clone(), - friendly_name: s.friendly_name().to_string(), - model_name: s.model_name().to_string(), - online: s.is_online(), - }) - .collect(); + servers + .into_iter() + .map(|s| MediaServerSummary { + id: s.id().0.clone(), + friendly_name: s.friendly_name().to_string(), + model_name: s.model_name().to_string(), + online: s.is_online(), + }) + .collect::>() + }) + .await + .unwrap_or_default(); Json(summaries) } diff --git a/pmoupnp/src/ssdp/server.rs b/pmoupnp/src/ssdp/server.rs index e07d9d0f..4ec816fe 100644 --- a/pmoupnp/src/ssdp/server.rs +++ b/pmoupnp/src/ssdp/server.rs @@ -83,6 +83,7 @@ impl SsdpServer { &"0.0.0.0".parse().unwrap(), )?; + socket.set_read_timeout(Some(Duration::from_secs(1)))?; socket.set_multicast_loop_v4(false)?; let socket = Arc::new(socket); @@ -274,6 +275,10 @@ impl SsdpServer { } } } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + // Timeout, continuer + continue; + } Err(e) => { warn!("❌ SSDP read error: {}", e); }