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

@@ -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

View File

@@ -206,10 +206,24 @@ impl MusicRenderer {
/// Get the current queue snapshot.
pub fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
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<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.
///
/// This is primarily for backends with persistent queues (OpenHome).

View File

@@ -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,
)),

View File

@@ -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<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
// ============================================================================
@@ -1844,6 +1920,10 @@ pub fn create_api_router(state: ControlPointState, control_point: Arc<ControlPoi
"/renderers/{renderer_id}/queue/add-after",
post(add_after_current),
)
.route(
"/renderers/{renderer_id}/queue/transfer",
post(transfer_queue),
)
// Servers
.route("/servers", get(list_servers))
.route(

View File

@@ -166,6 +166,7 @@ pub trait QueueBackend {
let QueueSnapshot {
items,
current_index,
..
} = snapshot;
if items.is_empty() {
@@ -198,6 +199,7 @@ pub trait QueueBackend {
let QueueSnapshot {
items,
current_index,
..
} = snapshot;
if items.is_empty() {

View File

@@ -97,6 +97,7 @@ impl QueueBackend for InternalQueue {
Ok(QueueSnapshot {
items,
current_index: self.current_index,
playlist_id: None,
})
}

View File

@@ -549,6 +549,7 @@ impl QueueBackend for OpenHomeQueue {
Ok(QueueSnapshot {
items: items,
current_index: current_index,
playlist_id: None,
})
}

View File

@@ -63,7 +63,6 @@ impl PlaybackItem {
}
}
/// Logical snapshot of a renderer queue.
///
/// This is the canonical view used by the ControlPoint and the REST/API
@@ -76,6 +75,10 @@ pub struct QueueSnapshot {
/// Index (0-based) of the current item in `items`, or `None` if
/// no item is currently selected.
pub current_index: Option<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 {