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:
@@ -1,6 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, watch } from "vue";
|
import { computed, watch, ref, onUnmounted } from "vue";
|
||||||
import { X, Music2, Circle, Settings, Play, Pause } from "lucide-vue-next";
|
import {
|
||||||
|
X,
|
||||||
|
Music2,
|
||||||
|
Circle,
|
||||||
|
Settings,
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
MoreVertical,
|
||||||
|
ArrowRightLeft,
|
||||||
|
} from "lucide-vue-next";
|
||||||
import { useRenderers } from "@/composables/useRenderers";
|
import { useRenderers } from "@/composables/useRenderers";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import StatusBadge from "@/components/pmocontrol/StatusBadge.vue";
|
import StatusBadge from "@/components/pmocontrol/StatusBadge.vue";
|
||||||
@@ -20,6 +29,39 @@ const emit = defineEmits<{
|
|||||||
const { allRenderers, fetchRenderers, getStateById } = useRenderers();
|
const { allRenderers, fetchRenderers, getStateById } = useRenderers();
|
||||||
const router = useRouter();
|
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
|
// Fonction pour obtenir l'état d'un renderer
|
||||||
function getRendererState(rendererId: string) {
|
function getRendererState(rendererId: string) {
|
||||||
return getStateById(rendererId);
|
return getStateById(rendererId);
|
||||||
@@ -109,6 +151,45 @@ async function handlePause(event: Event, rendererId: string) {
|
|||||||
console.error("[RendererDrawer] Error pausing:", error);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -235,6 +316,47 @@ async function handlePause(event: Event, rendererId: string) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="renderer-status">
|
||||||
<Circle :size="8" fill="currentColor" />
|
<Circle :size="8" fill="currentColor" />
|
||||||
</div>
|
</div>
|
||||||
@@ -603,6 +725,99 @@ async function handlePause(event: Event, rendererId: string) {
|
|||||||
color: var(--color-text-tertiary);
|
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 {
|
.renderer-status {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: var(--status-playing);
|
color: var(--status-playing);
|
||||||
|
|||||||
@@ -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
|
// MEDIA SERVERS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -1244,6 +1244,109 @@ impl ControlPoint {
|
|||||||
self.detach_playlist_binding(renderer_id, "api_detach");
|
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.
|
/// Query the current playlist binding for a renderer's queue, if any.
|
||||||
///
|
///
|
||||||
/// Returns `(server_id, container_id, has_seen_update)` if the queue is
|
/// Returns `(server_id, container_id, has_seen_update)` if the queue is
|
||||||
|
|||||||
@@ -206,10 +206,24 @@ impl MusicRenderer {
|
|||||||
|
|
||||||
/// Get the current queue snapshot.
|
/// Get the current queue snapshot.
|
||||||
pub fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
pub fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
||||||
self.queue
|
let mut snapshot = self
|
||||||
|
.queue
|
||||||
.lock()
|
.lock()
|
||||||
.expect("Queue mutex poisoned")
|
.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.
|
/// Get the current queue item without advancing.
|
||||||
@@ -497,6 +511,17 @@ impl MusicRenderer {
|
|||||||
queue.clear_queue()
|
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<PlaybackItem>,
|
||||||
|
current_index: Option<usize>,
|
||||||
|
) -> Result<(), ControlPointError> {
|
||||||
|
let mut queue = self.queue.lock().expect("Queue mutex poisoned");
|
||||||
|
queue.replace_queue(items, current_index)
|
||||||
|
}
|
||||||
|
|
||||||
/// Adds a track to the queue.
|
/// Adds a track to the queue.
|
||||||
///
|
///
|
||||||
/// This is primarily for backends with persistent queues (OpenHome).
|
/// This is primarily for backends with persistent queues (OpenHome).
|
||||||
|
|||||||
@@ -266,6 +266,14 @@ pub struct SeekQueueRequest {
|
|||||||
pub index: usize,
|
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
|
/// Réponse générique de succès
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
#[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::detach_playlist_binding,
|
||||||
crate::pmoserver_ext::play_content,
|
crate::pmoserver_ext::play_content,
|
||||||
crate::pmoserver_ext::add_to_queue,
|
crate::pmoserver_ext::add_to_queue,
|
||||||
|
crate::pmoserver_ext::transfer_queue,
|
||||||
crate::pmoserver_ext::list_servers,
|
crate::pmoserver_ext::list_servers,
|
||||||
crate::pmoserver_ext::browse_container,
|
crate::pmoserver_ext::browse_container,
|
||||||
crate::sse::all_events_sse,
|
crate::sse::all_events_sse,
|
||||||
@@ -436,6 +445,7 @@ GET /control/servers/{server_id}/containers/{container_id}
|
|||||||
AttachPlaylistRequest,
|
AttachPlaylistRequest,
|
||||||
PlayContentRequest,
|
PlayContentRequest,
|
||||||
SeekQueueRequest,
|
SeekQueueRequest,
|
||||||
|
TransferQueueRequest,
|
||||||
SuccessResponse,
|
SuccessResponse,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
)),
|
)),
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use crate::openapi::{
|
|||||||
AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, ErrorResponse,
|
AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, ErrorResponse,
|
||||||
FullRendererSnapshot, MediaServerSummary, PlayContentRequest, QueueSnapshot,
|
FullRendererSnapshot, MediaServerSummary, PlayContentRequest, QueueSnapshot,
|
||||||
RendererCapabilitiesSummary, RendererProtocolSummary, RendererState, RendererSummary,
|
RendererCapabilitiesSummary, RendererProtocolSummary, RendererState, RendererSummary,
|
||||||
SeekQueueRequest, SuccessResponse, VolumeSetRequest,
|
SeekQueueRequest, SuccessResponse, TransferQueueRequest, VolumeSetRequest,
|
||||||
};
|
};
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use crate::queue::PlaybackItem;
|
use crate::queue::PlaybackItem;
|
||||||
@@ -1523,6 +1523,82 @@ async fn add_after_current(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<ControlPointState>,
|
||||||
|
Path(source_renderer_id): Path<String>,
|
||||||
|
Json(req): Json<TransferQueueRequest>,
|
||||||
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
|
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
|
// HANDLERS - MEDIA SERVERS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -1844,6 +1920,10 @@ pub fn create_api_router(state: ControlPointState, control_point: Arc<ControlPoi
|
|||||||
"/renderers/{renderer_id}/queue/add-after",
|
"/renderers/{renderer_id}/queue/add-after",
|
||||||
post(add_after_current),
|
post(add_after_current),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/renderers/{renderer_id}/queue/transfer",
|
||||||
|
post(transfer_queue),
|
||||||
|
)
|
||||||
// Servers
|
// Servers
|
||||||
.route("/servers", get(list_servers))
|
.route("/servers", get(list_servers))
|
||||||
.route(
|
.route(
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ pub trait QueueBackend {
|
|||||||
let QueueSnapshot {
|
let QueueSnapshot {
|
||||||
items,
|
items,
|
||||||
current_index,
|
current_index,
|
||||||
|
..
|
||||||
} = snapshot;
|
} = snapshot;
|
||||||
|
|
||||||
if items.is_empty() {
|
if items.is_empty() {
|
||||||
@@ -198,6 +199,7 @@ pub trait QueueBackend {
|
|||||||
let QueueSnapshot {
|
let QueueSnapshot {
|
||||||
items,
|
items,
|
||||||
current_index,
|
current_index,
|
||||||
|
..
|
||||||
} = snapshot;
|
} = snapshot;
|
||||||
|
|
||||||
if items.is_empty() {
|
if items.is_empty() {
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ impl QueueBackend for InternalQueue {
|
|||||||
Ok(QueueSnapshot {
|
Ok(QueueSnapshot {
|
||||||
items,
|
items,
|
||||||
current_index: self.current_index,
|
current_index: self.current_index,
|
||||||
|
playlist_id: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -549,6 +549,7 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
Ok(QueueSnapshot {
|
Ok(QueueSnapshot {
|
||||||
items: items,
|
items: items,
|
||||||
current_index: current_index,
|
current_index: current_index,
|
||||||
|
playlist_id: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ impl PlaybackItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Logical snapshot of a renderer queue.
|
/// Logical snapshot of a renderer queue.
|
||||||
///
|
///
|
||||||
/// This is the canonical view used by the ControlPoint and the REST/API
|
/// 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
|
/// Index (0-based) of the current item in `items`, or `None` if
|
||||||
/// no item is currently selected.
|
/// no item is currently selected.
|
||||||
pub current_index: Option<usize>,
|
pub current_index: Option<usize>,
|
||||||
|
/// 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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl QueueSnapshot {
|
impl QueueSnapshot {
|
||||||
|
|||||||
Reference in New Issue
Block a user