Implémentation du shuffle de la queue de lecture
Ajout de la fonctionnalité de shuffle pour mélanger l'ordre des morceaux dans la queue de lecture.
- Implémentation de la méthode shuffle_queue() dans MusicRenderer
- Ajout de l'endpoint REST /api/control/renderers/{renderer_id}/queue/shuffle
- Intégration du bouton shuffle dans l'interface Vue.js
- Centralisation des émissions d'événements SSE dans le MusicRenderer
- Mise à jour de la documentation OpenAPI
Cette fonctionnalité permet de randomiser l'ordre des morceaux dans la queue et de redémarrer la lecture depuis le premier morceau, tout en améliorant l'architecture en centralisant les émissions d'événements.
This commit is contained in:
@@ -25,6 +25,7 @@ futures-util = "0.3"
|
||||
smol = "2.0"
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
|
||||
# pmoserver extension support (optional)
|
||||
pmoserver = { path = "../pmoserver", optional = true }
|
||||
|
||||
@@ -657,11 +657,7 @@ impl ControlPoint {
|
||||
"Cleared playback queue"
|
||||
);
|
||||
|
||||
// Emit QueueUpdated event
|
||||
self.emit_renderer_event(RendererEvent::QueueUpdated {
|
||||
id: renderer_id.clone(),
|
||||
queue_length: 0,
|
||||
});
|
||||
// Note: QueueUpdated event is emitted automatically by MusicRenderer::clear_queue()
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -711,11 +707,34 @@ impl ControlPoint {
|
||||
"Enqueued playback items"
|
||||
);
|
||||
|
||||
// Emit QueueUpdated event
|
||||
self.emit_renderer_event(RendererEvent::QueueUpdated {
|
||||
id: renderer_id.clone(),
|
||||
queue_length: new_len,
|
||||
});
|
||||
// Note: QueueUpdated event is emitted automatically by MusicRenderer::enqueue_items()
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shuffles the queue of a renderer and restarts playback from the first track.
|
||||
///
|
||||
/// This method:
|
||||
/// 1. Detaches the queue from any attached playlist
|
||||
/// 2. Stops playback
|
||||
/// 3. Randomizes the order of tracks in the queue
|
||||
/// 4. Starts playback from the first track
|
||||
///
|
||||
/// Note: QueueUpdated event is emitted automatically by MusicRenderer::shuffle_queue()
|
||||
/// via its internal call to replace_queue().
|
||||
pub fn shuffle_queue(&self, renderer_id: &DeviceId) -> Result<(), ControlPointError> {
|
||||
let renderer = self.music_renderer_by_id(renderer_id).ok_or_else(|| {
|
||||
ControlPointError::SnapshotError(format!("Renderer {} not found", renderer_id.0))
|
||||
})?;
|
||||
|
||||
// Perform the shuffle (this also detaches playlist and restarts playback)
|
||||
renderer.shuffle_queue()?;
|
||||
|
||||
debug!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
queue_len = renderer.len().unwrap_or(0),
|
||||
"Shuffled playback queue"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1045,12 +1064,7 @@ impl ControlPoint {
|
||||
// Prefetch next track if supported
|
||||
self.prefetch_next_track(&renderer, renderer_id);
|
||||
|
||||
// Emit QueueUpdated event
|
||||
let queue_length = renderer.len().unwrap_or(0);
|
||||
self.emit_renderer_event(RendererEvent::QueueUpdated {
|
||||
id: renderer_id.clone(),
|
||||
queue_length,
|
||||
});
|
||||
// Note: QueueUpdated event is emitted automatically by MusicRenderer::play_next_from_queue()
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1265,7 +1279,7 @@ impl ControlPoint {
|
||||
ControlPointError::ControlPoint(format!("Renderer {} not found", renderer_id.0))
|
||||
})?;
|
||||
|
||||
renderer.set_playlist_binding(Some(binding.clone()));
|
||||
renderer.set_playlist_binding(Some(binding));
|
||||
info!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
server = server_id.0.as_str(),
|
||||
@@ -1274,10 +1288,7 @@ impl ControlPoint {
|
||||
"Queue attached to playlist container"
|
||||
);
|
||||
|
||||
self.emit_renderer_event(RendererEvent::BindingChanged {
|
||||
id: renderer_id.clone(),
|
||||
binding: Some(binding),
|
||||
});
|
||||
// Note: BindingChanged event is emitted automatically by MusicRenderer::set_playlist_binding()
|
||||
|
||||
// For initial attach with auto_play, force playback start (don't check if idle)
|
||||
let mut auto_start_cb = |rid: &DeviceId| {
|
||||
@@ -1446,10 +1457,13 @@ impl ControlPoint {
|
||||
None => return,
|
||||
};
|
||||
|
||||
let removed = renderer.get_playlist_binding();
|
||||
renderer.set_playlist_binding(None);
|
||||
let had_binding = renderer.get_playlist_binding();
|
||||
renderer.clear_playlist_binding();
|
||||
|
||||
if let Some(binding) = removed {
|
||||
// Note: BindingChanged event is emitted automatically by MusicRenderer::clear_playlist_binding()
|
||||
// only if there was a binding to remove
|
||||
|
||||
if let Some(binding) = had_binding {
|
||||
info!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
server = binding.server_id.0.as_str(),
|
||||
@@ -1457,10 +1471,6 @@ impl ControlPoint {
|
||||
reason = reason,
|
||||
"Playlist binding detached"
|
||||
);
|
||||
self.emit_renderer_event(RendererEvent::BindingChanged {
|
||||
id: renderer_id.clone(),
|
||||
binding: None,
|
||||
});
|
||||
} else {
|
||||
debug!(
|
||||
renderer = renderer_id.0.as_str(),
|
||||
|
||||
@@ -10,6 +10,8 @@ use std::sync::{Arc, Mutex};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use crate::errors::ControlPointError;
|
||||
use crate::events::RendererEventBus;
|
||||
use crate::model::RendererEvent;
|
||||
use crate::model::{PlaybackSource, PlaybackState, RendererInfo, RendererProtocol, TrackMetadata};
|
||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||
use crate::music_renderer::arylic_tcp::ArylicTcpRenderer;
|
||||
@@ -83,13 +85,31 @@ struct MusicRendererState {
|
||||
sleep_timer: SleepTimer,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct MusicRenderer {
|
||||
info: RendererInfo,
|
||||
connection: Arc<Mutex<DeviceConnectionState>>,
|
||||
backend: Arc<Mutex<MusicRendererBackend>>,
|
||||
playlist_binding: Arc<Mutex<Option<PlaylistBinding>>>,
|
||||
state: Arc<Mutex<MusicRendererState>>,
|
||||
/// Optional event bus for emitting queue change events.
|
||||
event_bus: Option<RendererEventBus>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for MusicRenderer {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("MusicRenderer")
|
||||
.field("info", &self.info)
|
||||
.field("connection", &self.connection)
|
||||
.field("backend", &self.backend)
|
||||
.field("playlist_binding", &self.playlist_binding)
|
||||
.field("state", &self.state)
|
||||
.field(
|
||||
"event_bus",
|
||||
&self.event_bus.as_ref().map(|_| "RendererEventBus"),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl MusicRenderer {
|
||||
@@ -105,12 +125,20 @@ impl MusicRenderer {
|
||||
backend,
|
||||
playlist_binding: Arc::new(Mutex::new(None)),
|
||||
state: Arc::new(Mutex::new(MusicRendererState::default())),
|
||||
event_bus: None,
|
||||
};
|
||||
|
||||
Arc::new(renderer)
|
||||
}
|
||||
|
||||
pub fn from_renderer_info(info: &RendererInfo) -> Result<MusicRenderer, ControlPointError> {
|
||||
Self::from_renderer_info_with_bus(info, None)
|
||||
}
|
||||
|
||||
pub fn from_renderer_info_with_bus(
|
||||
info: &RendererInfo,
|
||||
event_bus: Option<RendererEventBus>,
|
||||
) -> Result<MusicRenderer, ControlPointError> {
|
||||
let connection = Arc::new(Mutex::new(DeviceConnectionState::new()));
|
||||
let backend = MusicRendererBackend::make_from_renderer_info(info)?;
|
||||
|
||||
@@ -120,10 +148,22 @@ impl MusicRenderer {
|
||||
backend,
|
||||
playlist_binding: Arc::new(Mutex::new(None)),
|
||||
state: Arc::new(Mutex::new(MusicRendererState::default())),
|
||||
event_bus,
|
||||
};
|
||||
Ok(renderer)
|
||||
}
|
||||
|
||||
/// Helper method to emit a QueueUpdated event if an event bus is available.
|
||||
fn emit_queue_updated(&self) {
|
||||
if let Some(ref bus) = self.event_bus {
|
||||
let queue_length = self.len().unwrap_or(0);
|
||||
bus.broadcast(RendererEvent::QueueUpdated {
|
||||
id: self.id(),
|
||||
queue_length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn info(&self) -> &RendererInfo {
|
||||
&self.info
|
||||
}
|
||||
@@ -255,7 +295,9 @@ impl MusicRenderer {
|
||||
self.backend
|
||||
.lock()
|
||||
.expect("Backend mutex poisoned")
|
||||
.play_next()
|
||||
.play_next()?;
|
||||
self.emit_queue_updated();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Play from a specific index in the queue.
|
||||
@@ -263,7 +305,9 @@ impl MusicRenderer {
|
||||
self.backend
|
||||
.lock()
|
||||
.expect("Backend mutex poisoned")
|
||||
.play_from_index(index)
|
||||
.play_from_index(index)?;
|
||||
self.emit_queue_updated();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Transport control: play
|
||||
@@ -375,11 +419,24 @@ impl MusicRenderer {
|
||||
}
|
||||
|
||||
/// Sets the playlist binding for this renderer.
|
||||
/// Emits a `BindingChanged` event only if the binding actually changes.
|
||||
pub fn set_playlist_binding(&self, binding: Option<PlaylistBinding>) {
|
||||
*self
|
||||
let mut guard = self
|
||||
.playlist_binding
|
||||
.lock()
|
||||
.expect("Playlist binding mutex poisoned") = binding;
|
||||
.expect("Playlist binding mutex poisoned");
|
||||
|
||||
// Check if there's an actual change (both None, or different Some values)
|
||||
let old_is_some = guard.is_some();
|
||||
let new_is_some = binding.is_some();
|
||||
let has_changed = old_is_some != new_is_some || (old_is_some && new_is_some);
|
||||
|
||||
*guard = binding.clone();
|
||||
drop(guard);
|
||||
|
||||
if has_changed {
|
||||
self.emit_binding_changed(binding);
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the current playlist binding, if any.
|
||||
@@ -391,11 +448,30 @@ impl MusicRenderer {
|
||||
}
|
||||
|
||||
/// Clears the playlist binding.
|
||||
/// Emits a `BindingChanged` event with `None` only if there was a binding to clear.
|
||||
pub fn clear_playlist_binding(&self) {
|
||||
*self
|
||||
let mut guard = self
|
||||
.playlist_binding
|
||||
.lock()
|
||||
.expect("Playlist binding mutex poisoned") = None;
|
||||
.expect("Playlist binding mutex poisoned");
|
||||
|
||||
let had_binding = guard.is_some();
|
||||
*guard = None;
|
||||
drop(guard);
|
||||
|
||||
if had_binding {
|
||||
self.emit_binding_changed(None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper method to emit a BindingChanged event if an event bus is available.
|
||||
fn emit_binding_changed(&self, binding: Option<PlaylistBinding>) {
|
||||
if let Some(ref bus) = self.event_bus {
|
||||
bus.broadcast(RendererEvent::BindingChanged {
|
||||
id: self.id(),
|
||||
binding,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks the current playlist binding for refresh if it matches the given server and container.
|
||||
@@ -483,7 +559,10 @@ impl MusicRenderer {
|
||||
mode: EnqueueMode,
|
||||
) -> Result<(), ControlPointError> {
|
||||
let mut backend = self.backend.lock().expect("Backend mutex poisoned");
|
||||
backend.enqueue_items(items, mode)
|
||||
backend.enqueue_items(items, mode)?;
|
||||
drop(backend);
|
||||
self.emit_queue_updated();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Synchronize the queue with new items while preserving the current track.
|
||||
@@ -494,7 +573,10 @@ impl MusicRenderer {
|
||||
/// - If there's no current track, the queue is simply replaced
|
||||
pub fn sync_queue(&self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||
let mut backend = self.backend.lock().expect("Backend mutex poisoned");
|
||||
backend.sync_queue(items)
|
||||
backend.sync_queue(items)?;
|
||||
drop(backend);
|
||||
self.emit_queue_updated();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set the current queue index (for advanced use).
|
||||
@@ -507,7 +589,10 @@ impl MusicRenderer {
|
||||
/// Clears the renderer's queue using the generic QueueBackend trait.
|
||||
pub fn clear_queue(&self) -> Result<(), ControlPointError> {
|
||||
let mut backend = self.backend.lock().expect("Backend mutex poisoned");
|
||||
backend.clear_queue()
|
||||
backend.clear_queue()?;
|
||||
drop(backend);
|
||||
self.emit_queue_updated();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dequeues and returns the next item from the queue.
|
||||
@@ -530,7 +615,10 @@ impl MusicRenderer {
|
||||
current_index: Option<usize>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
let mut backend = self.backend.lock().expect("Backend mutex poisoned");
|
||||
backend.replace_queue(items, current_index)
|
||||
backend.replace_queue(items, current_index)?;
|
||||
drop(backend);
|
||||
self.emit_queue_updated();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Adds a track to the queue.
|
||||
@@ -726,6 +814,53 @@ impl MusicRenderer {
|
||||
state.sleep_timer.remaining_seconds(),
|
||||
)
|
||||
}
|
||||
|
||||
// --- Queue Shuffle ---
|
||||
|
||||
/// Shuffles the current queue and restarts playback from the first track.
|
||||
///
|
||||
/// This method:
|
||||
/// 1. Detaches the queue from any attached playlist
|
||||
/// 2. Stops playback
|
||||
/// 3. Takes a snapshot of the current queue
|
||||
/// 4. Randomizes the order of tracks
|
||||
/// 5. Replaces the queue with the shuffled items
|
||||
/// 6. Starts playback from the first track
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the queue is empty or if any backend operation fails.
|
||||
pub fn shuffle_queue(&self) -> Result<(), ControlPointError> {
|
||||
use rand::seq::SliceRandom;
|
||||
use rand::thread_rng;
|
||||
|
||||
// 1. Clear the playlist binding (detach from playlist)
|
||||
self.clear_playlist_binding();
|
||||
|
||||
// 2. Stop playback (ignore errors if already stopped)
|
||||
let _ = self.stop();
|
||||
|
||||
// 3. Get a snapshot of the current queue
|
||||
let snapshot = self.queue_snapshot()?;
|
||||
|
||||
if snapshot.items.is_empty() {
|
||||
return Err(ControlPointError::QueueError(
|
||||
"Cannot shuffle an empty queue".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// 4. Shuffle the items
|
||||
let mut shuffled_items = snapshot.items;
|
||||
let mut rng = thread_rng();
|
||||
shuffled_items.shuffle(&mut rng);
|
||||
|
||||
// 5. Replace the queue with shuffled items, starting at index 0
|
||||
self.replace_queue(shuffled_items, Some(0))?;
|
||||
|
||||
// 6. Start playback from the first track
|
||||
self.play_from_index(0)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to build DIDL-Lite metadata XML from TrackMetadata
|
||||
|
||||
@@ -389,6 +389,11 @@ POST /control/renderers/{renderer_id}/queue/seek
|
||||
Body: {"index": 5}
|
||||
```
|
||||
|
||||
### Mélanger la queue
|
||||
```
|
||||
POST /control/renderers/{renderer_id}/queue/shuffle
|
||||
```
|
||||
|
||||
### Contrôler le volume
|
||||
```
|
||||
POST /control/renderers/{renderer_id}/volume/set
|
||||
@@ -442,6 +447,7 @@ GET /control/servers/{server_id}/containers/{container_id}
|
||||
crate::pmoserver_ext::next_renderer,
|
||||
crate::pmoserver_ext::seek_renderer,
|
||||
crate::pmoserver_ext::seek_queue_index,
|
||||
crate::pmoserver_ext::shuffle_queue,
|
||||
crate::pmoserver_ext::set_renderer_volume,
|
||||
crate::pmoserver_ext::volume_up_renderer,
|
||||
crate::pmoserver_ext::volume_down_renderer,
|
||||
|
||||
@@ -1333,6 +1333,100 @@ async fn get_sleep_timer_state(
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HANDLERS - QUEUE SHUFFLE
|
||||
// ============================================================================
|
||||
|
||||
/// POST /control/renderers/{renderer_id}/queue/shuffle - Mélange la queue de lecture
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/renderers/{renderer_id}/queue/shuffle",
|
||||
params(
|
||||
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Queue mélangée et lecture démarrée", body = SuccessResponse),
|
||||
(status = 404, description = "Renderer non trouvé", body = ErrorResponse),
|
||||
(status = 400, description = "Queue vide", 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 shuffle_queue(
|
||||
State(state): State<ControlPointState>,
|
||||
Path(renderer_id): Path<String>,
|
||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let rid = DeviceId(renderer_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);
|
||||
let rid_for_task = rid.clone();
|
||||
let shuffle_task =
|
||||
tokio::task::spawn_blocking(move || control_point.shuffle_queue(&rid_for_task));
|
||||
|
||||
time::timeout(QUEUE_COMMAND_TIMEOUT, shuffle_task)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
warn!(
|
||||
"Shuffle command for renderer {} exceeded {:?}",
|
||||
renderer_id, QUEUE_COMMAND_TIMEOUT
|
||||
);
|
||||
(
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
Json(ErrorResponse {
|
||||
error: format!(
|
||||
"Shuffle command timed out after {}s",
|
||||
QUEUE_COMMAND_TIMEOUT.as_secs()
|
||||
),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.map_err(|e| {
|
||||
warn!("Task join error during shuffle: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Internal task error: {}", e),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.map_err(|e| {
|
||||
warn!(
|
||||
"Failed to shuffle queue for renderer {}: {}",
|
||||
renderer_id, e
|
||||
);
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Failed to shuffle queue: {}", e),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
debug!(
|
||||
renderer = renderer_id.as_str(),
|
||||
"Queue shuffled via HTTP API"
|
||||
);
|
||||
|
||||
Ok(Json(SuccessResponse {
|
||||
message: "Queue shuffled and playback started".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HANDLERS - BINDING PLAYLIST
|
||||
// ============================================================================
|
||||
@@ -2140,6 +2234,10 @@ pub fn create_api_router(state: ControlPointState, control_point: Arc<ControlPoi
|
||||
"/renderers/{renderer_id}/queue/seek",
|
||||
post(seek_queue_index),
|
||||
)
|
||||
.route(
|
||||
"/renderers/{renderer_id}/queue/shuffle",
|
||||
post(shuffle_queue),
|
||||
)
|
||||
// Volume control
|
||||
.route(
|
||||
"/renderers/{renderer_id}/volume/set",
|
||||
|
||||
@@ -175,7 +175,9 @@ impl DeviceRegistry {
|
||||
return;
|
||||
}
|
||||
// Entry existe mais pas de renderer -> on l'ajoute
|
||||
if let Ok(new_renderer) = MusicRenderer::from_renderer_info(info) {
|
||||
if let Ok(new_renderer) =
|
||||
MusicRenderer::from_renderer_info_with_bus(info, Some(self.renderer_bus.clone()))
|
||||
{
|
||||
entry.music_renderer = Some(Arc::new(new_renderer));
|
||||
self.udn_index
|
||||
.insert(info.udn().to_ascii_lowercase(), device_id.clone());
|
||||
@@ -188,7 +190,9 @@ impl DeviceRegistry {
|
||||
}
|
||||
} else {
|
||||
// Entry n'existe pas -> on crée
|
||||
if let Ok(new_renderer) = MusicRenderer::from_renderer_info(info) {
|
||||
if let Ok(new_renderer) =
|
||||
MusicRenderer::from_renderer_info_with_bus(info, Some(self.renderer_bus.clone()))
|
||||
{
|
||||
let new_entry = DeviceItem {
|
||||
music_renderer: Some(Arc::new(new_renderer)),
|
||||
music_server: None,
|
||||
|
||||
Reference in New Issue
Block a user