Correction de la gestion des listes de playback.

This commit is contained in:
2025-12-03 21:49:41 +01:00
parent 23705c9c21
commit 1432f2507c
8 changed files with 249 additions and 34 deletions

View File

@@ -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("<no title>");
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()
}
}

View File

@@ -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<PlaybackItem>,
queue_current_index: Option<usize>,
show_queue_overlay: bool,
show_help_overlay: bool,
pending_binding_container: Option<String>,
status_line: String,
known_tracks: std::collections::HashMap<String, TrackMetadata>,
@@ -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(" <vide>"));
} else {
for (idx, item) in self.queue_snapshot.iter().enumerate() {
let title = item.title.as_deref().unwrap_or("<titre>");
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<Line<'static>>
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

View File

@@ -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<dyn std::error::Error>> {
// Initialiser le logging

View File

@@ -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<PlaybackItem>, Option<usize>)> {
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<PlaybackItem>, Option<usize>)> {
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))
}

View File

@@ -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<QueueItem>,
/// Index courant dans la playlist (None si rien n'est en cours)
pub current_index: Option<usize>,
}
// ============================================================================

View File

@@ -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<PlaybackItem>,
items: Vec<PlaybackItem>,
current_index: Option<usize>,
}
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<I: IntoIterator<Item = PlaybackItem>>(&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<PlaybackItem> {
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<PlaybackItem> {
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<PlaybackItem>, Option<usize>) {
(self.items.clone(), self.current_index)
}
}

View File

@@ -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<Json<QueueSnapshot>, (StatusCode, Json<ErrorResponse>)> {
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,
}))
}

View File

@@ -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")]