@@ -198,114 +144,13 @@ async function handleQueueItemClick(item: QueueItem) {
overflow: hidden;
}
-/* Header */
-.renderer-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: var(--spacing-md) var(--spacing-lg);
- background: rgba(255, 255, 255, 0.05);
- backdrop-filter: blur(10px);
- -webkit-backdrop-filter: blur(10px);
- border-bottom: 1px solid rgba(255, 255, 255, 0.1);
- flex-shrink: 0;
-}
-
-.header-info {
- flex: 1;
-}
-
-.renderer-name {
- font-size: var(--text-2xl);
- font-weight: 700;
- color: var(--color-text);
- margin: 0;
-}
-
-.renderer-model {
- font-size: var(--text-sm);
- color: var(--color-text-secondary);
- margin: 4px 0 0 0;
-}
-
-.header-badges {
- display: flex;
- align-items: center;
- gap: var(--spacing-sm);
-}
-
-.protocol-badge,
-.offline-badge {
- padding: 4px 12px;
- border-radius: var(--radius-sm);
- font-size: 12px;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.5px;
-}
-
-.protocol-badge {
- background: rgba(255, 255, 255, 0.1);
- color: var(--color-text-secondary);
- border: 1px solid rgba(255, 255, 255, 0.2);
-}
-
-.offline-badge {
- background: var(--status-offline);
- color: white;
-}
-
-/* Badge playlist binding compact */
-.playlist-badge {
- position: relative;
- display: inline-flex;
-}
-
-.playlist-badge-btn {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 32px;
- height: 32px;
- padding: 0;
- background: rgba(102, 126, 234, 0.2);
- backdrop-filter: blur(10px);
- -webkit-backdrop-filter: blur(10px);
- border: 1px solid rgba(102, 126, 234, 0.4);
- border-radius: 50%;
- cursor: pointer;
- transition: all 0.3s ease;
- color: rgba(102, 126, 234, 1);
-}
-
-.playlist-badge-btn:hover {
- background: rgba(102, 126, 234, 0.3);
- border-color: rgba(102, 126, 234, 0.6);
- transform: scale(1.1);
-}
-
-.playlist-badge-btn:active {
- transform: scale(1);
-}
-
-@media (prefers-color-scheme: dark) {
- .playlist-badge-btn {
- background: rgba(102, 126, 234, 0.15);
- border-color: rgba(102, 126, 234, 0.3);
- }
-
- .playlist-badge-btn:hover {
- background: rgba(102, 126, 234, 0.25);
- border-color: rgba(102, 126, 234, 0.5);
- }
-}
-
/* Layout principal - 800x600 landscape (2 colonnes) */
.renderer-layout {
display: grid;
grid-template-columns: 300px 1fr;
gap: var(--spacing-lg);
- padding: var(--spacing-lg);
+ padding: var(--spacing-lg) 0 var(--spacing-lg) var(--spacing-lg);
+ /* padding-right: 0 pour coller la scrollbar au bord */
flex: 1;
overflow: hidden;
}
@@ -316,7 +161,7 @@ async function handleQueueItemClick(item: QueueItem) {
flex-direction: column;
gap: var(--spacing-lg);
overflow-y: auto;
- padding-right: var(--spacing-sm);
+ padding-right: 16px; /* Dégager la scrollbar */
}
.current-track-section,
@@ -373,10 +218,8 @@ async function handleQueueItemClick(item: QueueItem) {
.renderer-layout {
grid-template-columns: 1fr;
gap: var(--spacing-md);
- padding: var(--spacing-md);
- padding-bottom: var(
- --spacing-xl
- ); /* Espace pour éviter que la tab bar cache les contrôles */
+ padding: var(--spacing-md) 0 var(--spacing-xl) var(--spacing-md);
+ /* padding-right: 0 pour coller la scrollbar au bord */
}
/* Queue drawer visible sur mobile (géré par v-if maintenant) */
@@ -445,7 +288,6 @@ async function handleQueueItemClick(item: QueueItem) {
}
.controls-column {
- padding-right: 0;
padding-bottom: 100px; /* Espace supplémentaire pour éviter que la tab bar (64px) cache le volume */
}
}
diff --git a/pmoapp/webapp/src/views/UnifiedControlView.vue b/pmoapp/webapp/src/views/UnifiedControlView.vue
index 4619bc5b..9d2ee419 100644
--- a/pmoapp/webapp/src/views/UnifiedControlView.vue
+++ b/pmoapp/webapp/src/views/UnifiedControlView.vue
@@ -10,6 +10,7 @@ import { useSwipe } from "@vueuse/core";
import BottomTabBar from "@/components/unified/BottomTabBar.vue";
import EmptyState from "@/components/unified/EmptyState.vue";
import ServerDrawer from "@/components/unified/ServerDrawer.vue";
+import RendererDrawer from "@/components/unified/RendererDrawer.vue";
import RendererTabContent from "@/components/unified/RendererTabContent.vue";
import ServerTabContent from "@/components/unified/ServerTabContent.vue";
@@ -17,11 +18,12 @@ const route = useRoute();
const router = useRouter();
const { tabs, activeTabId, switchTab, activeTab, syncWithRenderers, isEmpty } =
useTabs();
-const { allRenderers, fetchRenderers } = useRenderers();
+const { allRenderers, fetchRenderers, getStateById } = useRenderers();
const { allServers, fetchServers } = useMediaServers();
-// État du drawer server
+// État des drawers
const drawerOpen = ref(false);
+const rendererDrawerOpen = ref(false);
// Ref pour le swipe edge detection
const viewRef = ref(null);
@@ -50,16 +52,51 @@ const currentRendererId = computed(() => {
return null;
});
+// Récupérer le renderer actif
+const activeRenderer = computed(() => {
+ const rendererId = currentRendererId.value;
+ if (!rendererId) return null;
+ return allRenderers.value.find((r) => r.id === rendererId) || null;
+});
+
+// Récupérer l'état du renderer actif
+const activeRendererState = computed(() => {
+ const rendererId = currentRendererId.value;
+ if (!rendererId) return null;
+ return getStateById(rendererId);
+});
+
// Nombre de servers online pour afficher dans le badge
const onlineServersCount = computed(
() => allServers.value.filter((s) => s.online).length,
);
+// Nombre de renderers online pour afficher dans le badge
+const onlineRenderersCount = computed(
+ () => allRenderers.value.filter((r) => r.online).length,
+);
+
// Gestion de l'ouverture du drawer depuis le bouton
function handleDrawerOpen() {
drawerOpen.value = true;
}
+// Gestion de l'ouverture du renderer drawer depuis le bouton
+function handleRendererDrawerOpen() {
+ rendererDrawerOpen.value = true;
+}
+
+// Gestion de la sélection d'un renderer depuis le drawer
+function handleRendererSelect(rendererId: string) {
+ // Chercher le tab correspondant à ce renderer ou le créer
+ const existingTab = tabs.value.find(
+ (t) => t.type === "renderer" && t.metadata?.rendererId === rendererId,
+ );
+ if (existingTab) {
+ switchTab(existingTab.id);
+ }
+}
+
// Sync route query params avec l'état des tabs
onMounted(async () => {
// Fetch renderers et servers au montage
@@ -162,10 +199,14 @@ const currentTabProps = computed(() => {
-
+
@@ -173,6 +214,13 @@ const currentTabProps = computed(() => {
v-model="drawerOpen"
:selected-renderer-id="currentRendererId"
/>
+
+
+
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);
}