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/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/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..fa7b561c --- /dev/null +++ b/pmoapp/webapp/src/components/unified/RendererDrawer.vue @@ -0,0 +1,977 @@ + + + + + diff --git a/pmoapp/webapp/src/components/unified/RendererTabContent.vue b/pmoapp/webapp/src/components/unified/RendererTabContent.vue index a5d4f2ac..12950107 100644 --- a/pmoapp/webapp/src/components/unified/RendererTabContent.vue +++ b/pmoapp/webapp/src/components/unified/RendererTabContent.vue @@ -1,475 +1,372 @@ 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..1907008b 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,61 @@ 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), + }, + ); + } + + /** + * Transfère la queue d'un renderer vers un autre + * POST /api/control/renderers/{id}/queue/transfer + */ + async transferQueue( + sourceRendererId: string, + destinationRendererId: string, + ): Promise { + const payload = { + destination_renderer_id: destinationRendererId, + }; + return this.request( + `/renderers/${encodeURIComponent(sourceRendererId)}/queue/transfer`, + { + method: "POST", + body: JSON.stringify(payload), + }, + ); } // ============================================================================ @@ -284,19 +380,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..9d2ee419 100644 --- a/pmoapp/webapp/src/views/UnifiedControlView.vue +++ b/pmoapp/webapp/src/views/UnifiedControlView.vue @@ -1,334 +1,346 @@ diff --git a/pmocontrol/src/control_point.rs b/pmocontrol/src/control_point.rs index cecafee5..08f44e8a 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" @@ -1230,6 +1244,109 @@ impl ControlPoint { self.detach_playlist_binding(renderer_id, "api_detach"); } + /// Transfers the queue and playlist binding from one renderer to another. + /// + /// This method performs a complete transfer: + /// 1. Takes a snapshot of the source renderer's queue (including playlist binding) + /// 2. Clears the destination renderer's queue + /// 3. Fills the destination renderer's queue with the source snapshot + /// 4. If the source had a playlist binding, recreates it on the destination + /// 5. Stops playback on the source renderer + /// 6. Starts playback on the destination renderer at the same position + /// 7. Clears the source renderer's queue + /// + /// This is useful for seamlessly moving playback from one device to another + /// while preserving the queue state and playlist synchronization. + pub fn transfer_queue( + &self, + source_renderer_id: &DeviceId, + dest_renderer_id: &DeviceId, + ) -> Result<(), ControlPointError> { + // 1. Get snapshot from source renderer + let source_snapshot = self.get_renderer_queue_snapshot(source_renderer_id)?; + let source_binding = self.current_queue_playlist_binding(source_renderer_id); + + tracing::info!( + source = source_renderer_id.0.as_str(), + dest = dest_renderer_id.0.as_str(), + items = source_snapshot.items.len(), + current_index = ?source_snapshot.current_index, + has_binding = source_binding.is_some(), + "Transferring queue between renderers" + ); + + // 2. Clear destination queue + self.clear_renderer_queue(dest_renderer_id)?; + + // 3. Fill destination queue with source items + let dest_renderer = self.music_renderer_by_id(dest_renderer_id).ok_or_else(|| { + ControlPointError::SnapshotError(format!( + "Destination renderer {} not found", + dest_renderer_id.0 + )) + })?; + + dest_renderer + .replace_queue(source_snapshot.items.clone(), source_snapshot.current_index)?; + + // 4. Recreate playlist binding on destination if source had one + if let Some((server_id, container_id, _)) = source_binding { + tracing::debug!( + dest = dest_renderer_id.0.as_str(), + server = server_id.0.as_str(), + container = container_id.as_str(), + "Recreating playlist binding on destination renderer" + ); + self.attach_queue_to_playlist(dest_renderer_id, server_id, container_id)?; + } + + // 5. Stop playback on source renderer + let source_renderer = self + .music_renderer_by_id(source_renderer_id) + .ok_or_else(|| { + ControlPointError::SnapshotError(format!( + "Source renderer {} not found", + source_renderer_id.0 + )) + })?; + + if let Err(e) = source_renderer.stop() { + tracing::warn!( + source = source_renderer_id.0.as_str(), + error = ?e, + "Failed to stop source renderer (continuing transfer)" + ); + } + + // 6. Start playback on destination renderer (if there was a current item) + if source_snapshot.current_index.is_some() && !source_snapshot.items.is_empty() { + if let Err(e) = dest_renderer.play() { + tracing::warn!( + dest = dest_renderer_id.0.as_str(), + error = ?e, + "Failed to start playback on destination renderer" + ); + } + } + + // 7. Clear source queue + if let Err(e) = self.clear_renderer_queue(source_renderer_id) { + tracing::warn!( + source = source_renderer_id.0.as_str(), + error = ?e, + "Failed to clear source renderer queue after transfer" + ); + } + + tracing::info!( + source = source_renderer_id.0.as_str(), + dest = dest_renderer_id.0.as_str(), + "Queue transfer completed successfully" + ); + + Ok(()) + } + /// Query the current playlist binding for a renderer's queue, if any. /// /// Returns `(server_id, container_id, has_seen_update)` if the queue is 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/music_renderer/musicrenderer.rs b/pmocontrol/src/music_renderer/musicrenderer.rs index 3ba8de50..682a6d30 100644 --- a/pmocontrol/src/music_renderer/musicrenderer.rs +++ b/pmocontrol/src/music_renderer/musicrenderer.rs @@ -206,10 +206,24 @@ impl MusicRenderer { /// Get the current queue snapshot. pub fn queue_snapshot(&self) -> Result { - self.queue + let mut snapshot = self + .queue .lock() .expect("Queue mutex poisoned") - .queue_snapshot() + .queue_snapshot()?; + + // Enrich snapshot with playlist_id from binding if available + if let Some(binding) = self + .playlist_binding + .lock() + .expect("Binding mutex poisoned") + .as_ref() + { + // Use container_id as the playlist identifier + snapshot.playlist_id = Some(binding.container_id.clone()); + } + + Ok(snapshot) } /// Get the current queue item without advancing. @@ -231,7 +245,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 @@ -497,6 +511,17 @@ impl MusicRenderer { queue.clear_queue() } + /// Replaces the entire queue with new items and sets the current index. + /// This is a complete replacement, unlike sync_queue which tries to preserve the current track. + pub fn replace_queue( + &self, + items: Vec, + current_index: Option, + ) -> Result<(), ControlPointError> { + let mut queue = self.queue.lock().expect("Queue mutex poisoned"); + queue.replace_queue(items, current_index) + } + /// Adds a track to the queue. /// /// This is primarily for backends with persistent queues (OpenHome). diff --git a/pmocontrol/src/openapi.rs b/pmocontrol/src/openapi.rs index 4b37bd97..9af5feb1 100644 --- a/pmocontrol/src/openapi.rs +++ b/pmocontrol/src/openapi.rs @@ -266,6 +266,14 @@ pub struct SeekQueueRequest { pub index: usize, } +/// Requête pour transférer une queue d'un renderer vers un autre +#[cfg(feature = "pmoserver")] +#[derive(Debug, Clone, Deserialize, ToSchema)] +pub struct TransferQueueRequest { + /// ID du renderer de destination + pub destination_renderer_id: String, +} + /// Réponse générique de succès #[cfg(feature = "pmoserver")] #[derive(Debug, Clone, Serialize, ToSchema)] @@ -413,6 +421,7 @@ GET /control/servers/{server_id}/containers/{container_id} crate::pmoserver_ext::detach_playlist_binding, crate::pmoserver_ext::play_content, crate::pmoserver_ext::add_to_queue, + crate::pmoserver_ext::transfer_queue, crate::pmoserver_ext::list_servers, crate::pmoserver_ext::browse_container, crate::sse::all_events_sse, @@ -436,6 +445,7 @@ GET /control/servers/{server_id}/containers/{container_id} AttachPlaylistRequest, PlayContentRequest, SeekQueueRequest, + TransferQueueRequest, SuccessResponse, ErrorResponse, )), diff --git a/pmocontrol/src/pmoserver_ext.rs b/pmocontrol/src/pmoserver_ext.rs index d909d2a2..b49bcb71 100644 --- a/pmocontrol/src/pmoserver_ext.rs +++ b/pmocontrol/src/pmoserver_ext.rs @@ -14,7 +14,7 @@ use crate::openapi::{ AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, ErrorResponse, FullRendererSnapshot, MediaServerSummary, PlayContentRequest, QueueSnapshot, RendererCapabilitiesSummary, RendererProtocolSummary, RendererState, RendererSummary, - SeekQueueRequest, SuccessResponse, VolumeSetRequest, + SeekQueueRequest, SuccessResponse, TransferQueueRequest, VolumeSetRequest, }; #[cfg(feature = "pmoserver")] use crate::queue::PlaybackItem; @@ -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)) } @@ -1392,6 +1411,194 @@ 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(), + })) +} + +/// POST /control/renderers/{renderer_id}/queue/transfer - Transfère la queue vers un autre renderer +#[cfg(feature = "pmoserver")] +#[utoipa::path( + post, + path = "/renderers/{renderer_id}/queue/transfer", + params( + ("renderer_id" = String, Path, description = "ID du renderer source") + ), + request_body = TransferQueueRequest, + responses( + (status = 200, description = "Queue transférée avec succès", body = SuccessResponse), + (status = 404, description = "Renderer non trouvé", body = ErrorResponse), + (status = 500, description = "Erreur lors du transfert", body = ErrorResponse) + ), + tag = "control" +)] +async fn transfer_queue( + State(state): State, + Path(source_renderer_id): Path, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let source_id = DeviceId(source_renderer_id.clone()); + let dest_id = DeviceId(req.destination_renderer_id.clone()); + + debug!( + source = source_renderer_id.as_str(), + dest = req.destination_renderer_id.as_str(), + "Transferring queue between renderers via HTTP API" + ); + + let control_point = state.control_point.clone(); + tokio::task::spawn_blocking(move || control_point.transfer_queue(&source_id, &dest_id)) + .await + .map_err(|e| { + warn!( + source = source_renderer_id.as_str(), + dest = req.destination_renderer_id.as_str(), + error = ?e, + "Failed to spawn transfer_queue task" + ); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to spawn transfer task: {}", e), + }), + ) + })? + .map_err(|e| { + warn!( + source = source_renderer_id.as_str(), + dest = req.destination_renderer_id.as_str(), + error = ?e, + "Failed to transfer queue" + ); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to transfer queue: {}", e), + }), + ) + })?; + + debug!( + source = source_renderer_id.as_str(), + dest = req.destination_renderer_id.as_str(), + "Queue transferred successfully via HTTP API" + ); + + Ok(Json(SuccessResponse { + message: format!( + "Queue transferred from {} to {}", + source_renderer_id, req.destination_renderer_id + ), + })) +} + // ============================================================================ // HANDLERS - MEDIA SERVERS // ============================================================================ @@ -1407,17 +1614,23 @@ async fn add_to_queue( 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) } @@ -1703,6 +1916,14 @@ 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, current_index, + .. } = snapshot; if items.is_empty() { @@ -198,6 +199,7 @@ pub trait QueueBackend { let QueueSnapshot { items, current_index, + .. } = snapshot; if items.is_empty() { diff --git a/pmocontrol/src/queue/interne.rs b/pmocontrol/src/queue/interne.rs index 7b61894c..8b835f67 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)) + }), } } @@ -109,6 +97,7 @@ impl QueueBackend for InternalQueue { Ok(QueueSnapshot { items, current_index: self.current_index, + playlist_id: None, }) } @@ -142,16 +131,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 +174,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 +230,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 +238,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..20618bfd 100644 --- a/pmocontrol/src/queue/openhome.rs +++ b/pmocontrol/src/queue/openhome.rs @@ -549,6 +549,7 @@ impl QueueBackend for OpenHomeQueue { Ok(QueueSnapshot { items: items, current_index: current_index, + playlist_id: None, }) } @@ -778,7 +779,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); diff --git a/pmocontrol/src/queue/snapshot.rs b/pmocontrol/src/queue/snapshot.rs index a59abb7f..7026f93d 100644 --- a/pmocontrol/src/queue/snapshot.rs +++ b/pmocontrol/src/queue/snapshot.rs @@ -63,7 +63,6 @@ impl PlaybackItem { } } - /// Logical snapshot of a renderer queue. /// /// This is the canonical view used by the ControlPoint and the REST/API @@ -76,6 +75,10 @@ pub struct QueueSnapshot { /// Index (0-based) of the current item in `items`, or `None` if /// no item is currently selected. pub current_index: Option, + /// Optional playlist ID if the queue is bound to a specific playlist. + /// This allows reconstructing a queue from a snapshot by referencing + /// the source playlist, enabling transfer between renderers. + pub playlist_id: Option, } impl QueueSnapshot { 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/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); } 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) }