From 38f49d4439dd8bd9952cf7e24c5f14037a836d88 Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Fri, 9 Jan 2026 14:30:53 +0100 Subject: [PATCH] =?UTF-8?q?Impl=C3=A9mentation=20du=20menu=20d=C3=A9roulan?= =?UTF-8?q?t=20pour=20les=20actions=20d'=C3=A9l=C3=A9ments=20dans=20le=20S?= =?UTF-8?q?erverDrawer?= 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);