diff --git a/pmoapp/webapp/src/components/unified/RendererDrawer.vue b/pmoapp/webapp/src/components/unified/RendererDrawer.vue
index 763c93e5..fa7b561c 100644
--- a/pmoapp/webapp/src/components/unified/RendererDrawer.vue
+++ b/pmoapp/webapp/src/components/unified/RendererDrawer.vue
@@ -1,6 +1,15 @@
@@ -235,6 +316,47 @@ async function handlePause(event: Event, rendererId: string) {
/>
+
+
+
+
@@ -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);
diff --git a/pmoapp/webapp/src/services/pmocontrol/api.ts b/pmoapp/webapp/src/services/pmocontrol/api.ts
index bccf62ac..1907008b 100644
--- a/pmoapp/webapp/src/services/pmocontrol/api.ts
+++ b/pmoapp/webapp/src/services/pmocontrol/api.ts
@@ -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 {
+ const payload = {
+ destination_renderer_id: destinationRendererId,
+ };
+ return this.request(
+ `/renderers/${encodeURIComponent(sourceRendererId)}/queue/transfer`,
+ {
+ method: "POST",
+ body: JSON.stringify(payload),
+ },
+ );
+ }
+
// ============================================================================
// MEDIA SERVERS
// ============================================================================
diff --git a/pmocontrol/src/control_point.rs b/pmocontrol/src/control_point.rs
index 2172da28..08f44e8a 100644
--- a/pmocontrol/src/control_point.rs
+++ b/pmocontrol/src/control_point.rs
@@ -1244,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/music_renderer/musicrenderer.rs b/pmocontrol/src/music_renderer/musicrenderer.rs
index 1017de0c..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.
@@ -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 8bbb8906..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;
@@ -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,
+ 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
// ============================================================================
@@ -1844,6 +1920,10 @@ pub fn create_api_router(state: ControlPointState, control_point: Arc,
+ /// 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 {