diff --git a/pmocontrol/examples/event_demo.rs b/pmocontrol/examples/event_demo.rs index fe051322..d9875ad1 100644 --- a/pmocontrol/examples/event_demo.rs +++ b/pmocontrol/examples/event_demo.rs @@ -18,6 +18,7 @@ use std::io; use std::thread; use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use pmocontrol::model::TrackMetadata; use pmocontrol::{ ControlPoint, DeviceRegistryRead, PlaybackPositionInfo, PlaybackState, RendererEvent, RendererId, RendererInfo, @@ -123,6 +124,8 @@ fn event_matches_id(event: &RendererEvent, id: &RendererId) -> bool { RendererEvent::PositionChanged { id: eid, .. } => eid == id, RendererEvent::VolumeChanged { id: eid, .. } => eid == id, RendererEvent::MuteChanged { id: eid, .. } => eid == id, + RendererEvent::MetadataChanged { id: eid, .. } => eid == id, + RendererEvent::QueueUpdated { id: eid, .. } => eid == id, } } @@ -188,5 +191,32 @@ fn print_event(event: &RendererEvent) { RendererEvent::MuteChanged { id, mute } => { println!("[{}] [{}] MuteChanged: {}", ts, id.0, mute); } + RendererEvent::MetadataChanged { id, metadata } => { + println!( + "[{}] [{}] MetadataChanged: {}", + ts, + id.0, + format_metadata(metadata) + ); + } + RendererEvent::QueueUpdated { id, queue_length } => { + println!( + "[{}] [{}] QueueUpdated: queue_length={}", + ts, id.0, queue_length + ); + } + } +} + +fn format_metadata(meta: &TrackMetadata) -> String { + let title = meta.title.as_deref().unwrap_or(""); + let artist = meta.artist.as_deref().unwrap_or(""); + let album = meta.album.as_deref().unwrap_or(""); + if !artist.is_empty() && !album.is_empty() { + format!("{} - {} ({})", artist, title, album) + } else if !artist.is_empty() { + format!("{} - {}", artist, title) + } else { + title.to_string() } } diff --git a/pmocontrol/examples/full_control_point_demo.rs b/pmocontrol/examples/full_control_point_demo.rs index dd49209d..f2f0ad0b 100644 --- a/pmocontrol/examples/full_control_point_demo.rs +++ b/pmocontrol/examples/full_control_point_demo.rs @@ -20,8 +20,8 @@ use crossterm::terminal::{ use pmocontrol::model::TrackMetadata; use pmocontrol::{ ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaResource, MediaServerEvent, - MediaServerInfo, MusicServer, PlaybackItem, PlaybackPositionInfo, RendererEvent, RendererInfo, - TransportControl, VolumeControl, + MediaServerInfo, MusicServer, PlaybackItem, PlaybackPosition, PlaybackStatus, + PlaybackPositionInfo, RendererEvent, RendererInfo, TransportControl, VolumeControl, }; use ratatui::backend::CrosstermBackend; use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; @@ -32,7 +32,6 @@ use ratatui::Terminal; const DEFAULT_TIMEOUT_SECS: u64 = 5; const DEFAULT_DISCOVERY_SECS: u64 = 15; -const PROGRESS_BAR_WIDTH: usize = 32; const TICK_RATE: Duration = Duration::from_millis(200); #[derive(Clone)] @@ -138,7 +137,9 @@ struct App { mode: Mode, ui_state: UiState, queue_snapshot: Vec, + queue_current_index: Option, show_queue_overlay: bool, + show_help_overlay: bool, pending_binding_container: Option, status_line: String, known_tracks: std::collections::HashMap, @@ -182,7 +183,9 @@ impl App { mode: Mode::SelectRenderer, ui_state: UiState::placeholder(), queue_snapshot: Vec::new(), + queue_current_index: None, show_queue_overlay: false, + show_help_overlay: false, pending_binding_container: None, status_line: "Sélectionne un renderer avec ↑/↓ et Entrée".to_string(), known_tracks: HashMap::new(), @@ -205,6 +208,10 @@ impl App { self.draw_queue_overlay(terminal); } + if self.show_help_overlay { + self.draw_help_overlay(terminal); + } + if matches!(self.mode, Mode::BindingPrompt) { self.draw_binding_prompt(terminal); } @@ -387,35 +394,60 @@ impl App { fn draw_help(&self, f: &mut ratatui::Frame<'_>, area: Rect) { let lines = vec![ - Line::from("Commandes: r=Play p=Pause s=Stop n=Next +/- Volume m=Mute"), - Line::from(" i=Infos k=Queue b=Binding h=Aide q=Quit"), - Line::from("Switch : R=Renderer menu | S=Serveur menu"), + Line::from("Commandes: h=Aide détaillée | R=Renderer | S=Serveur"), + Line::from(" r=Play p=Pause s=Stop n=Next"), + Line::from(" +=Vol+ -=Vol- m=Mute i=Infos k=Queue b=Binding"), + Line::from(" q=Quit | ESC ferme les overlays"), ]; let paragraph = - Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title("Aide")); + Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title("Raccourcis")); f.render_widget(paragraph, area); } + fn draw_help_overlay(&self, f: &mut ratatui::Frame<'_>) { + let area = centered_rect(70, 60, f.size()); + let lines = vec![ + Line::from("Raccourcis disponibles:"), + Line::from(" R / S : re-sélection du renderer / serveur"), + Line::from(" n / p : navigation dans la file"), + Line::from(" +/-/m : volume et mute"), + Line::from(" k : afficher la queue actuelle"), + Line::from(" b : afficher le binding playlist"), + Line::from(" h : fermer cette aide"), + Line::from(" ESC : fermer les overlays"), + ]; + let block = Block::default() + .title("Aide détaillée (h pour fermer)") + .borders(Borders::ALL) + .style(Style::default().bg(Color::Black)); + f.render_widget(Clear, area); + f.render_widget(Paragraph::new(lines).block(block), area); + } + fn draw_queue_overlay(&self, f: &mut ratatui::Frame<'_>) { let area = centered_rect(80, 70, f.size()); let mut lines = Vec::new(); - lines.push(Line::from("Queue actuelle:")); + lines.push(Line::from("Playlist actuelle (▶ = en cours):")); if self.queue_snapshot.is_empty() { lines.push(Line::from(" ")); } else { for (idx, item) in self.queue_snapshot.iter().enumerate() { let title = item.title.as_deref().unwrap_or(""); let artist = item.artist.as_deref().unwrap_or(""); + let prefix = match self.queue_current_index { + Some(current) if current == idx => "▶", + _ => " ", + }; let line = if artist.is_empty() { - format!("[{idx}] {title}") + format!("{prefix} [{idx}] {title}") } else { - format!("[{idx}] {artist} - {title}") + format!("{prefix} [{idx}] {artist} - {title}") }; lines.push(Line::from(line)); } } let block = Block::default() - .title("Queue (fermer avec k ou Esc)") + .title("Playlist (fermer avec k ou Esc)") .borders(Borders::ALL) .style(Style::default().bg(Color::Black)); f.render_widget(Clear, area); @@ -488,12 +520,15 @@ impl App { self.music_server = None; self.browser = None; self.queue_snapshot.clear(); + self.queue_current_index = None; self.known_tracks.clear(); self.pending_binding_container = None; self.ui_state.current_track_uri = None; self.ui_state.server_name = None; self.show_queue_overlay = false; + self.show_help_overlay = false; self.mode = Mode::SelectServer; + self.update_renderer_status_from_device(); self.status_line = "Sélectionne un serveur avec ↑/↓ et Entrée".to_string(); } _ => {} @@ -623,7 +658,12 @@ impl App { self.open_server_menu(); } KeyCode::Char('h') => { - self.ui_state.set_status("Aide affichée"); + self.show_help_overlay = !self.show_help_overlay; + if self.show_help_overlay { + self.ui_state.set_status("Aide ouverte"); + } else { + self.ui_state.set_status("Aide fermée"); + } } KeyCode::Char('p') => { self.pause_renderer()?; @@ -657,6 +697,7 @@ impl App { } KeyCode::Esc => { self.show_queue_overlay = false; + self.show_help_overlay = false; } _ => {} } @@ -684,7 +725,12 @@ impl App { self.control_point.clear_queue(&renderer_id)?; self.control_point.enqueue_items(&renderer_id, items)?; - self.queue_snapshot = self.control_point.get_queue_snapshot(&renderer_id)?; + let (full_queue, current_index) = + self.control_point + .get_full_queue_snapshot(&renderer_id) + .context("failed to snapshot queue after enqueue")?; + self.queue_snapshot = full_queue; + self.queue_current_index = current_index; self.record_queue_metadata(); self.pending_binding_container = Some(current_container_id); self.status_line = format!("File prête ({})", self.queue_snapshot.len()); @@ -715,6 +761,7 @@ impl App { self.pending_binding_container = None; self.mode = Mode::Control; self.start_playback()?; + self.update_renderer_status_from_device(); Ok(()) } @@ -827,11 +874,29 @@ impl App { Ok(()) } + fn update_renderer_status_from_device(&mut self) { + if let Ok(renderer) = self.get_renderer() { + if let Ok(volume) = renderer.volume() { + self.ui_state.volume = Some(volume); + } + if let Ok(mute) = renderer.mute() { + self.ui_state.mute = Some(mute); + } + if let Ok(state) = renderer.playback_state() { + self.ui_state.playback_state = Some(state); + } + if let Ok(position) = renderer.playback_position() { + self.ui_state.position = Some(position); + } + } + } + fn open_renderer_menu(&mut self) { self.mode = Mode::SelectRenderer; self.status_line = "Sélectionne un renderer avec ↑/↓ et Entrée".to_string(); self.ui_state.set_status("Menu renderer ouvert"); self.show_queue_overlay = false; + self.show_help_overlay = false; } fn open_server_menu(&mut self) { @@ -840,6 +905,7 @@ impl App { self.status_line = "Sélectionne un serveur avec ↑/↓ et Entrée".to_string(); self.ui_state.set_status("Menu serveur ouvert"); self.show_queue_overlay = false; + self.show_help_overlay = false; } else { self.ui_state.set_status("Sélectionne d'abord un renderer"); } @@ -882,9 +948,24 @@ impl App { } fn refresh_queue_snapshot(&mut self, renderer_id: &pmocontrol::model::RendererId) { - if let Ok(queue) = self.control_point.get_queue_snapshot(renderer_id) { + if let Ok((queue, current_index)) = + self.control_point.get_full_queue_snapshot(renderer_id) + { self.queue_snapshot = queue; + self.queue_current_index = current_index; self.record_queue_metadata(); + if let Some(idx) = self.queue_current_index { + if let Some(item) = self.queue_snapshot.get(idx).cloned() { + let needs_update = self + .ui_state + .current_track_uri + .as_deref() + != Some(item.uri.as_str()); + if needs_update { + self.apply_item_as_current(&item); + } + } + } } } @@ -965,6 +1046,13 @@ impl App { } } } + RendererEvent::QueueUpdated { id, queue_length } => { + if id == renderer_id { + self.refresh_queue_snapshot(&renderer_id); + self.ui_state + .set_status(format!("File mise à jour ({queue_length})")); + } + } } } @@ -1034,7 +1122,7 @@ fn run_app(mut app: App) -> Result<()> { } if !renderer_events_started { - if let Some(renderer_id) = app.renderer_id() { + if app.renderer_id().is_some() { start_event_threads(Arc::clone(&app.control_point), event_tx.clone()); renderer_events_started = true; } @@ -1241,17 +1329,28 @@ fn render_metadata_block(metadata: Option<&TrackMetadata>) -> Vec> lines.push(Line::from(format!("Genre : {genre}"))); } if let Some(date) = meta.date.as_deref() { - lines.push(Line::from(format!("Date : {date}"))); + lines.push(Line::from(format!("Date : {}", format_date(date)))); } if let Some(track) = meta.track_number.as_deref() { lines.push(Line::from(format!("Piste : {track}"))); } + if let Some(art) = meta.album_art_uri.as_deref() { + lines.push(Line::from(format!("Cover : {art}"))); + } } else { lines.push(Line::from("(En attente des métadonnées...)")); } lines } +fn format_date(raw: &str) -> String { + let parts: Vec<&str> = raw.split('-').collect(); + if parts.len() == 3 && parts[1] == "01" && parts[2] == "01" { + return parts[0].to_string(); + } + raw.to_string() +} + fn build_progress_gauge(position: &PlaybackPositionInfo) -> Gauge<'static> { let rel_secs = position.rel_time.as_deref().and_then(parse_time_to_seconds); let dur_secs = position diff --git a/pmocontrol/examples/pmomusic_integration_example.rs b/pmocontrol/examples/pmomusic_integration_example.rs index ad395589..c840e523 100644 --- a/pmocontrol/examples/pmomusic_integration_example.rs +++ b/pmocontrol/examples/pmomusic_integration_example.rs @@ -3,10 +3,21 @@ //! Cet exemple montre comment enregistrer le Control Point dans une application //! PMOMusic complète, en suivant le même pattern que les autres composants. +#[cfg(not(feature = "pmoserver"))] +fn main() { + eprintln!( + "This example requires the 'pmoserver' feature. Re-run with `--features pmoserver`." + ); +} + +#[cfg(feature = "pmoserver")] use pmocontrol::ControlPointExt; +#[cfg(feature = "pmoserver")] use pmoserver::Server; +#[cfg(feature = "pmoserver")] use tracing::info; +#[cfg(feature = "pmoserver")] #[tokio::main] async fn main() -> Result<(), Box> { // Initialiser le logging diff --git a/pmocontrol/src/control_point.rs b/pmocontrol/src/control_point.rs index 2eb19a7b..490a0cc2 100644 --- a/pmocontrol/src/control_point.rs +++ b/pmocontrol/src/control_point.rs @@ -505,7 +505,7 @@ impl ControlPoint { let removed = self .runtime .with_queue_mut(renderer_id, |queue| { - let removed = queue.len(); + let removed = queue.upcoming_len(); queue.clear(); removed }) @@ -549,7 +549,7 @@ impl ControlPoint { .runtime .with_queue_mut(renderer_id, |queue| { queue.enqueue_many(items); - queue.len() + queue.upcoming_len() }) .ok_or_else(|| Self::runtime_entry_missing(renderer_id))?; @@ -587,6 +587,24 @@ impl ControlPoint { .ok_or_else(|| Self::runtime_entry_missing(renderer_id)) } + pub fn get_full_queue_snapshot( + &self, + renderer_id: &RendererId, + ) -> anyhow::Result<(Vec, Option)> { + if !self.runtime.has_entry(renderer_id) { + let err = Self::runtime_entry_missing(renderer_id); + warn!( + renderer = renderer_id.0.as_str(), + "Cannot snapshot full queue: renderer not registered in runtime" + ); + return Err(err); + } + + self.runtime + .queue_full_snapshot(renderer_id) + .ok_or_else(|| Self::runtime_entry_missing(renderer_id)) + } + pub fn play_next_from_queue(&self, renderer_id: &RendererId) -> anyhow::Result<()> { if !self.runtime.has_entry(renderer_id) { let err = Self::runtime_entry_missing(renderer_id); @@ -912,11 +930,16 @@ impl RuntimeState { entries.get(id).map(|entry| entry.queue.snapshot()) } + fn queue_full_snapshot(&self, id: &RendererId) -> Option<(Vec, Option)> { + let entries = self.entries.lock().unwrap(); + entries.get(id).map(|entry| entry.queue.full_snapshot()) + } + fn dequeue_next(&self, id: &RendererId) -> Option<(PlaybackItem, usize)> { let mut entries = self.entries.lock().unwrap(); let entry = entries.get_mut(id)?; let item = entry.queue.dequeue()?; - let remaining = entry.queue.len(); + let remaining = entry.queue.upcoming_len(); Some((item, remaining)) } diff --git a/pmocontrol/src/openapi.rs b/pmocontrol/src/openapi.rs index 30551a97..f896d2a8 100644 --- a/pmocontrol/src/openapi.rs +++ b/pmocontrol/src/openapi.rs @@ -94,8 +94,10 @@ pub struct QueueItem { pub struct QueueSnapshot { /// ID du renderer pub renderer_id: String, - /// Items de la queue + /// Items de la queue (playlist complète) pub items: Vec, + /// Index courant dans la playlist (None si rien n'est en cours) + pub current_index: Option, } // ============================================================================ diff --git a/pmocontrol/src/playback_queue.rs b/pmocontrol/src/playback_queue.rs index 3062a95c..0aecb890 100644 --- a/pmocontrol/src/playback_queue.rs +++ b/pmocontrol/src/playback_queue.rs @@ -1,5 +1,3 @@ -use std::collections::VecDeque; - use crate::media_server::ServerId; #[derive(Clone, Debug)] @@ -37,13 +35,15 @@ impl PlaybackItem { #[derive(Clone, Debug, Default)] pub struct PlaybackQueue { - items: VecDeque, + items: Vec, + current_index: Option, } impl PlaybackQueue { pub fn new() -> Self { Self { - items: VecDeque::new(), + items: Vec::new(), + current_index: None, } } @@ -57,31 +57,76 @@ impl PlaybackQueue { pub fn clear(&mut self) { self.items.clear(); + self.current_index = None; } pub fn enqueue(&mut self, item: PlaybackItem) { - self.items.push_back(item); + self.items.push(item); } pub fn enqueue_many>(&mut self, items: I) { for item in items { - self.items.push_back(item); + self.items.push(item); } } pub fn enqueue_front(&mut self, item: PlaybackItem) { - self.items.push_front(item); + let insert_at = match self.current_index { + Some(idx) => { + let next = idx.saturating_add(1); + next.min(self.items.len()) + } + None => 0, + }; + self.items.insert(insert_at, item); + // current_index remains unchanged; insertion happens after the cursor. } pub fn dequeue(&mut self) -> Option { - self.items.pop_front() + if self.items.is_empty() { + return None; + } + + match self.current_index { + None => { + self.current_index = Some(0); + self.items.get(0).cloned() + } + Some(idx) => { + let next_idx = idx + 1; + if next_idx >= self.items.len() { + None + } else { + self.current_index = Some(next_idx); + self.items.get(next_idx).cloned() + } + } + } } pub fn peek(&self) -> Option<&PlaybackItem> { - self.items.front() + if let Some(idx) = self.current_index { + self.items.get(idx) + } else { + self.items.first() + } } pub fn snapshot(&self) -> Vec { - self.items.iter().cloned().collect() + match self.current_index { + None => self.items.clone(), + Some(idx) => self.items.iter().skip(idx + 1).cloned().collect(), + } + } + + pub fn upcoming_len(&self) -> usize { + match self.current_index { + None => self.items.len(), + Some(idx) => self.items.len().saturating_sub(idx + 1), + } + } + + pub fn full_snapshot(&self) -> (Vec, Option) { + (self.items.clone(), self.current_index) } } diff --git a/pmocontrol/src/pmoserver_ext.rs b/pmocontrol/src/pmoserver_ext.rs index 8e479010..742f11ec 100644 --- a/pmocontrol/src/pmoserver_ext.rs +++ b/pmocontrol/src/pmoserver_ext.rs @@ -182,7 +182,11 @@ async fn get_renderer_state( ("renderer_id" = String, Path, description = "ID unique du renderer") ), responses( - (status = 200, description = "Queue du renderer", body = QueueSnapshot), + ( + status = 200, + description = "Playlist complète du renderer (avec index courant)", + body = QueueSnapshot + ), (status = 404, description = "Renderer non trouvé", body = ErrorResponse) ), tag = "control" @@ -193,9 +197,9 @@ async fn get_renderer_queue( ) -> Result, (StatusCode, Json)> { let rid = RendererId(renderer_id.clone()); - let items = state + let (items, current_index) = state .control_point - .get_queue_snapshot(&rid) + .get_full_queue_snapshot(&rid) .map_err(|e| { ( StatusCode::NOT_FOUND, @@ -222,6 +226,7 @@ async fn get_renderer_queue( Ok(Json(QueueSnapshot { renderer_id, items: queue_items, + current_index, })) } diff --git a/pmodidl/src/lib.rs b/pmodidl/src/lib.rs index 1e4f4028..eb901736 100644 --- a/pmodidl/src/lib.rs +++ b/pmodidl/src/lib.rs @@ -223,7 +223,7 @@ pub struct Item { /// Ressource média (fichier audio) #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, Reflect)] pub struct Resource { - #[serde(rename = "@protocolInfo")] + #[serde(rename = "@protocolInfo", default)] pub protocol_info: String, #[serde(rename = "@bitsPerSample", skip_serializing_if = "Option::is_none")]