Implémentation du menu déroulant pour les actions d'éléments dans le ServerDrawer

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
This commit is contained in:
2026-01-09 14:30:53 +01:00
parent d708bb6035
commit 38f49d4439
11 changed files with 950 additions and 492 deletions

View File

@@ -588,6 +588,19 @@ impl ControlPoint {
&self,
renderer_id: &DeviceId,
items: Vec<PlaybackItem>,
) -> 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<PlaybackItem>,
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"

View File

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

View File

@@ -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<ControlPointState>,
Path(renderer_id): Path<String>,
Json(req): Json<PlayContentRequest>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
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<ControlPoi
// Queue content
.route("/renderers/{renderer_id}/queue/play", post(play_content))
.route("/renderers/{renderer_id}/queue/add", post(add_to_queue))
.route(
"/renderers/{renderer_id}/queue/add-after",
post(add_after_current),
)
// Servers
.route("/servers", get(list_servers))
.route(

View File

@@ -38,7 +38,7 @@ use crate::{PlaybackItem, QueueSnapshot, errors::ControlPointError};
pub enum EnqueueMode {
/// Append new items at the end of the queue.
AppendToEnd,
/// Insert new items immediately after the current index
/// Insert new items immediately after the current playing index
/// (or at the beginning if there is no current index).
InsertAfterCurrent,
/// Replace the whole queue with the new items.
@@ -161,7 +161,7 @@ pub trait QueueBackend {
/// Returns the current item (or the first pending item if no index is set)
/// along with the count of remaining items.
fn peek_current(&self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
fn peek_current(&mut self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
let snapshot = self.queue_snapshot()?;
let QueueSnapshot {
items,

View File

@@ -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, ControlPointError> {
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<Option<u32>, 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<PlaybackItem>
) -> Result<(), ControlPointError> {
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> 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<Option<(PlaybackItem, usize)>, ControlPointError> {
fn peek_current(&mut self) -> Result<Option<(PlaybackItem, usize)>, 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 {

View File

@@ -156,7 +156,7 @@ impl QueueBackend for MusicQueue {
}
}
fn peek_current(&self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
fn peek_current(&mut self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
match self {
MusicQueue::Internal(q) => q.peek_current(),
MusicQueue::OpenHome(q) => q.peek_current(),

View File

@@ -778,7 +778,7 @@ impl QueueBackend for OpenHomeQueue {
}
/// Optimized peek_current: use primitives instead of full snapshot.
fn peek_current(&self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
fn peek_current(&mut self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
let len = self.len()?;
if len == 0 {
return Ok(None);