Rends les items de la queue de lecture cliquables.

This commit is contained in:
2025-12-27 14:56:45 +01:00
parent 6d9c50d454
commit 40cce9f894
8 changed files with 268 additions and 4 deletions

View File

@@ -1570,6 +1570,118 @@ impl ControlPoint {
Ok(())
}
/// Jumps to a specific index in the queue and starts playback.
///
/// For OpenHome renderers, this uses the playlist's native SeekId capability.
/// For internal queues, this updates the current index and starts playback.
pub fn play_queue_index(&self, renderer_id: &RendererId, index: usize) -> anyhow::Result<()> {
if !self.runtime.has_entry(renderer_id) {
let err = Self::runtime_entry_missing(renderer_id);
warn!(
renderer = renderer_id.0.as_str(),
"Cannot jump to index: renderer not registered in runtime"
);
return Err(err);
}
// For OpenHome, use select_track_index which handles the track_id lookup
if self.runtime.uses_openhome_playlist(renderer_id) {
info!(
renderer = renderer_id.0.as_str(),
index,
"Seeking to OpenHome queue index"
);
// Use the queue to select the track by index
self.runtime.with_music_queue_mut(renderer_id, |queue| {
if let crate::control_point::music_queue::MusicQueue::OpenHome(oh_queue) = queue {
oh_queue.select_track_index(index)
} else {
Err(anyhow!("Expected OpenHome queue"))
}
})?;
info!(
renderer = renderer_id.0.as_str(),
index,
"Successfully seeked to OpenHome queue index"
);
self.runtime
.set_playback_source(renderer_id, PlaybackSource::FromQueue);
return self.sync_openhome_playlist_for(renderer_id);
}
// For internal queue, set the index and play
self.runtime.with_music_queue_mut(renderer_id, |queue| {
queue.set_index(Some(index))
})?;
let Some((item, remaining)) = self.runtime.peek_current(renderer_id) else {
debug!(
renderer = renderer_id.0.as_str(),
index,
"play_queue_index: no item at index"
);
self.runtime
.set_playback_source(renderer_id, PlaybackSource::None);
return Ok(());
};
debug!(
renderer = renderer_id.0.as_str(),
index,
queue_len = remaining + 1,
uri = item.uri.as_str(),
"Playing item at index from queue"
);
let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| {
warn!(
renderer = renderer_id.0.as_str(),
"Renderer disappeared before queue playback could start"
);
anyhow!("Renderer {} not found", renderer_id.0)
})?;
let playback = (|| -> anyhow::Result<()> {
let didl_metadata = playback_item_to_didl(&item);
renderer.play_uri(&item.uri, &didl_metadata)?;
Ok(())
})();
match playback {
Ok(()) => {
info!(
renderer = renderer_id.0.as_str(),
index,
uri = item.uri.as_str(),
"Queue playback started at index"
);
let metadata = playback_item_track_metadata(&item);
self.runtime.update_snapshot_with(renderer_id, |snapshot| {
snapshot.last_metadata = Some(metadata);
});
self.runtime
.set_playback_source(renderer_id, PlaybackSource::FromQueue);
Ok(())
}
Err(err) => {
error!(
renderer = renderer_id.0.as_str(),
index,
error = %err,
"Failed to start playback for queued item at index"
);
self.runtime
.set_playback_source(renderer_id, PlaybackSource::None);
Err(err)
}
}
}
fn start_queue_playback_if_idle(&self, renderer_id: &RendererId) -> anyhow::Result<()> {
let snapshot = self.runtime.snapshot_for(renderer_id);
let renderer_playing = matches!(snapshot.state, Some(PlaybackState::Playing));

View File

@@ -163,6 +163,20 @@ impl OpenHomeQueue {
Ok(())
}
/// Selects and plays a track by its queue index (0-based).
pub fn select_track_index(&mut self, index: usize) -> Result<()> {
let track_id = self
.track_ids
.get(index)
.copied()
.ok_or_else(|| anyhow!("Index {} out of bounds (queue length: {})", index, self.track_ids.len()))?;
self.ensure_playlist_source_selected()?;
self.playlist.play_id(track_id)?;
self.current_index = Some(index);
Ok(())
}
pub fn clear(&mut self) -> Result<()> {
self.ensure_playlist_source_selected()?;
self.playlist.delete_all()?;

View File

@@ -280,6 +280,14 @@ pub struct PlayContentRequest {
pub object_id: String,
}
/// Requête pour sauter à un index spécifique dans la queue
#[cfg(feature = "pmoserver")]
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct SeekQueueRequest {
/// Index de l'item dans la queue (0-based)
pub index: usize,
}
/// Réponse générique de succès
#[cfg(feature = "pmoserver")]
#[derive(Debug, Clone, Serialize, ToSchema)]

View File

@@ -16,7 +16,8 @@ use crate::openapi::{
AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, ErrorResponse,
FullRendererSnapshot, MediaServerSummary, OpenHomePlaylistAddRequest, OpenHomePlaylistSnapshot,
PlayContentRequest, QueueItem, QueueSnapshot, RendererCapabilitiesSummary,
RendererProtocolSummary, RendererState, RendererSummary, SuccessResponse, VolumeSetRequest,
RendererProtocolSummary, RendererState, RendererSummary, SeekQueueRequest, SuccessResponse,
VolumeSetRequest,
};
#[cfg(feature = "pmoserver")]
use crate::queue_backend::PlaybackItem;
@@ -621,6 +622,89 @@ async fn next_renderer(
}))
}
/// POST /control/renderers/{renderer_id}/queue/seek - Saute à un index spécifique dans la queue
#[cfg(feature = "pmoserver")]
#[utoipa::path(
post,
path = "/renderers/{renderer_id}/queue/seek",
tag = "control",
request_body = SeekQueueRequest,
responses(
(status = 200, description = "Lecture démarrée à l'index spécifié", body = SuccessResponse),
(status = 404, description = "Renderer non trouvé", body = ErrorResponse),
(status = 400, description = "Index invalide", body = ErrorResponse),
(status = 500, description = "Erreur interne", body = ErrorResponse),
)
)]
async fn seek_queue_index(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
Json(payload): Json<SeekQueueRequest>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = RendererId(renderer_id.clone());
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);
let rid_for_task = rid.clone();
let index = payload.index;
let seek_task = tokio::task::spawn_blocking(move || {
control_point.play_queue_index(&rid_for_task, index)
});
time::timeout(TRANSPORT_COMMAND_TIMEOUT, seek_task)
.await
.map_err(|_| {
warn!(
"Seek queue command for renderer {} exceeded {:?}",
renderer_id, TRANSPORT_COMMAND_TIMEOUT
);
(
StatusCode::GATEWAY_TIMEOUT,
Json(ErrorResponse {
error: format!(
"Seek command timed out after {}s",
TRANSPORT_COMMAND_TIMEOUT.as_secs()
),
}),
)
})?
.map_err(|e| {
warn!("Task join error during queue seek: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Internal task error: {}", e),
}),
)
})?
.map_err(|e| {
warn!(
"Failed to seek to index {} for renderer {}: {}",
index, renderer_id, e
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Failed to seek to index {}: {}", index, e),
}),
)
})?;
Ok(Json(SuccessResponse {
message: format!("Playing item at index {}", index),
}))
}
/// POST /control/renderers/{renderer_id}/volume/set - Définit le volume
#[cfg(feature = "pmoserver")]
#[utoipa::path(
@@ -1939,6 +2023,8 @@ pub fn create_api_router(state: ControlPointState, control_point: Arc<ControlPoi
.route("/renderers/{renderer_id}/stop", post(stop_renderer))
.route("/renderers/{renderer_id}/resume", post(resume_renderer))
.route("/renderers/{renderer_id}/next", post(next_renderer))
// Queue control
.route("/renderers/{renderer_id}/queue/seek", post(seek_queue_index))
// Volume control
.route(
"/renderers/{renderer_id}/volume/set",