Ajout de la fonctionnalité de transfert de queue entre renderers

Implémentation complète de la fonctionnalité de transfert de queue entre renderers :
- Ajout d'une nouvelle API POST /renderers/{id}/queue/transfer
- Intégration dans le backend avec la méthode transfer_queue dans ControlPoint
- Mise à jour des structures de données pour inclure l'ID de playlist dans les snapshots
- Ajout d'une interface utilisateur dans RendererDrawer avec menu déroulant pour le transfert
- Gestion des écouteurs d'événements pour fermer le menu en dehors
- Support du transfert de la queue avec préservation de la playlist binding
- Ajout de tests et documentation OpenAPI
This commit is contained in:
2026-01-09 21:35:00 +01:00
parent 58ac28489c
commit bd8f87fbe4
10 changed files with 466 additions and 6 deletions

View File

@@ -1,6 +1,15 @@
<script setup lang="ts">
import { computed, watch } from "vue";
import { X, Music2, Circle, Settings, Play, Pause } from "lucide-vue-next";
import { computed, watch, ref, onUnmounted } from "vue";
import {
X,
Music2,
Circle,
Settings,
Play,
Pause,
MoreVertical,
ArrowRightLeft,
} from "lucide-vue-next";
import { useRenderers } from "@/composables/useRenderers";
import { useRouter } from "vue-router";
import StatusBadge from "@/components/pmocontrol/StatusBadge.vue";
@@ -20,6 +29,39 @@ const emit = defineEmits<{
const { allRenderers, fetchRenderers, getStateById } = useRenderers();
const router = useRouter();
// Gestion du menu déroulant
const openMenuId = ref<string | null>(null);
// Gestionnaire pour fermer le menu quand on clique en dehors
let clickOutsideHandler: ((event: MouseEvent) => void) | null = null;
watch(openMenuId, (newValue) => {
if (newValue) {
// Ajouter l'écouteur après un petit délai pour éviter la fermeture immédiate
setTimeout(() => {
clickOutsideHandler = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (!target.closest(".action-menu-container")) {
closeMenu();
}
};
document.addEventListener("click", clickOutsideHandler);
}, 100);
} else {
// Retirer l'écouteur
if (clickOutsideHandler) {
document.removeEventListener("click", clickOutsideHandler);
clickOutsideHandler = null;
}
}
});
onUnmounted(() => {
if (clickOutsideHandler) {
document.removeEventListener("click", clickOutsideHandler);
}
});
// Fonction pour obtenir l'état d'un renderer
function getRendererState(rendererId: string) {
return getStateById(rendererId);
@@ -109,6 +151,45 @@ async function handlePause(event: Event, rendererId: string) {
console.error("[RendererDrawer] Error pausing:", error);
}
}
// Gestion du menu déroulant
function toggleMenu(event: Event, rendererId: string) {
event.stopPropagation();
openMenuId.value = openMenuId.value === rendererId ? null : rendererId;
}
function closeMenu() {
openMenuId.value = null;
}
// Transfert de la queue
async function handleTransferQueue(event: Event, targetRendererId: string) {
event.stopPropagation();
closeMenu();
if (!props.selectedRendererId) {
console.warn("[RendererDrawer] No source renderer selected");
return;
}
if (props.selectedRendererId === targetRendererId) {
console.warn("[RendererDrawer] Cannot transfer to the same renderer");
return;
}
try {
console.log(
`[RendererDrawer] Transferring queue from ${props.selectedRendererId} to ${targetRendererId}`,
);
await api.transferQueue(props.selectedRendererId, targetRendererId);
// Sélectionner le nouveau renderer et fermer le drawer
emit("select-renderer", targetRendererId);
close();
} catch (error) {
console.error("[RendererDrawer] Error transferring queue:", error);
}
}
</script>
<template>
@@ -235,6 +316,47 @@ async function handlePause(event: Event, rendererId: string) {
/>
</div>
</div>
<!-- Menu actions (uniquement si ce n'est pas le renderer sélectionné) -->
<div
v-if="
selectedRendererId &&
renderer.id !== selectedRendererId
"
class="action-menu-container"
>
<button
class="menu-btn"
@click="toggleMenu($event, renderer.id)"
:aria-label="`Actions pour ${renderer.friendly_name}`"
>
<MoreVertical :size="18" />
</button>
<Transition name="menu-fade">
<div
v-if="openMenuId === renderer.id"
class="action-dropdown"
>
<button
class="dropdown-item"
@click="
handleTransferQueue(
$event,
renderer.id,
)
"
>
<ArrowRightLeft :size="16" />
<span
>Transférer la lecture
ici</span
>
</button>
</div>
</Transition>
</div>
<div class="renderer-status">
<Circle :size="8" fill="currentColor" />
</div>
@@ -603,6 +725,99 @@ async function handlePause(event: Event, rendererId: string) {
color: var(--color-text-tertiary);
}
/* Menu actions */
.action-menu-container {
position: relative;
flex-shrink: 0;
}
.menu-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
color: var(--color-text-secondary);
transition: all 0.2s ease;
}
.menu-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--color-text);
}
.action-dropdown {
position: absolute;
right: 0;
top: calc(100% + 4px);
min-width: 200px;
background: var(--color-surface-elevated);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
overflow: hidden;
z-index: 1000;
}
@media (prefers-color-scheme: light) {
.action-dropdown {
background: white;
border-color: rgba(0, 0, 0, 0.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
}
.dropdown-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
background: none;
border: none;
text-align: left;
cursor: pointer;
transition: background-color 0.2s ease;
color: var(--color-text);
font-size: var(--text-sm);
}
.dropdown-item:hover {
background: rgba(102, 126, 234, 0.1);
}
.dropdown-item:active {
background: rgba(102, 126, 234, 0.2);
}
.dropdown-item span {
flex: 1;
}
/* Transitions pour le menu */
.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(-4px);
}
.menu-fade-leave-to {
opacity: 0;
transform: translateY(-4px);
}
.renderer-status {
flex-shrink: 0;
color: var(--status-playing);

View File

@@ -351,6 +351,26 @@ class PMOControlAPI {
);
}
/**
* Transfère la queue d'un renderer vers un autre
* POST /api/control/renderers/{id}/queue/transfer
*/
async transferQueue(
sourceRendererId: string,
destinationRendererId: string,
): Promise<SuccessResponse> {
const payload = {
destination_renderer_id: destinationRendererId,
};
return this.request<SuccessResponse>(
`/renderers/${encodeURIComponent(sourceRendererId)}/queue/transfer`,
{
method: "POST",
body: JSON.stringify(payload),
},
);
}
// ============================================================================
// MEDIA SERVERS
// ============================================================================