From d708bb6035681d5a99ee8536c48f4f1cb359101e Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Thu, 8 Jan 2026 23:46:39 +0100 Subject: [PATCH 1/6] Remove debug traces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supprime les traces de débogage (tracing::warn) inutiles dans les fichiers principaux du projet. - PMOMusic/src/main.rs - pmomediaserver/src/paradise_streaming.rs - pmoupnp/src/upnp_server.rs Ces traces étaient utilisées pour le débogage pendant le développement et ne sont plus nécessaires dans la version finale. --- PMOMusic/src/main.rs | 6 ------ pmomediaserver/src/paradise_streaming.rs | 7 ------- pmoupnp/src/upnp_server.rs | 9 --------- 3 files changed, 22 deletions(-) diff --git a/PMOMusic/src/main.rs b/PMOMusic/src/main.rs index e2dc910a..d2c4c9bd 100644 --- a/PMOMusic/src/main.rs +++ b/PMOMusic/src/main.rs @@ -71,22 +71,16 @@ async fn main() -> Result<(), Box> { .await .expect("Failed to register MediaRenderer"); - tracing::warn!("🔍 DEBUG: MediaRenderer registered, getting base_url..."); let base_url = renderer_instance.base_url(); - tracing::warn!("🔍 DEBUG: Got base_url, getting description_route..."); let desc_route = renderer_instance.description_route(); - tracing::warn!("🔍 DEBUG: Got description_route, logging..."); info!("✅ MediaRenderer ready at {}{}", base_url, desc_route); - tracing::warn!("🔍 DEBUG: Log complete!"); - tracing::warn!("🔍 DEBUG: About to register MediaServer..."); let server_instance = server .write() .await .register_device(MEDIA_SERVER.clone()) .await .expect("Failed to register MediaServer"); - tracing::warn!("🔍 DEBUG: MediaServer registered successfully"); // Enregistrer l'instance ContentDirectory pour les notifications GENA if let Some(cd_service) = server_instance.get_service("ContentDirectory") { diff --git a/pmomediaserver/src/paradise_streaming.rs b/pmomediaserver/src/paradise_streaming.rs index 160a9d66..8e8b6a97 100644 --- a/pmomediaserver/src/paradise_streaming.rs +++ b/pmomediaserver/src/paradise_streaming.rs @@ -75,7 +75,6 @@ impl ParadiseStreamingExt for pmoserver::Server { // Récupérer ou initialiser les caches singletons info!("📦 Getting cache singletons..."); - tracing::warn!("🔍 DEBUG: About to check get_cover_cache()"); let cover_cache = match get_cover_cache() { Some(cache) => { info!(" ✅ Using existing cover cache singleton"); @@ -83,17 +82,14 @@ impl ParadiseStreamingExt for pmoserver::Server { } None => { info!(" 📦 Initializing new cover cache singleton"); - tracing::warn!("🔍 DEBUG: About to call init_cover_cache_configured()"); let cache = self .init_cover_cache_configured() .await .context("Failed to initialize cover cache")?; - tracing::warn!("🔍 DEBUG: init_cover_cache_configured() completed"); register_cover_cache(cache.clone()); cache } }; - tracing::warn!("🔍 DEBUG: Cover cache ready, checking audio cache..."); let audio_cache = match get_audio_cache() { Some(cache) => { @@ -104,18 +100,15 @@ impl ParadiseStreamingExt for pmoserver::Server { } None => { info!(" 📦 Initializing new audio cache singleton"); - tracing::warn!("🔍 DEBUG: About to call init_audio_cache_configured()"); let cache = self .init_audio_cache_configured() .await .context("Failed to initialize audio cache")?; - tracing::warn!("🔍 DEBUG: init_audio_cache_configured() completed"); register_audio_cache(cache.clone()); register_playlist_audio_cache(cache.clone()); cache } }; - tracing::warn!("🔍 DEBUG: Audio cache ready, creating ParadiseChannelManager..."); // Créer le builder d'historique let mut history_builder = ParadiseHistoryBuilder::default(); diff --git a/pmoupnp/src/upnp_server.rs b/pmoupnp/src/upnp_server.rs index 333b12e5..9ebe2d0f 100644 --- a/pmoupnp/src/upnp_server.rs +++ b/pmoupnp/src/upnp_server.rs @@ -250,26 +250,19 @@ impl UpnpServerExt for Server { } // Enregistrer les URLs dans le serveur web - tracing::warn!("🔍 DEBUG: About to register_urls for device {}", di.udn()); di.register_urls(self).await?; - tracing::warn!("🔍 DEBUG: register_urls completed for device {}", di.udn()); // Ajouter au registre pour l'introspection - tracing::warn!("🔍 DEBUG: Adding to DEVICE_REGISTRY..."); DEVICE_REGISTRY .write() .unwrap() .register(di.clone()) .map_err(|e| DeviceError::UrlRegistrationError(e))?; - tracing::warn!("🔍 DEBUG: Added to DEVICE_REGISTRY"); // Annoncer via SSDP (si initialisé) - tracing::warn!("🔍 DEBUG: Checking SSDP..."); if self.ssdp_enabled() { - tracing::warn!("🔍 DEBUG: SSDP enabled, getting lock..."); let ssdp_opt = SSDP_SERVER.read().unwrap(); if let Some(ref ssdp) = *ssdp_opt { - tracing::warn!("🔍 DEBUG: SSDP server exists, announcing..."); use crate::config_ext::UpnpConfigExt; let config = pmoconfig::get_config(); let manufacturer = config @@ -278,10 +271,8 @@ impl UpnpServerExt for Server { let ssdp_device = di.to_ssdp_device(&manufacturer, "1.0"); ssdp.add_device(ssdp_device); info!("✅ SSDP announcement for {}", di.udn()); - tracing::warn!("🔍 DEBUG: SSDP announcement complete"); } } - tracing::warn!("🔍 DEBUG: Returning device instance"); Ok(di) } From 38f49d4439dd8bd9952cf7e24c5f14037a836d88 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Fri, 9 Jan 2026 14:30:53 +0100 Subject: [PATCH 2/6] =?UTF-8?q?Impl=C3=A9mentation=20du=20menu=20d=C3=A9ro?= =?UTF-8?q?ulant=20pour=20les=20actions=20d'=C3=A9l=C3=A9ments=20dans=20le?= =?UTF-8?q?=20ServerDrawer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajout d'un menu déroulant avec les actions 'Ajouter à la queue' et 'Ajouter après' pour les éléments dans le ServerDrawer. - Remplacement des fonctions handlePlayItem et handleQueueItem par des fonctions plus spécifiques (handlePlayItem, handleAddToQueue, handleAddAfterCurrent) - Ajout d'une gestion du menu déroulant avec ouverture/fermeture - Intégration du composant useRenderers pour les actions de lecture - Mise à jour des styles pour le menu déroulant avec animations - Correction de l'alignement du backdrop sur mobile - Amélioration des transitions et animations du drawer - Ajout de la fonction addAfterCurrent dans l'API et le control point - Support de l'ajout d'éléments après le morceau actuel dans les queues --- .../src/components/unified/ServerDrawer.vue | 326 +++++++++++-- pmoapp/webapp/src/composables/useRenderers.ts | 261 ++++++----- pmoapp/webapp/src/services/pmocontrol/api.ts | 227 ++++++--- .../webapp/src/views/UnifiedControlView.vue | 439 ++++++++---------- pmocontrol/src/control_point.rs | 22 +- .../src/music_renderer/musicrenderer.rs | 2 +- pmocontrol/src/pmoserver_ext.rs | 116 +++++ pmocontrol/src/queue/backend.rs | 4 +- pmocontrol/src/queue/interne.rs | 41 +- pmocontrol/src/queue/music_queue.rs | 2 +- pmocontrol/src/queue/openhome.rs | 2 +- 11 files changed, 950 insertions(+), 492 deletions(-) diff --git a/pmoapp/webapp/src/components/unified/ServerDrawer.vue b/pmoapp/webapp/src/components/unified/ServerDrawer.vue index 86d58bdf..15bce614 100644 --- a/pmoapp/webapp/src/components/unified/ServerDrawer.vue +++ b/pmoapp/webapp/src/components/unified/ServerDrawer.vue @@ -12,8 +12,10 @@ import { Play, Plus, Settings, + MoreVertical, } from "lucide-vue-next"; import { useMediaServers } from "@/composables/useMediaServers"; +import { useRenderers } from "@/composables/useRenderers"; import type { MediaServerSummary, BrowseResponse, @@ -22,12 +24,11 @@ import type { const props = defineProps<{ modelValue: boolean; // v-model pour contrôler l'ouverture + selectedRendererId?: string | null; // ID du renderer sélectionné }>(); const emit = defineEmits<{ "update:modelValue": [value: boolean]; - "play-item": [item: ContainerEntry, serverId: string]; - "queue-item": [item: ContainerEntry, serverId: string]; }>(); const { @@ -38,6 +39,10 @@ const { setPath, clearPath, } = useMediaServers(); + +const { playContent, addToQueue, addAfterCurrent, attachAndPlayPlaylist } = + useRenderers(); + const router = useRouter(); // État de navigation @@ -45,6 +50,9 @@ const currentServer = ref(null); const browseData = ref(null); const isLoading = ref(false); +// État du menu dropdown (pour chaque item, on stocke si son menu est ouvert) +const openMenuId = ref(null); + // Rafraîchir la liste quand le drawer s'ouvre watch( () => props.modelValue, @@ -56,6 +64,34 @@ watch( currentServer.value = null; browseData.value = null; clearPath(); + closeMenu(); + } + }, +); + +// Fermer le menu quand on clique ailleurs (utilise un seul listener global) +let clickOutsideHandler: ((e: MouseEvent) => void) | null = null; + +watch( + () => openMenuId.value, + (menuId) => { + // Nettoyer l'ancien listener s'il existe + if (clickOutsideHandler) { + document.removeEventListener("click", clickOutsideHandler); + clickOutsideHandler = null; + } + + // Ajouter un nouveau listener seulement si un menu est ouvert + if (menuId) { + clickOutsideHandler = () => { + closeMenu(); + }; + // Utiliser setTimeout pour éviter que le clic qui ouvre le menu le ferme immédiatement + setTimeout(() => { + if (clickOutsideHandler) { + document.addEventListener("click", clickOutsideHandler); + } + }, 0); } }, ); @@ -169,16 +205,85 @@ function handleItemClick(item: ContainerEntry) { // Les tracks individuels : on ne fait rien (actions via boutons) } -function handlePlayItem(event: Event, item: ContainerEntry) { +function toggleMenu(itemId: string, event: Event) { event.stopPropagation(); - if (!currentServer.value) return; - emit("play-item", item, currentServer.value.id); + openMenuId.value = openMenuId.value === itemId ? null : itemId; } -function handleQueueItem(event: Event, item: ContainerEntry) { +function closeMenu() { + openMenuId.value = null; +} + +async function handlePlayItem(event: Event, item: ContainerEntry) { event.stopPropagation(); - if (!currentServer.value) return; - emit("queue-item", item, currentServer.value.id); + closeMenu(); + + if (!currentServer.value || !props.selectedRendererId) { + console.warn("[ServerDrawer] No server or renderer selected"); + return; + } + + try { + if (item.is_container) { + // Container : attacher comme playlist avec auto_play + await attachAndPlayPlaylist( + props.selectedRendererId, + currentServer.value.id, + item.id, + ); + } else { + // Item : vider queue + ajouter + jouer + await playContent( + props.selectedRendererId, + currentServer.value.id, + item.id, + ); + } + } catch (error) { + console.error("[ServerDrawer] Error playing item:", error); + } +} + +async function handleAddToQueue(event: Event, item: ContainerEntry) { + event.stopPropagation(); + closeMenu(); + + if (!currentServer.value || !props.selectedRendererId) { + console.warn("[ServerDrawer] No server or renderer selected"); + return; + } + + try { + // Détacher le binding (fait côté serveur) + ajouter à la fin + await addToQueue( + props.selectedRendererId, + currentServer.value.id, + item.id, + ); + } catch (error) { + console.error("[ServerDrawer] Error adding to queue:", error); + } +} + +async function handleAddAfterCurrent(event: Event, item: ContainerEntry) { + event.stopPropagation(); + closeMenu(); + + if (!currentServer.value || !props.selectedRendererId) { + console.warn("[ServerDrawer] No server or renderer selected"); + return; + } + + try { + // Détacher le binding + insérer après current + await addAfterCurrent( + props.selectedRendererId, + currentServer.value.id, + item.id, + ); + } catch (error) { + console.error("[ServerDrawer] Error adding after current:", error); + } } function handleSettingsClick() { @@ -358,6 +463,7 @@ function handleSettingsClick() { :class="{ navigable: item.is_container && isNavigable(item), + 'menu-open': openMenuId === item.id, }" @click="handleItemClick(item)" > @@ -433,6 +539,7 @@ function handleSettingsClick() { v-if="isPlayable(item)" class="content-actions" > + - + + +
+ + + + +
+ + +
+
+
@@ -507,7 +651,8 @@ function handleSettingsClick() { @media (max-width: 768px) and (orientation: portrait) { .drawer-backdrop { - left: 80vw; /* Mobile portrait: 80vw */ + left: 0; /* Mobile portrait: backdrop commence à gauche car drawer prend 100vw */ + background: rgba(0, 0, 0, 0.4); /* Plus sombre sur mobile */ } } @@ -517,11 +662,11 @@ function handleSettingsClick() { left: 0; bottom: 0; width: 50vw; /* Desktop/landscape: 50% de l'écran */ - background: rgba(255, 255, 255, 0.12); - backdrop-filter: blur(30px) saturate(180%); - -webkit-backdrop-filter: blur(30px) saturate(180%); - border-right: 1px solid rgba(255, 255, 255, 0.2); - box-shadow: 4px 0 24px rgba(0, 0, 0, 0.2); + background: rgba(255, 255, 255, 0.08); /* Plus transparent */ + backdrop-filter: blur(40px) saturate(180%); + -webkit-backdrop-filter: blur(40px) saturate(180%); + border-right: 1px solid rgba(255, 255, 255, 0.15); + box-shadow: 4px 0 32px rgba(0, 0, 0, 0.25); z-index: 201; display: flex; flex-direction: column; @@ -794,6 +939,10 @@ function handleSettingsClick() { transform: translateX(1px); } +.content-item.menu-open { + z-index: 200; /* Passe au-dessus des autres items quand son menu est ouvert */ +} + /* Cover avec image */ .content-cover { position: relative; @@ -884,7 +1033,8 @@ function handleSettingsClick() { flex-shrink: 0; opacity: 1; /* Toujours visible pour le tactile */ transition: all 0.2s ease; - z-index: 1; /* Au-dessus pour capturer les clicks */ + z-index: 10; /* Au-dessus pour capturer les clicks */ + position: relative; /* Crée un contexte de stacking */ } .action-btn { @@ -923,15 +1073,92 @@ function handleSettingsClick() { box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4); } -.queue-btn { +.menu-btn { color: var(--color-text-secondary); } -.queue-btn:hover { +.menu-btn:hover { background: rgba(255, 255, 255, 0.25); color: var(--color-text); } +/* Menu dropdown container */ +.action-menu-container { + position: relative; + z-index: 100; /* Plus élevé que les content-item pour que le dropdown passe au-dessus */ +} + +/* Dropdown menu */ +.action-dropdown { + position: absolute; + top: calc(100% + 4px); + right: 0; + min-width: 180px; + background: rgba(20, 20, 30, 0.98); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); + padding: 4px; + z-index: 10000; /* Très haut pour passer au-dessus de tout */ +} + +@media (prefers-color-scheme: light) { + .action-dropdown { + background: rgba(255, 255, 255, 0.98); + border-color: rgba(0, 0, 0, 0.1); + } +} + +/* Dropdown items */ +.dropdown-item { + display: flex; + align-items: center; + gap: var(--spacing-sm); + width: 100%; + padding: var(--spacing-sm) var(--spacing-md); + background: transparent; + border: none; + border-radius: 6px; + color: var(--color-text); + font-size: var(--text-sm); + text-align: left; + cursor: pointer; + transition: all 0.2s ease; +} + +.dropdown-item:hover { + background: rgba(255, 255, 255, 0.1); +} + +.dropdown-item:active { + transform: scale(0.98); +} + +.dropdown-item span { + flex: 1; +} + +/* Menu fade animation */ +.menu-fade-enter-active { + transition: all 0.15s ease-out; +} + +.menu-fade-leave-active { + transition: all 0.1s ease-in; +} + +.menu-fade-enter-from { + opacity: 0; + transform: translateY(-8px) scale(0.95); +} + +.menu-fade-leave-to { + opacity: 0; + transform: translateY(-4px) scale(0.98); +} + /* Loading state */ .loading-state { display: flex; @@ -1017,12 +1244,12 @@ function handleSettingsClick() { /* Animations */ .backdrop-enter-active { - transition: opacity 0.2s ease; - transition-delay: 0.15s; /* Attend que le drawer soit à moitié visible */ + transition: opacity 0.3s ease-out; + transition-delay: 0.1s; /* Attend que le drawer soit un peu visible */ } .backdrop-leave-active { - transition: opacity 0.2s ease; + transition: opacity 0.25s ease-in; /* Pas de delay au leave - disparaît en même temps que le drawer */ } @@ -1031,16 +1258,26 @@ function handleSettingsClick() { opacity: 0; } -.drawer-enter-active, -.drawer-leave-active { - transition: transform 0.3s ease; +.drawer-enter-active { + transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1); /* Courbe d'animation fluide (easeOutExpo) */ +} + +.drawer-leave-active { + transition: all 0.3s cubic-bezier(0.7, 0, 0.84, 0); /* Courbe d'animation de sortie (easeInExpo) */ +} + +.drawer-enter-from { + transform: translateX(-100%); + opacity: 0; } -.drawer-enter-from, .drawer-leave-to { transform: translateX(-100%); + opacity: 0; } +/* Animation des contenus - désactivée pour éviter les problèmes de z-index en escalier */ + /* Scrollbar styling */ .drawer-content::-webkit-scrollbar, .breadcrumb::-webkit-scrollbar { @@ -1068,7 +1305,14 @@ function handleSettingsClick() { /* Mobile responsive - portrait */ @media (max-width: 768px) and (orientation: portrait) { .server-drawer { - width: 80vw; /* Mobile portrait: 80% de l'écran */ + width: 100vw; /* Mobile portrait: 100% de l'écran */ + background: rgba( + 255, + 255, + 255, + 0.06 + ); /* Encore plus transparent sur mobile */ + box-shadow: none; /* Pas d'ombre sur les côtés */ } .drawer-header { @@ -1078,6 +1322,16 @@ function handleSettingsClick() { .drawer-title { font-size: var(--text-lg); } + + /* Ajuster les items pour mobile */ + .content-item { + padding: var(--spacing-md); + } + + .content-cover { + width: 64px; + height: 64px; + } } /* Fallback pour navigateurs sans backdrop-filter */ diff --git a/pmoapp/webapp/src/composables/useRenderers.ts b/pmoapp/webapp/src/composables/useRenderers.ts index dd27c5de..68fd0126 100644 --- a/pmoapp/webapp/src/composables/useRenderers.ts +++ b/pmoapp/webapp/src/composables/useRenderers.ts @@ -4,28 +4,28 @@ * - Les snapshots complets proviennent de /renderers/{id}/full * - Les événements SSE ne servent qu'à déclencher un refetch. */ -import { ref, reactive, computed, type Ref } from 'vue' -import { api } from '../services/pmocontrol/api' -import { sse } from '../services/pmocontrol/sse' +import { ref, reactive, computed, type Ref } from "vue"; +import { api } from "../services/pmocontrol/api"; +import { sse } from "../services/pmocontrol/sse"; import type { RendererSummary, RendererState, QueueSnapshot, AttachedPlaylistInfo, FullRendererSnapshot, -} from '../services/pmocontrol/types' +} from "../services/pmocontrol/types"; interface RendererSnapshotState { - snapshots: Map - lastSnapshotAt: Map - lastEventAt: Map - loadingIds: Set - selectedRendererId: string | null + snapshots: Map; + lastSnapshotAt: Map; + lastEventAt: Map; + loadingIds: Set; + selectedRendererId: string | null; } -const renderersCache = ref>(new Map()) -const RENDERERS_CACHE_MS = 2000 -const lastRenderersFetch = ref(0) +const renderersCache = ref>(new Map()); +const RENDERERS_CACHE_MS = 2000; +const lastRenderersFetch = ref(0); const snapshotState = reactive({ snapshots: reactive(new Map()), @@ -33,23 +33,25 @@ const snapshotState = reactive({ lastEventAt: reactive(new Map()), loadingIds: reactive(new Set()), selectedRendererId: null, -}) +}); -const loading = ref(false) -const error = ref(null) +const loading = ref(false); +const error = ref(null); -let sseConnected = false +let sseConnected = false; function ensureSSEConnected() { - if (sseConnected) return + if (sseConnected) return; sse.onRendererEvent((event) => { - const rendererId = event.renderer_id - const timestamp = Date.parse(event.timestamp ?? '') || Date.now() + const rendererId = event.renderer_id; + const timestamp = Date.parse(event.timestamp ?? "") || Date.now(); // Gérer les événements Online/Offline différemment - if (event.type === 'online') { + if (event.type === "online") { // Nouveau renderer découvert - console.log(`[useRenderers] Renderer ${rendererId} (${event.friendly_name}) est maintenant en ligne`) + console.log( + `[useRenderers] Renderer ${rendererId} (${event.friendly_name}) est maintenant en ligne`, + ); // Ajouter au cache avec les infos disponibles // Note: on n'a pas toutes les infos (capabilities, protocol) donc on fetch ensuite @@ -57,7 +59,7 @@ function ensureSSEConnected() { id: rendererId, friendly_name: event.friendly_name, model_name: event.model_name, - protocol: 'upnp', // Valeur par défaut, sera mise à jour par le fetch + protocol: "upnp", // Valeur par défaut, sera mise à jour par le fetch capabilities: { has_avtransport: false, has_avtransport_set_next: false, @@ -72,187 +74,200 @@ function ensureSSEConnected() { has_oh_radio: false, }, online: true, - } - renderersCache.value.set(rendererId, renderer) + }; + renderersCache.value.set(rendererId, renderer); // Fetch la liste complète pour avoir les bonnes infos - void fetchRenderers(true) + void fetchRenderers(true); // Fetch le snapshot complet pour ce renderer - void fetchRendererSnapshot(rendererId, { force: true }) - return + void fetchRendererSnapshot(rendererId, { force: true }); + return; } - if (event.type === 'offline') { + if (event.type === "offline") { // Renderer déconnecté - console.log(`[useRenderers] Renderer ${rendererId} est maintenant hors ligne`) + console.log( + `[useRenderers] Renderer ${rendererId} est maintenant hors ligne`, + ); // Marquer comme offline dans le cache - const renderer = renderersCache.value.get(rendererId) + const renderer = renderersCache.value.get(rendererId); if (renderer) { - renderer.online = false - renderersCache.value.set(rendererId, renderer) + renderer.online = false; + renderersCache.value.set(rendererId, renderer); } // Supprimer le snapshot (il n'est plus valide) - snapshotState.snapshots.delete(rendererId) - snapshotState.lastSnapshotAt.delete(rendererId) - snapshotState.lastEventAt.delete(rendererId) - return + snapshotState.snapshots.delete(rendererId); + snapshotState.lastSnapshotAt.delete(rendererId); + snapshotState.lastEventAt.delete(rendererId); + return; } // Pour les autres événements, comportement existant - snapshotState.lastEventAt.set(rendererId, timestamp) - const lastSnapshot = snapshotState.lastSnapshotAt.get(rendererId) ?? 0 + snapshotState.lastEventAt.set(rendererId, timestamp); + const lastSnapshot = snapshotState.lastSnapshotAt.get(rendererId) ?? 0; if (!snapshotState.snapshots.has(rendererId) || timestamp > lastSnapshot) { - void fetchRendererSnapshot(rendererId, { force: true }) + void fetchRendererSnapshot(rendererId, { force: true }); } - }) + }); - sseConnected = true + sseConnected = true; } -const allRenderers = computed(() => Array.from(renderersCache.value.values())) -const onlineRenderers = computed(() => allRenderers.value.filter((r) => r.online)) -const allSnapshots = computed(() => Array.from(snapshotState.snapshots.values())) +const allRenderers = computed(() => Array.from(renderersCache.value.values())); +const onlineRenderers = computed(() => + allRenderers.value.filter((r) => r.online), +); +const allSnapshots = computed(() => + Array.from(snapshotState.snapshots.values()), +); const playingRenderers = computed(() => allSnapshots.value - .filter((snapshot) => snapshot.state.transport_state === 'PLAYING') + .filter((snapshot) => snapshot.state.transport_state === "PLAYING") .map((snapshot) => snapshot.state), -) +); function getRendererById(id: string) { - return renderersCache.value.get(id) + return renderersCache.value.get(id); } function getSnapshotById(id: string) { - return snapshotState.snapshots.get(id) ?? null + return snapshotState.snapshots.get(id) ?? null; } function getStateById(id: string): RendererState | null { - return snapshotState.snapshots.get(id)?.state ?? null + return snapshotState.snapshots.get(id)?.state ?? null; } function getQueueById(id: string): QueueSnapshot | null { - return snapshotState.snapshots.get(id)?.queue ?? null + return snapshotState.snapshots.get(id)?.queue ?? null; } function getBindingById(id: string): AttachedPlaylistInfo | null { - return snapshotState.snapshots.get(id)?.binding ?? null + return snapshotState.snapshots.get(id)?.binding ?? null; } function isSnapshotLoading(id: string) { - return snapshotState.loadingIds.has(id) + return snapshotState.loadingIds.has(id); } function selectRenderer(id: string | null) { - snapshotState.selectedRendererId = id + snapshotState.selectedRendererId = id; } async function fetchRenderers(force = false) { - ensureSSEConnected() + ensureSSEConnected(); - const now = Date.now() + const now = Date.now(); if (!force && now - lastRenderersFetch.value < RENDERERS_CACHE_MS) { - return + return; } try { - loading.value = true - error.value = null - const data = await api.getRenderers() - renderersCache.value = new Map(data.map((renderer) => [renderer.id, renderer])) - lastRenderersFetch.value = now + loading.value = true; + error.value = null; + const data = await api.getRenderers(); + renderersCache.value = new Map( + data.map((renderer) => [renderer.id, renderer]), + ); + lastRenderersFetch.value = now; } catch (err) { - error.value = err instanceof Error ? err.message : 'Erreur fetch renderers' - console.error('[useRenderers] Erreur fetch:', err) + error.value = err instanceof Error ? err.message : "Erreur fetch renderers"; + console.error("[useRenderers] Erreur fetch:", err); } finally { - loading.value = false + loading.value = false; } } -async function fetchRendererSnapshot(rendererId: string, opts?: { force?: boolean }) { - ensureSSEConnected() - const force = opts?.force ?? false - const hasSnapshot = snapshotState.snapshots.has(rendererId) +async function fetchRendererSnapshot( + rendererId: string, + opts?: { force?: boolean }, +) { + ensureSSEConnected(); + const force = opts?.force ?? false; + const hasSnapshot = snapshotState.snapshots.has(rendererId); if (!force && hasSnapshot) { - const lastSnapshot = snapshotState.lastSnapshotAt.get(rendererId) ?? 0 - const lastEvent = snapshotState.lastEventAt.get(rendererId) ?? 0 + const lastSnapshot = snapshotState.lastSnapshotAt.get(rendererId) ?? 0; + const lastEvent = snapshotState.lastEventAt.get(rendererId) ?? 0; if (lastEvent <= lastSnapshot) { - return + return; } } if (snapshotState.loadingIds.has(rendererId)) { - return + return; } - snapshotState.loadingIds.add(rendererId) + snapshotState.loadingIds.add(rendererId); try { - const snapshot = await api.getRendererFullSnapshot(rendererId) - snapshotState.snapshots.set(rendererId, snapshot) - snapshotState.lastSnapshotAt.set(rendererId, Date.now()) + const snapshot = await api.getRendererFullSnapshot(rendererId); + snapshotState.snapshots.set(rendererId, snapshot); + snapshotState.lastSnapshotAt.set(rendererId, Date.now()); } catch (err) { - console.error(`[useRenderers] Erreur snapshot ${rendererId}:`, err) + console.error(`[useRenderers] Erreur snapshot ${rendererId}:`, err); } finally { - snapshotState.loadingIds.delete(rendererId) + snapshotState.loadingIds.delete(rendererId); } } // Transport controls async function play(id: string) { - await api.play(id) + await api.play(id); } async function resumeOrPlayFromQueue(id: string) { - const snapshot = snapshotState.snapshots.get(id) + const snapshot = snapshotState.snapshots.get(id); if (!snapshot) { - throw new Error(`Renderer ${id} non trouvé`) + throw new Error(`Renderer ${id} non trouvé`); } - const state = snapshot.state - if (state.transport_state === 'PAUSED') { - return play(id) + const state = snapshot.state; + if (state.transport_state === "PAUSED") { + return play(id); } if ( - ['STOPPED', 'NO_MEDIA'].includes(state.transport_state) && + ["STOPPED", "NO_MEDIA"].includes(state.transport_state) && snapshot.queue.items.length > 0 ) { - return api.resume(id) + return api.resume(id); } - throw new Error('La file d\'attente est vide. Ajoutez des morceaux avant de démarrer la lecture.') + throw new Error( + "La file d'attente est vide. Ajoutez des morceaux avant de démarrer la lecture.", + ); } async function pause(id: string) { - await api.pause(id) + await api.pause(id); } async function stop(id: string) { - await api.stop(id) + await api.stop(id); } async function next(id: string) { - await api.next(id) + await api.next(id); } // Volume controls async function setVolume(id: string, volume: number) { - await api.setVolume(id, volume) + await api.setVolume(id, volume); } async function volumeUp(id: string) { - await api.volumeUp(id) + await api.volumeUp(id); } async function volumeDown(id: string) { - await api.volumeDown(id) + await api.volumeDown(id); } async function toggleMute(id: string) { - await api.toggleMute(id) + await api.toggleMute(id); } // Playlist binding @@ -262,11 +277,16 @@ async function attachPlaylist( containerId: string, options?: { autoPlay?: boolean }, ) { - await api.attachPlaylist(rendererId, serverId, containerId, options?.autoPlay ?? false) + await api.attachPlaylist( + rendererId, + serverId, + containerId, + options?.autoPlay ?? false, + ); } async function detachPlaylist(rendererId: string) { - await api.detachPlaylist(rendererId) + await api.detachPlaylist(rendererId); } async function attachAndPlayPlaylist( @@ -274,20 +294,36 @@ async function attachAndPlayPlaylist( serverId: string, containerId: string, ) { - await attachPlaylist(rendererId, serverId, containerId, { autoPlay: true }) + await attachPlaylist(rendererId, serverId, containerId, { autoPlay: true }); } // Queue content -async function playContent(rendererId: string, serverId: string, objectId: string) { - await api.playContent(rendererId, serverId, objectId) +async function playContent( + rendererId: string, + serverId: string, + objectId: string, +) { + await api.playContent(rendererId, serverId, objectId); } -async function addToQueue(rendererId: string, serverId: string, objectId: string) { - await api.addToQueue(rendererId, serverId, objectId) +async function addToQueue( + rendererId: string, + serverId: string, + objectId: string, +) { + await api.addToQueue(rendererId, serverId, objectId); +} + +async function addAfterCurrent( + rendererId: string, + serverId: string, + objectId: string, +) { + await api.addAfterCurrent(rendererId, serverId, objectId); } export function useRenderers() { - ensureSSEConnected() + ensureSSEConnected(); return { loading, @@ -326,23 +362,26 @@ export function useRenderers() { // Queue content playContent, addToQueue, - } + addAfterCurrent, + }; } export function useRenderer(rendererId: Ref) { - ensureSSEConnected() + ensureSSEConnected(); - const renderer = computed(() => renderersCache.value.get(rendererId.value)) - const snapshot = computed(() => snapshotState.snapshots.get(rendererId.value) ?? null) - const state = computed(() => snapshot.value?.state ?? null) - const queue = computed(() => snapshot.value?.queue ?? null) - const binding = computed(() => snapshot.value?.binding ?? null) + const renderer = computed(() => renderersCache.value.get(rendererId.value)); + const snapshot = computed( + () => snapshotState.snapshots.get(rendererId.value) ?? null, + ); + const state = computed(() => snapshot.value?.state ?? null); + const queue = computed(() => snapshot.value?.queue ?? null); + const binding = computed(() => snapshot.value?.binding ?? null); async function refresh(force = true) { await Promise.all([ fetchRenderers(force), fetchRendererSnapshot(rendererId.value, { force: true }), - ]) + ]); } return { @@ -352,5 +391,5 @@ export function useRenderer(rendererId: Ref) { queue, binding, refresh, - } + }; } diff --git a/pmoapp/webapp/src/services/pmocontrol/api.ts b/pmoapp/webapp/src/services/pmocontrol/api.ts index 280cdafd..bccf62ac 100644 --- a/pmoapp/webapp/src/services/pmocontrol/api.ts +++ b/pmoapp/webapp/src/services/pmocontrol/api.ts @@ -13,40 +13,40 @@ import type { AttachPlaylistRequest, PlayContentRequest, SuccessResponse, - ErrorResponse -} from './types' + ErrorResponse, +} from "./types"; /** * Client API REST pour le Control Point PMOMusic */ class PMOControlAPI { - private readonly baseURL = '/api/control' + private readonly baseURL = "/api/control"; /** * Effectue une requête HTTP générique */ private async request( path: string, - options: RequestInit = {} + options: RequestInit = {}, ): Promise { - const url = `${this.baseURL}${path}` + const url = `${this.baseURL}${path}`; const response = await fetch(url, { ...options, headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", ...options.headers, }, - }) + }); if (!response.ok) { const error: ErrorResponse = await response.json().catch(() => ({ error: `HTTP ${response.status}: ${response.statusText}`, - })) - throw new Error(error.error) + })); + throw new Error(error.error); } - return response.json() + return response.json(); } // ============================================================================ @@ -58,7 +58,7 @@ class PMOControlAPI { * GET /api/control/renderers */ async getRenderers(): Promise { - return this.request('/renderers') + return this.request("/renderers"); } /** @@ -66,7 +66,7 @@ class PMOControlAPI { * GET /api/control/renderers/{id} */ async getRendererState(id: string): Promise { - return this.request(`/renderers/${encodeURIComponent(id)}`) + return this.request(`/renderers/${encodeURIComponent(id)}`); } /** @@ -74,7 +74,9 @@ class PMOControlAPI { * GET /api/control/renderers/{id}/full */ async getRendererFullSnapshot(id: string): Promise { - return this.request(`/renderers/${encodeURIComponent(id)}/full`) + return this.request( + `/renderers/${encodeURIComponent(id)}/full`, + ); } /** @@ -82,7 +84,9 @@ class PMOControlAPI { * GET /api/control/renderers/{id}/queue */ async getQueue(id: string): Promise { - return this.request(`/renderers/${encodeURIComponent(id)}/queue`) + return this.request( + `/renderers/${encodeURIComponent(id)}/queue`, + ); } /** @@ -90,7 +94,9 @@ class PMOControlAPI { * GET /api/control/renderers/{id}/binding */ async getBinding(id: string): Promise { - return this.request(`/renderers/${encodeURIComponent(id)}/binding`) + return this.request( + `/renderers/${encodeURIComponent(id)}/binding`, + ); } // ============================================================================ @@ -102,9 +108,12 @@ class PMOControlAPI { * POST /api/control/renderers/{id}/play */ async play(id: string): Promise { - return this.request(`/renderers/${encodeURIComponent(id)}/play`, { - method: 'POST', - }) + return this.request( + `/renderers/${encodeURIComponent(id)}/play`, + { + method: "POST", + }, + ); } /** @@ -112,9 +121,12 @@ class PMOControlAPI { * POST /api/control/renderers/{id}/pause */ async pause(id: string): Promise { - return this.request(`/renderers/${encodeURIComponent(id)}/pause`, { - method: 'POST', - }) + return this.request( + `/renderers/${encodeURIComponent(id)}/pause`, + { + method: "POST", + }, + ); } /** @@ -122,9 +134,12 @@ class PMOControlAPI { * POST /api/control/renderers/{id}/stop */ async stop(id: string): Promise { - return this.request(`/renderers/${encodeURIComponent(id)}/stop`, { - method: 'POST', - }) + return this.request( + `/renderers/${encodeURIComponent(id)}/stop`, + { + method: "POST", + }, + ); } /** @@ -132,9 +147,12 @@ class PMOControlAPI { * POST /api/control/renderers/{id}/resume */ async resume(id: string): Promise { - return this.request(`/renderers/${encodeURIComponent(id)}/resume`, { - method: 'POST', - }) + return this.request( + `/renderers/${encodeURIComponent(id)}/resume`, + { + method: "POST", + }, + ); } /** @@ -142,9 +160,12 @@ class PMOControlAPI { * POST /api/control/renderers/{id}/next */ async next(id: string): Promise { - return this.request(`/renderers/${encodeURIComponent(id)}/next`, { - method: 'POST', - }) + return this.request( + `/renderers/${encodeURIComponent(id)}/next`, + { + method: "POST", + }, + ); } /** @@ -152,10 +173,13 @@ class PMOControlAPI { * POST /api/control/renderers/{id}/queue/seek */ async seekQueueIndex(id: string, index: number): Promise { - return this.request(`/renderers/${encodeURIComponent(id)}/queue/seek`, { - method: 'POST', - body: JSON.stringify({ index }), - }) + return this.request( + `/renderers/${encodeURIComponent(id)}/queue/seek`, + { + method: "POST", + body: JSON.stringify({ index }), + }, + ); } // ============================================================================ @@ -167,11 +191,14 @@ class PMOControlAPI { * POST /api/control/renderers/{id}/volume/set */ async setVolume(id: string, volume: number): Promise { - const payload: VolumeSetRequest = { volume } - return this.request(`/renderers/${encodeURIComponent(id)}/volume/set`, { - method: 'POST', - body: JSON.stringify(payload), - }) + const payload: VolumeSetRequest = { volume }; + return this.request( + `/renderers/${encodeURIComponent(id)}/volume/set`, + { + method: "POST", + body: JSON.stringify(payload), + }, + ); } /** @@ -179,9 +206,12 @@ class PMOControlAPI { * POST /api/control/renderers/{id}/volume/up */ async volumeUp(id: string): Promise { - return this.request(`/renderers/${encodeURIComponent(id)}/volume/up`, { - method: 'POST', - }) + return this.request( + `/renderers/${encodeURIComponent(id)}/volume/up`, + { + method: "POST", + }, + ); } /** @@ -189,9 +219,12 @@ class PMOControlAPI { * POST /api/control/renderers/{id}/volume/down */ async volumeDown(id: string): Promise { - return this.request(`/renderers/${encodeURIComponent(id)}/volume/down`, { - method: 'POST', - }) + return this.request( + `/renderers/${encodeURIComponent(id)}/volume/down`, + { + method: "POST", + }, + ); } /** @@ -199,9 +232,12 @@ class PMOControlAPI { * POST /api/control/renderers/{id}/mute/toggle */ async toggleMute(id: string): Promise { - return this.request(`/renderers/${encodeURIComponent(id)}/mute/toggle`, { - method: 'POST', - }) + return this.request( + `/renderers/${encodeURIComponent(id)}/mute/toggle`, + { + method: "POST", + }, + ); } // ============================================================================ @@ -216,17 +252,20 @@ class PMOControlAPI { rendererId: string, serverId: string, containerId: string, - autoPlay = false + autoPlay = false, ): Promise { const payload: AttachPlaylistRequest = { server_id: serverId, container_id: containerId, auto_play: autoPlay, - } - return this.request(`/renderers/${encodeURIComponent(rendererId)}/binding/attach`, { - method: 'POST', - body: JSON.stringify(payload), - }) + }; + return this.request( + `/renderers/${encodeURIComponent(rendererId)}/binding/attach`, + { + method: "POST", + body: JSON.stringify(payload), + }, + ); } /** @@ -234,9 +273,12 @@ class PMOControlAPI { * POST /api/control/renderers/{id}/binding/detach */ async detachPlaylist(rendererId: string): Promise { - return this.request(`/renderers/${encodeURIComponent(rendererId)}/binding/detach`, { - method: 'POST', - }) + return this.request( + `/renderers/${encodeURIComponent(rendererId)}/binding/detach`, + { + method: "POST", + }, + ); } // ============================================================================ @@ -250,13 +292,19 @@ class PMOControlAPI { async playContent( rendererId: string, serverId: string, - objectId: string + objectId: string, ): Promise { - const payload: PlayContentRequest = { server_id: serverId, object_id: objectId } - return this.request(`/renderers/${encodeURIComponent(rendererId)}/queue/play`, { - method: 'POST', - body: JSON.stringify(payload), - }) + const payload: PlayContentRequest = { + server_id: serverId, + object_id: objectId, + }; + return this.request( + `/renderers/${encodeURIComponent(rendererId)}/queue/play`, + { + method: "POST", + body: JSON.stringify(payload), + }, + ); } /** @@ -266,13 +314,41 @@ class PMOControlAPI { async addToQueue( rendererId: string, serverId: string, - objectId: string + objectId: string, ): Promise { - const payload: PlayContentRequest = { server_id: serverId, object_id: objectId } - return this.request(`/renderers/${encodeURIComponent(rendererId)}/queue/add`, { - method: 'POST', - body: JSON.stringify(payload), - }) + const payload: PlayContentRequest = { + server_id: serverId, + object_id: objectId, + }; + return this.request( + `/renderers/${encodeURIComponent(rendererId)}/queue/add`, + { + method: "POST", + body: JSON.stringify(payload), + }, + ); + } + + /** + * Ajouter du contenu après le morceau actuel + * POST /api/control/renderers/{id}/queue/add-after + */ + async addAfterCurrent( + rendererId: string, + serverId: string, + objectId: string, + ): Promise { + const payload: PlayContentRequest = { + server_id: serverId, + object_id: objectId, + }; + return this.request( + `/renderers/${encodeURIComponent(rendererId)}/queue/add-after`, + { + method: "POST", + body: JSON.stringify(payload), + }, + ); } // ============================================================================ @@ -284,19 +360,22 @@ class PMOControlAPI { * GET /api/control/servers */ async getServers(): Promise { - return this.request('/servers') + return this.request("/servers"); } /** * Browse le contenu d'un container sur un serveur * GET /api/control/servers/{serverId}/containers/{containerId} */ - async browseContainer(serverId: string, containerId: string): Promise { + async browseContainer( + serverId: string, + containerId: string, + ): Promise { return this.request( - `/servers/${encodeURIComponent(serverId)}/containers/${encodeURIComponent(containerId)}` - ) + `/servers/${encodeURIComponent(serverId)}/containers/${encodeURIComponent(containerId)}`, + ); } } // Export singleton -export const api = new PMOControlAPI() +export const api = new PMOControlAPI(); diff --git a/pmoapp/webapp/src/views/UnifiedControlView.vue b/pmoapp/webapp/src/views/UnifiedControlView.vue index fad696ba..b57fee43 100644 --- a/pmoapp/webapp/src/views/UnifiedControlView.vue +++ b/pmoapp/webapp/src/views/UnifiedControlView.vue @@ -1,334 +1,299 @@ diff --git a/pmocontrol/src/control_point.rs b/pmocontrol/src/control_point.rs index cecafee5..2172da28 100644 --- a/pmocontrol/src/control_point.rs +++ b/pmocontrol/src/control_point.rs @@ -588,6 +588,19 @@ impl ControlPoint { &self, renderer_id: &DeviceId, items: Vec, + ) -> Result<(), ControlPointError> { + self.enqueue_items_with_mode(renderer_id, items, EnqueueMode::AppendToEnd) + } + + /// Enqueue items to a renderer's queue with a specific enqueue mode. + /// + /// This is the low-level version that allows specifying the enqueue mode. + /// User-driven operations should detach any playlist binding. + pub fn enqueue_items_with_mode( + &self, + renderer_id: &DeviceId, + items: Vec, + mode: EnqueueMode, ) -> Result<(), ControlPointError> { // User-driven mutation: detach any playlist binding self.detach_playlist_binding(renderer_id, "enqueue_items"); @@ -600,7 +613,7 @@ impl ControlPoint { let new_len = { let mut queue = renderer.get_queue_mut(); - queue.enqueue_items(items, EnqueueMode::AppendToEnd)?; + queue.enqueue_items(items, mode)?; queue.upcoming_len()? }; @@ -608,6 +621,7 @@ impl ControlPoint { renderer = renderer_id.0.as_str(), added = item_count, queue_len = new_len, + mode = ?mode, "Enqueued playback items" ); @@ -855,7 +869,7 @@ impl ControlPoint { })?; // Use generic queue access (works for all backends) - let Some((item, remaining)) = renderer.get_queue().peek_current()? else { + let Some((item, remaining)) = renderer.get_queue_mut().peek_current()? else { debug!( renderer = renderer_id.0.as_str(), "play_current_from_queue: queue is empty or no current item" @@ -976,7 +990,7 @@ impl ControlPoint { } // Get the next item from the queue using peek_current - let Ok(Some((_, remaining))) = renderer.get_queue().peek_current() else { + let Ok(Some((_, remaining))) = renderer.get_queue_mut().peek_current() else { return; }; @@ -1022,7 +1036,7 @@ impl ControlPoint { // Set queue index renderer.get_queue_mut().set_index(Some(index))?; - let Some((item, remaining)) = renderer.get_queue().peek_current()? else { + let Some((item, remaining)) = renderer.get_queue_mut().peek_current()? else { debug!( renderer = renderer_id.0.as_str(), index, "play_queue_index: no item at index" diff --git a/pmocontrol/src/music_renderer/musicrenderer.rs b/pmocontrol/src/music_renderer/musicrenderer.rs index 3ba8de50..1017de0c 100644 --- a/pmocontrol/src/music_renderer/musicrenderer.rs +++ b/pmocontrol/src/music_renderer/musicrenderer.rs @@ -231,7 +231,7 @@ impl MusicRenderer { /// Play the current item from the queue. pub fn play_current_from_queue(&self) -> Result<(), ControlPointError> { - let queue = self.queue.lock().expect("Queue mutex poisoned"); + let mut queue = self.queue.lock().expect("Queue mutex poisoned"); let backend = self.backend.lock().expect("Backend mutex poisoned"); // Get the current item from the queue diff --git a/pmocontrol/src/pmoserver_ext.rs b/pmocontrol/src/pmoserver_ext.rs index d909d2a2..8329cc52 100644 --- a/pmocontrol/src/pmoserver_ext.rs +++ b/pmocontrol/src/pmoserver_ext.rs @@ -1392,6 +1392,118 @@ async fn add_to_queue( })) } +/// POST /control/renderers/{renderer_id}/queue/add-after - Ajouter du contenu après le morceau actuel +#[cfg(feature = "pmoserver")] +#[utoipa::path( + post, + path = "/renderers/{renderer_id}/queue/add-after", + params( + ("renderer_id" = String, Path, description = "ID unique du renderer") + ), + request_body = PlayContentRequest, + responses( + (status = 200, description = "Contenu ajouté après le morceau actuel", body = SuccessResponse), + (status = 404, description = "Renderer ou serveur non trouvé", 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 add_after_current( + State(state): State, + Path(renderer_id): Path, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let rid = DeviceId(renderer_id.clone()); + let sid = DeviceId(req.server_id.clone()); + let object_id = req.object_id.clone(); + let object_id_for_log = object_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); + + // Spawn blocking task for content loading + let add_task = tokio::task::spawn_blocking(move || { + // Fetch playback items from server + let items = fetch_playback_items(&control_point, &sid, &object_id)?; + + if items.is_empty() { + return Err(anyhow::anyhow!("No playable content found")); + } + + // Insert items after current using the new method + control_point.enqueue_items_with_mode( + &rid, + items, + crate::queue::EnqueueMode::InsertAfterCurrent, + )?; + + Ok::<(), anyhow::Error>(()) + }); + + time::timeout(QUEUE_COMMAND_TIMEOUT, add_task) + .await + .map_err(|_| { + warn!( + "Add after current command for renderer {} exceeded {:?}", + renderer_id, QUEUE_COMMAND_TIMEOUT + ); + ( + StatusCode::GATEWAY_TIMEOUT, + Json(ErrorResponse { + error: format!( + "Add after current timed out after {}s", + QUEUE_COMMAND_TIMEOUT.as_secs() + ), + }), + ) + })? + .map_err(|e| { + warn!("Task join error during add after current: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Internal task error: {}", e), + }), + ) + })? + .map_err(|e| { + warn!( + "Failed to add content after current for renderer {}: {}", + renderer_id, e + ); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to add after current: {}", e), + }), + ) + })?; + + debug!( + renderer = renderer_id.as_str(), + server = req.server_id.as_str(), + object = object_id_for_log.as_str(), + "Content added after current via HTTP API" + ); + + Ok(Json(SuccessResponse { + message: "Content added after current track".to_string(), + })) +} + // ============================================================================ // HANDLERS - MEDIA SERVERS // ============================================================================ @@ -1703,6 +1815,10 @@ pub fn create_api_router(state: ControlPointState, control_point: Arc Result, ControlPointError> { + fn peek_current(&mut self) -> Result, ControlPointError> { let snapshot = self.queue_snapshot()?; let QueueSnapshot { items, diff --git a/pmocontrol/src/queue/interne.rs b/pmocontrol/src/queue/interne.rs index 7b61894c..b834b639 100644 --- a/pmocontrol/src/queue/interne.rs +++ b/pmocontrol/src/queue/interne.rs @@ -15,7 +15,6 @@ //! - maintains a `current_index`, //! - never starts playback (transport control is handled elsewhere). - use crate::{ DeviceId, DeviceIdentity, RendererInfo, errors::ControlPointError, @@ -72,27 +71,16 @@ impl QueueBackend for InternalQueue { } fn position_to_id(&self, id: usize) -> Result { - u32::try_from(id).map_err(|_| { - ControlPointError::QueueError(format!( - "Position {} exceeds u32::MAX", - id - )) - }) + u32::try_from(id) + .map_err(|_| ControlPointError::QueueError(format!("Position {} exceeds u32::MAX", id))) } fn current_track(&self) -> Result, ControlPointError> { match self.current_index { None => Ok(None), - Some(i) => { - u32::try_from(i) - .map(Some) - .map_err(|_| { - ControlPointError::QueueError(format!( - "Current index {} exceeds u32::MAX", - i - )) - }) - } + Some(i) => u32::try_from(i).map(Some).map_err(|_| { + ControlPointError::QueueError(format!("Current index {} exceeds u32::MAX", i)) + }), } } @@ -142,16 +130,14 @@ impl QueueBackend for InternalQueue { Ok(()) } - fn sync_queue( - &mut self, - items: Vec - ) -> Result<(), ControlPointError> { + fn sync_queue(&mut self, items: Vec) -> Result<(), ControlPointError> { if items.is_empty() { return self.replace_queue(Vec::new(), None); } // Récupérer l'item actuel - let current = self.current_index + let current = self + .current_index .and_then(|idx| self.items.get(idx).map(|item| (idx, item.uri.clone()))); if let Some((_current_idx, current_uri)) = current { @@ -187,7 +173,8 @@ impl QueueBackend for InternalQueue { self.items.extend(items); } EnqueueMode::InsertAfterCurrent => { - let insert_pos = self.current_index + let insert_pos = self + .current_index .map(|i| (i + 1).min(self.items.len())) .unwrap_or(0); @@ -242,7 +229,7 @@ impl QueueBackend for InternalQueue { Ok(items) } - fn peek_current(&self) -> Result, ControlPointError> { + fn peek_current(&mut self) -> Result, ControlPointError> { if self.items.is_empty() { return Ok(None); } @@ -250,7 +237,11 @@ impl QueueBackend for InternalQueue { let len = self.items.len(); let (item, resolved_index) = match self.current_index { Some(idx) if idx < len => (self.items.get(idx).cloned(), Some(idx)), - _ => (self.items.first().cloned(), None), + _ => { + // Si current_index est None ou invalide, initialiser à 0 + self.current_index = Some(0); + (self.items.first().cloned(), Some(0)) + } }; let item = match item { diff --git a/pmocontrol/src/queue/music_queue.rs b/pmocontrol/src/queue/music_queue.rs index c7293319..9388d834 100644 --- a/pmocontrol/src/queue/music_queue.rs +++ b/pmocontrol/src/queue/music_queue.rs @@ -156,7 +156,7 @@ impl QueueBackend for MusicQueue { } } - fn peek_current(&self) -> Result, ControlPointError> { + fn peek_current(&mut self) -> Result, ControlPointError> { match self { MusicQueue::Internal(q) => q.peek_current(), MusicQueue::OpenHome(q) => q.peek_current(), diff --git a/pmocontrol/src/queue/openhome.rs b/pmocontrol/src/queue/openhome.rs index 1bf3d102..5b97c7ba 100644 --- a/pmocontrol/src/queue/openhome.rs +++ b/pmocontrol/src/queue/openhome.rs @@ -778,7 +778,7 @@ impl QueueBackend for OpenHomeQueue { } /// Optimized peek_current: use primitives instead of full snapshot. - fn peek_current(&self) -> Result, ControlPointError> { + fn peek_current(&mut self) -> Result, ControlPointError> { let len = self.len()?; if len == 0 { return Ok(None); From 2ebbed2ffc40816c9106fed9b4c6c74283e39519 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Fri, 9 Jan 2026 17:32:40 +0100 Subject: [PATCH 3/6] Refactor QueueViewer component and update UnifiedControlView This commit includes a refactor of the QueueViewer component to improve code formatting and readability, along with adding a scrollable area fix to ensure items are not hidden under a fixed bar. Additionally, the padding-bottom in UnifiedControlView has been removed as it's now handled by the QueueViewer component. --- .../src/components/pmocontrol/QueueViewer.vue | 217 ++++++++++-------- .../webapp/src/views/UnifiedControlView.vue | 1 - 2 files changed, 118 insertions(+), 100 deletions(-) diff --git a/pmoapp/webapp/src/components/pmocontrol/QueueViewer.vue b/pmoapp/webapp/src/components/pmocontrol/QueueViewer.vue index 6c636d7c..2752e834 100644 --- a/pmoapp/webapp/src/components/pmocontrol/QueueViewer.vue +++ b/pmoapp/webapp/src/components/pmocontrol/QueueViewer.vue @@ -1,159 +1,178 @@ diff --git a/pmoapp/webapp/src/views/UnifiedControlView.vue b/pmoapp/webapp/src/views/UnifiedControlView.vue index b57fee43..4619bc5b 100644 --- a/pmoapp/webapp/src/views/UnifiedControlView.vue +++ b/pmoapp/webapp/src/views/UnifiedControlView.vue @@ -191,7 +191,6 @@ const currentTabProps = computed(() => { overflow-y: auto; overflow-x: hidden; padding: 0; - padding-bottom: 80px; /* Espace pour la barre fixe en bas (64px + marge) */ position: relative; } From 256495b4837031098744c8e6d507a81478aac574 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Fri, 9 Jan 2026 19:26:04 +0100 Subject: [PATCH 4/6] Refactorisation du composant RendererTabContent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration vers un format de code plus cohérent avec des indentations et sauts de ligne uniformes. Correction des espaces et mise à jour des styles pour une meilleure lisibilité et maintenabilité. --- .../components/unified/RendererTabContent.vue | 687 ++++++++++-------- 1 file changed, 371 insertions(+), 316 deletions(-) diff --git a/pmoapp/webapp/src/components/unified/RendererTabContent.vue b/pmoapp/webapp/src/components/unified/RendererTabContent.vue index a5d4f2ac..804eac38 100644 --- a/pmoapp/webapp/src/components/unified/RendererTabContent.vue +++ b/pmoapp/webapp/src/components/unified/RendererTabContent.vue @@ -1,475 +1,530 @@ From 58ac28489c1401bf76176ecaea2a61309fe222ec Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Fri, 9 Jan 2026 19:59:04 +0100 Subject: [PATCH 5/6] 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); } From bd8f87fbe45327523eaef59f3a823b560ce17c46 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Fri, 9 Jan 2026 21:35:00 +0100 Subject: [PATCH 6/6] =?UTF-8?q?Ajout=20de=20la=20fonctionnalit=C3=A9=20de?= =?UTF-8?q?=20transfert=20de=20queue=20entre=20renderers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implémentation complète de la fonctionnalité de transfert de queue entre renderers : - Ajout d'une nouvelle API POST /renderers/{id}/queue/transfer - Intégration dans le backend avec la méthode transfer_queue dans ControlPoint - Mise à jour des structures de données pour inclure l'ID de playlist dans les snapshots - Ajout d'une interface utilisateur dans RendererDrawer avec menu déroulant pour le transfert - Gestion des écouteurs d'événements pour fermer le menu en dehors - Support du transfert de la queue avec préservation de la playlist binding - Ajout de tests et documentation OpenAPI --- .../src/components/unified/RendererDrawer.vue | 219 +++++++++++++++++- pmoapp/webapp/src/services/pmocontrol/api.ts | 20 ++ pmocontrol/src/control_point.rs | 103 ++++++++ .../src/music_renderer/musicrenderer.rs | 29 ++- pmocontrol/src/openapi.rs | 10 + pmocontrol/src/pmoserver_ext.rs | 82 ++++++- pmocontrol/src/queue/backend.rs | 2 + pmocontrol/src/queue/interne.rs | 1 + pmocontrol/src/queue/openhome.rs | 1 + pmocontrol/src/queue/snapshot.rs | 5 +- 10 files changed, 466 insertions(+), 6 deletions(-) diff --git a/pmoapp/webapp/src/components/unified/RendererDrawer.vue b/pmoapp/webapp/src/components/unified/RendererDrawer.vue index 763c93e5..fa7b561c 100644 --- a/pmoapp/webapp/src/components/unified/RendererDrawer.vue +++ b/pmoapp/webapp/src/components/unified/RendererDrawer.vue @@ -1,6 +1,15 @@