1848 lines
60 KiB
Rust
1848 lines
60 KiB
Rust
//! Remote REST-based control point demo using Ratatui.
|
|
//!
|
|
//! This example mirrors the UX of `full_control_point_demo.rs` but drives a
|
|
//! remote PMOMusic server exclusively through the `/api/control` REST API.
|
|
|
|
use std::env;
|
|
use std::fs::{File, OpenOptions};
|
|
use std::io::{self, Stdout, Write};
|
|
use std::process;
|
|
use std::sync::mpsc::TryRecvError;
|
|
use std::sync::{Arc, Mutex, mpsc};
|
|
use std::thread;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use anyhow::{Context, Result, anyhow, bail};
|
|
use crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent};
|
|
use crossterm::execute;
|
|
use crossterm::terminal::{
|
|
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
|
|
};
|
|
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
|
|
use ratatui::Terminal;
|
|
use ratatui::backend::CrosstermBackend;
|
|
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
|
|
use ratatui::style::{Color, Modifier, Style};
|
|
use ratatui::text::{Line, Span};
|
|
use ratatui::widgets::{Block, Borders, Clear, Gauge, List, ListItem, ListState, Paragraph};
|
|
use serde::de::{DeserializeOwned, Deserializer};
|
|
use serde::{Deserialize, Serialize};
|
|
use tracing::info;
|
|
use tracing_subscriber::EnvFilter;
|
|
use tracing_subscriber::fmt::writer::BoxMakeWriter;
|
|
use ureq::http;
|
|
use ureq::{Agent, Body};
|
|
|
|
const DEFAULT_BASE_URL: &str = "http://localhost:8080/api/control";
|
|
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(15);
|
|
const TICK_RATE: Duration = Duration::from_millis(200);
|
|
const ROOT_CONTAINERS: &[&str] = &["0", "0$"];
|
|
|
|
fn main() -> Result<()> {
|
|
// Install panic handler to restore terminal even on panic
|
|
std::panic::set_hook(Box::new(|panic_info| {
|
|
// Force terminal restoration
|
|
let _ = disable_raw_mode();
|
|
let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture);
|
|
eprintln!("\n\n❌ Application panicked: {:?}", panic_info);
|
|
eprintln!("Terminal has been restored. You can now close this window safely.");
|
|
}));
|
|
|
|
init_tracing();
|
|
let options = resolve_options()?;
|
|
info!(
|
|
base_url = %options.base_url,
|
|
timeout_ms = options.timeout.as_millis(),
|
|
"Démarrage du client REST"
|
|
);
|
|
println!("PMO Remote Control demo");
|
|
println!("Using Control API at {}", options.base_url);
|
|
|
|
let client = RestClient::new(&options.base_url, options.timeout)?;
|
|
let renderers = client
|
|
.list_renderers()
|
|
.context("Impossible de récupérer la liste des renderers")?;
|
|
if renderers.is_empty() {
|
|
eprintln!(
|
|
"Aucun renderer disponible via {}. Lancement annulé.",
|
|
options.base_url
|
|
);
|
|
return Ok(());
|
|
}
|
|
|
|
let app = App::new(client, renderers);
|
|
if let Err(err) = run_app(app) {
|
|
eprintln!("Application fermée avec erreur: {err}");
|
|
}
|
|
|
|
println!("\nAu revoir !");
|
|
Ok(())
|
|
}
|
|
|
|
struct AppOptions {
|
|
base_url: String,
|
|
timeout: Duration,
|
|
}
|
|
|
|
fn resolve_options() -> Result<AppOptions> {
|
|
let mut args = env::args().skip(1);
|
|
let mut cli_base: Option<String> = None;
|
|
let mut cli_timeout: Option<u64> = None;
|
|
while let Some(arg) = args.next() {
|
|
match arg.as_str() {
|
|
"--base-url" => {
|
|
let value = args
|
|
.next()
|
|
.ok_or_else(|| anyhow!("--base-url requiert une valeur"))?;
|
|
cli_base = Some(value);
|
|
}
|
|
"--timeout-ms" => {
|
|
let value = args
|
|
.next()
|
|
.ok_or_else(|| anyhow!("--timeout-ms requiert une valeur"))?;
|
|
let millis: u64 = value
|
|
.parse()
|
|
.with_context(|| format!("Valeur invalide pour --timeout-ms: {value}"))?;
|
|
cli_timeout = Some(millis);
|
|
}
|
|
"--help" | "-h" => {
|
|
print_usage();
|
|
process::exit(0);
|
|
}
|
|
other => bail!("Argument inconnu: {other}. Utilise --help pour l'aide."),
|
|
}
|
|
}
|
|
let base = cli_base
|
|
.or_else(|| env::var("PMO_REMOTE_BASE_URL").ok())
|
|
.unwrap_or_else(|| DEFAULT_BASE_URL.to_string());
|
|
let timeout_ms = cli_timeout
|
|
.or_else(|| {
|
|
env::var("PMO_REMOTE_TIMEOUT_MS")
|
|
.ok()
|
|
.and_then(|v| v.parse().ok())
|
|
})
|
|
.unwrap_or_else(|| DEFAULT_TIMEOUT.as_millis() as u64);
|
|
let timeout = Duration::from_millis(timeout_ms.max(1));
|
|
Ok(AppOptions {
|
|
base_url: base,
|
|
timeout,
|
|
})
|
|
}
|
|
|
|
fn print_usage() {
|
|
println!(
|
|
"Usage: cargo run -p pmocontrol --example pmo_remote_control [-- --base-url <url> --timeout-ms <ms>]"
|
|
);
|
|
println!("Variables d'environnement:");
|
|
println!(
|
|
" PMO_REMOTE_BASE_URL Override la base de l'API REST (par défaut {DEFAULT_BASE_URL})"
|
|
);
|
|
println!(
|
|
" PMO_REMOTE_TIMEOUT_MS Timeout HTTP global en millisecondes (par défaut {})",
|
|
DEFAULT_TIMEOUT.as_millis()
|
|
);
|
|
println!(
|
|
" PMO_REMOTE_LOG_FILE Écrit les logs tracing dans ce fichier (append) au lieu de stderr"
|
|
);
|
|
println!(
|
|
" RUST_LOG Active le filtrage tracing/log (ex: pmocontrol=debug,ureq=debug)"
|
|
);
|
|
}
|
|
|
|
fn init_tracing() {
|
|
let _ = tracing_log::LogTracer::init();
|
|
let writer = log_writer();
|
|
let env_filter = EnvFilter::try_from_default_env()
|
|
.or_else(|_| EnvFilter::try_new("info"))
|
|
.unwrap_or_else(|_| EnvFilter::new("info"));
|
|
let _ = tracing_subscriber::fmt()
|
|
.with_env_filter(env_filter)
|
|
.with_writer(writer)
|
|
.try_init();
|
|
}
|
|
|
|
fn log_writer() -> BoxMakeWriter {
|
|
if let Ok(path) = env::var("PMO_REMOTE_LOG_FILE") {
|
|
match OpenOptions::new().create(true).append(true).open(&path) {
|
|
Ok(file) => {
|
|
let shared = SharedLogWriter::new(file);
|
|
let writer = BoxMakeWriter::new(move || shared.clone());
|
|
return writer;
|
|
}
|
|
Err(err) => {
|
|
eprintln!(
|
|
"Impossible d'ouvrir {path} pour les logs tracing: {err}. Retour à stderr"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
BoxMakeWriter::new(io::stderr)
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct SharedLogWriter {
|
|
inner: Arc<Mutex<File>>,
|
|
}
|
|
|
|
impl SharedLogWriter {
|
|
fn new(file: File) -> Self {
|
|
Self {
|
|
inner: Arc::new(Mutex::new(file)),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Write for SharedLogWriter {
|
|
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
|
let mut guard = self
|
|
.inner
|
|
.lock()
|
|
.map_err(|err| io::Error::new(io::ErrorKind::Other, err.to_string()))?;
|
|
guard.write(buf)
|
|
}
|
|
|
|
fn flush(&mut self) -> io::Result<()> {
|
|
let mut guard = self
|
|
.inner
|
|
.lock()
|
|
.map_err(|err| io::Error::new(io::ErrorKind::Other, err.to_string()))?;
|
|
guard.flush()
|
|
}
|
|
}
|
|
|
|
struct App {
|
|
client: RestClient,
|
|
renderers: Vec<RendererSummaryClient>,
|
|
renderer_index: usize,
|
|
selected_renderer: Option<RendererSummaryClient>,
|
|
servers: Vec<MediaServerSummaryClient>,
|
|
server_index: usize,
|
|
selected_server: Option<MediaServerSummaryClient>,
|
|
browser: Option<BrowserState>,
|
|
mode: Mode,
|
|
ui_state: UiState,
|
|
queue_snapshot: Vec<QueueItemClient>,
|
|
queue_current_index: Option<usize>,
|
|
binding_info: Option<AttachedPlaylistInfoClient>,
|
|
show_queue_overlay: bool,
|
|
show_help_overlay: bool,
|
|
pending_binding: Option<PendingBinding>,
|
|
status_line: String,
|
|
binding_worker: Option<mpsc::Receiver<BindingWorkerMessage>>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
enum Mode {
|
|
SelectRenderer,
|
|
SelectServer,
|
|
Browse,
|
|
BindingPrompt,
|
|
Control,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct UiState {
|
|
renderer_name: String,
|
|
server_name: Option<String>,
|
|
transport_state: Option<String>,
|
|
progress: Option<PlaybackProgress>,
|
|
volume: Option<u8>,
|
|
mute: Option<bool>,
|
|
metadata: Option<TrackMetadata>,
|
|
last_status: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct PlaybackProgress {
|
|
position_ms: Option<u64>,
|
|
duration_ms: Option<u64>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct TrackMetadata {
|
|
title: Option<String>,
|
|
artist: Option<String>,
|
|
album: Option<String>,
|
|
album_art_uri: Option<String>,
|
|
}
|
|
|
|
struct PendingBinding {
|
|
container_id: String,
|
|
container_title: String,
|
|
}
|
|
|
|
enum BindingWorkerMessage {
|
|
Success { container_title: String },
|
|
Failure { error: String },
|
|
}
|
|
|
|
struct BrowserState {
|
|
server_id: String,
|
|
nav_state: NavigationState,
|
|
entries: Vec<ContainerEntryClient>,
|
|
selected_index: usize,
|
|
}
|
|
|
|
struct NavigationState {
|
|
path_stack: Vec<(String, String)>,
|
|
current_container_id: String,
|
|
current_container_title: String,
|
|
}
|
|
|
|
impl App {
|
|
fn new(client: RestClient, renderers: Vec<RendererSummaryClient>) -> Self {
|
|
Self {
|
|
client,
|
|
renderers,
|
|
renderer_index: 0,
|
|
selected_renderer: None,
|
|
servers: Vec::new(),
|
|
server_index: 0,
|
|
selected_server: None,
|
|
browser: None,
|
|
mode: Mode::SelectRenderer,
|
|
ui_state: UiState::placeholder(),
|
|
queue_snapshot: Vec::new(),
|
|
queue_current_index: None,
|
|
binding_info: None,
|
|
show_queue_overlay: false,
|
|
show_help_overlay: false,
|
|
pending_binding: None,
|
|
status_line: "Sélectionne un renderer avec ↑/↓ puis Entrée".to_string(),
|
|
binding_worker: None,
|
|
}
|
|
}
|
|
|
|
fn draw(&self, f: &mut ratatui::Frame<'_>) {
|
|
match self.mode {
|
|
Mode::SelectRenderer => self.draw_renderer_selection(f),
|
|
Mode::SelectServer => self.draw_server_selection(f),
|
|
Mode::Browse => self.draw_browser(f),
|
|
_ => self.draw_control_screen(f),
|
|
};
|
|
|
|
if self.show_queue_overlay {
|
|
self.draw_queue_overlay(f);
|
|
}
|
|
if self.show_help_overlay {
|
|
self.draw_help_overlay(f);
|
|
}
|
|
if matches!(self.mode, Mode::BindingPrompt) {
|
|
self.draw_binding_prompt(f);
|
|
}
|
|
self.draw_status_line(f);
|
|
}
|
|
|
|
fn draw_renderer_selection(&self, f: &mut ratatui::Frame<'_>) {
|
|
let area = f.size();
|
|
let block = Block::default()
|
|
.borders(Borders::ALL)
|
|
.title("Sélection du renderer");
|
|
let items: Vec<ListItem> = self
|
|
.renderers
|
|
.iter()
|
|
.map(|info| {
|
|
let status = if info.online {
|
|
"[en ligne]"
|
|
} else {
|
|
"[hors ligne]"
|
|
};
|
|
let text = format!("{status} {} | {}", info.friendly_name, info.model_name);
|
|
ListItem::new(text)
|
|
})
|
|
.collect();
|
|
let list = List::new(items)
|
|
.block(block)
|
|
.highlight_style(
|
|
Style::default()
|
|
.fg(Color::Yellow)
|
|
.add_modifier(Modifier::BOLD),
|
|
)
|
|
.highlight_symbol("▶ ");
|
|
let mut state = ListState::default();
|
|
if !self.renderers.is_empty() {
|
|
state.select(Some(
|
|
self.renderer_index
|
|
.min(self.renderers.len().saturating_sub(1)),
|
|
));
|
|
}
|
|
f.render_stateful_widget(list, area, &mut state);
|
|
}
|
|
|
|
fn draw_server_selection(&self, f: &mut ratatui::Frame<'_>) {
|
|
let area = f.size();
|
|
let block = Block::default()
|
|
.borders(Borders::ALL)
|
|
.title("Sélection du serveur");
|
|
let items: Vec<ListItem> = self
|
|
.servers
|
|
.iter()
|
|
.map(|info| {
|
|
let status = if info.online {
|
|
"[en ligne]"
|
|
} else {
|
|
"[hors ligne]"
|
|
};
|
|
let text = format!("{status} {} | {}", info.friendly_name, info.model_name);
|
|
ListItem::new(text)
|
|
})
|
|
.collect();
|
|
let list = List::new(items)
|
|
.block(block)
|
|
.highlight_style(
|
|
Style::default()
|
|
.fg(Color::Cyan)
|
|
.add_modifier(Modifier::BOLD),
|
|
)
|
|
.highlight_symbol("▶ ");
|
|
let mut state = ListState::default();
|
|
if !self.servers.is_empty() {
|
|
state.select(Some(
|
|
self.server_index.min(self.servers.len().saturating_sub(1)),
|
|
));
|
|
}
|
|
f.render_stateful_widget(list, area, &mut state);
|
|
}
|
|
|
|
fn draw_browser(&self, f: &mut ratatui::Frame<'_>) {
|
|
let area = f.size();
|
|
let Some(browser) = &self.browser else {
|
|
return;
|
|
};
|
|
|
|
let block = Block::default().borders(Borders::ALL).title(Span::styled(
|
|
format!(
|
|
"Navigation: {} (id: {})",
|
|
browser.nav_state.current_container_title, browser.nav_state.current_container_id
|
|
),
|
|
Style::default()
|
|
.fg(Color::White)
|
|
.add_modifier(Modifier::BOLD),
|
|
));
|
|
|
|
let items: Vec<ListItem> = browser
|
|
.entries
|
|
.iter()
|
|
.map(|entry| {
|
|
let icon = if entry.is_container { "📁" } else { "♪" };
|
|
let text = format!("{icon} {}", entry.title);
|
|
ListItem::new(text)
|
|
})
|
|
.collect();
|
|
|
|
let list = List::new(items)
|
|
.block(block)
|
|
.highlight_style(
|
|
Style::default()
|
|
.fg(Color::Green)
|
|
.add_modifier(Modifier::BOLD),
|
|
)
|
|
.highlight_symbol("▶ ");
|
|
let mut state = ListState::default();
|
|
if !browser.entries.is_empty() {
|
|
state.select(Some(
|
|
browser
|
|
.selected_index
|
|
.min(browser.entries.len().saturating_sub(1)),
|
|
));
|
|
}
|
|
f.render_stateful_widget(list, area, &mut state);
|
|
}
|
|
|
|
fn draw_control_screen(&self, f: &mut ratatui::Frame<'_>) {
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Length(5),
|
|
Constraint::Min(8),
|
|
Constraint::Length(3),
|
|
])
|
|
.split(f.size());
|
|
|
|
self.draw_header(f, chunks[0]);
|
|
self.draw_playback_panel(f, chunks[1]);
|
|
self.draw_help_strip(f, chunks[2]);
|
|
}
|
|
|
|
fn draw_header(&self, f: &mut ratatui::Frame<'_>, area: Rect) {
|
|
let ui = &self.ui_state;
|
|
let renderer = &ui.renderer_name;
|
|
let server = ui.server_name.as_deref().unwrap_or("<serveur?>");
|
|
let state = ui
|
|
.transport_state
|
|
.as_deref()
|
|
.unwrap_or("État inconnu")
|
|
.to_string();
|
|
let volume = ui
|
|
.volume
|
|
.map(|v| v.to_string())
|
|
.unwrap_or_else(|| "--".to_string());
|
|
let mute = match ui.mute {
|
|
Some(true) => "ON",
|
|
Some(false) => "OFF",
|
|
None => "??",
|
|
};
|
|
|
|
let binding = self
|
|
.binding_info
|
|
.as_ref()
|
|
.map(|info| format!("{} / {}", info.server_id, info.container_id))
|
|
.unwrap_or_else(|| "<aucun binding>".to_string());
|
|
|
|
let text = vec![
|
|
Line::from(vec![Span::styled(
|
|
format!("Renderer : {renderer}"),
|
|
Style::default().fg(Color::Yellow),
|
|
)]),
|
|
Line::from(vec![Span::raw(format!("Serveur : {server}"))]),
|
|
Line::from(vec![Span::raw(format!(
|
|
"État : {state} Volume {volume} | Mute {mute}"
|
|
))]),
|
|
Line::from(vec![Span::raw(format!("Playlist : {binding}"))]),
|
|
];
|
|
|
|
let paragraph = Paragraph::new(text)
|
|
.block(Block::default().borders(Borders::ALL).title("Statut"))
|
|
.alignment(Alignment::Left);
|
|
f.render_widget(paragraph, area);
|
|
}
|
|
|
|
fn draw_playback_panel(&self, f: &mut ratatui::Frame<'_>, area: Rect) {
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([Constraint::Min(6), Constraint::Length(3)])
|
|
.split(area);
|
|
|
|
let meta_lines = render_metadata_block(self.ui_state.metadata.as_ref());
|
|
let paragraph = Paragraph::new(meta_lines)
|
|
.block(
|
|
Block::default()
|
|
.borders(Borders::ALL)
|
|
.title("Lecture en cours"),
|
|
)
|
|
.wrap(ratatui::widgets::Wrap { trim: true });
|
|
f.render_widget(paragraph, chunks[0]);
|
|
|
|
let gauge = match self.ui_state.progress.as_ref() {
|
|
Some(progress) => build_progress_gauge(progress),
|
|
None => Gauge::default()
|
|
.block(Block::default().borders(Borders::ALL).title("Progression"))
|
|
.label("en attente...")
|
|
.ratio(0.0),
|
|
};
|
|
f.render_widget(gauge, chunks[1]);
|
|
}
|
|
|
|
fn draw_help_strip(&self, f: &mut ratatui::Frame<'_>, area: Rect) {
|
|
let lines = vec![
|
|
Line::from("Commandes: h=Aide | R=Renderer | S=Serveur | B=Browse"),
|
|
Line::from(" Espace=Play/Pause s=Stop n=Next m=Mute +/-=Volume k=Queue"),
|
|
Line::from(" b=Binding dans le browser | q=Quit | ESC ferme les overlays"),
|
|
];
|
|
let paragraph =
|
|
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électionner renderer / serveur"),
|
|
Line::from(" B : revenir au navigateur depuis l'écran principal"),
|
|
Line::from(" ↑/↓ ou +/- : ajuster le volume (via REST)"),
|
|
Line::from(" k / h : toggle queue ou aide"),
|
|
Line::from(
|
|
" Browser : Entrée=ouvrir, ←/Backspace ou r=retour, s ou b=sélectionner",
|
|
),
|
|
Line::from(" Binding prompt : y=confirmer, n=annuler"),
|
|
Line::from(" ESC : fermer overlay courant"),
|
|
];
|
|
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("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!("{prefix} [{idx}] {title}")
|
|
} else {
|
|
format!("{prefix} [{idx}] {artist} - {title}")
|
|
};
|
|
lines.push(Line::from(line));
|
|
}
|
|
}
|
|
let block = Block::default()
|
|
.title("Playlist (fermer avec k ou Esc)")
|
|
.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_binding_prompt(&self, f: &mut ratatui::Frame<'_>) {
|
|
let area = centered_rect(60, 40, f.size());
|
|
let Some(binding) = &self.pending_binding else {
|
|
return;
|
|
};
|
|
let renderer = self.ui_state.renderer_name.clone();
|
|
let server = self
|
|
.selected_server
|
|
.as_ref()
|
|
.map(|s| s.friendly_name.clone())
|
|
.unwrap_or_else(|| "<serveur?>".to_string());
|
|
let lines = vec![
|
|
Line::from("Attacher ce conteneur comme playlist distante ?"),
|
|
Line::from(format!("Renderer: {renderer}")),
|
|
Line::from(format!("Serveur : {server}")),
|
|
Line::from(format!(
|
|
"Container: {} ({})",
|
|
binding.container_title, binding.container_id
|
|
)),
|
|
Line::from("y = oui | n = non"),
|
|
];
|
|
let block = Block::default()
|
|
.title("Binding playlist")
|
|
.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_status_line(&self, f: &mut ratatui::Frame<'_>) {
|
|
let area = Rect {
|
|
x: 0,
|
|
y: f.size().height.saturating_sub(1),
|
|
width: f.size().width,
|
|
height: 1,
|
|
};
|
|
let status = self
|
|
.ui_state
|
|
.last_status
|
|
.clone()
|
|
.unwrap_or_else(|| self.status_line.clone());
|
|
let paragraph = Paragraph::new(status).style(Style::default().fg(Color::Gray));
|
|
f.render_widget(paragraph, area);
|
|
}
|
|
|
|
fn handle_key(&mut self, key: KeyEvent) -> Result<bool> {
|
|
match self.mode {
|
|
Mode::SelectRenderer => self.handle_renderer_key(key),
|
|
Mode::SelectServer => self.handle_server_key(key),
|
|
Mode::Browse => self.handle_browse_key(key),
|
|
Mode::BindingPrompt => self.handle_binding_key(key),
|
|
Mode::Control => self.handle_control_key(key),
|
|
}
|
|
}
|
|
|
|
fn handle_renderer_key(&mut self, key: KeyEvent) -> Result<bool> {
|
|
match key.code {
|
|
KeyCode::Char('q') => return Ok(true),
|
|
KeyCode::Esc => return Ok(true),
|
|
KeyCode::Up => {
|
|
if self.renderer_index > 0 {
|
|
self.renderer_index -= 1;
|
|
}
|
|
}
|
|
KeyCode::Down => {
|
|
if self.renderer_index + 1 < self.renderers.len() {
|
|
self.renderer_index += 1;
|
|
}
|
|
}
|
|
KeyCode::Enter => {
|
|
let info = self.renderers[self.renderer_index].clone();
|
|
let previous_renderer_id = self
|
|
.selected_renderer
|
|
.as_ref()
|
|
.map(|renderer| renderer.id.clone());
|
|
let mut stop_error: Option<String> = None;
|
|
if let Some(prev_id) = previous_renderer_id {
|
|
if prev_id != info.id {
|
|
if let Err(err) = self.client.stop(&prev_id) {
|
|
stop_error = Some(format!(
|
|
"Renderer sélectionné mais arrêt de l'ancien impossible: {err}"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
self.selected_renderer = Some(info.clone());
|
|
self.ui_state = UiState::new(info.friendly_name.clone());
|
|
self.queue_snapshot.clear();
|
|
self.queue_current_index = None;
|
|
self.binding_info = None;
|
|
self.selected_server = None;
|
|
self.browser = None;
|
|
self.show_help_overlay = false;
|
|
self.show_queue_overlay = false;
|
|
self.pending_binding = None;
|
|
self.mode = Mode::SelectServer;
|
|
self.status_line = "Sélectionne un serveur avec ↑/↓ puis Entrée".to_string();
|
|
self.load_servers();
|
|
self.refresh_renderer_state();
|
|
self.refresh_queue();
|
|
self.refresh_binding_info();
|
|
if let Some(message) = stop_error {
|
|
self.ui_state.set_status(message);
|
|
} else {
|
|
self.ui_state.set_status("Renderer sélectionné");
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
Ok(false)
|
|
}
|
|
|
|
fn handle_server_key(&mut self, key: KeyEvent) -> Result<bool> {
|
|
match key.code {
|
|
KeyCode::Char('q') => return Ok(true),
|
|
KeyCode::Esc => {
|
|
self.mode = Mode::SelectRenderer;
|
|
self.status_line = "Sélectionne un renderer avec ↑/↓ puis Entrée".to_string();
|
|
}
|
|
KeyCode::Up => {
|
|
if self.server_index > 0 {
|
|
self.server_index -= 1;
|
|
}
|
|
}
|
|
KeyCode::Down => {
|
|
if self.server_index + 1 < self.servers.len() {
|
|
self.server_index += 1;
|
|
}
|
|
}
|
|
KeyCode::Enter => {
|
|
if self.servers.is_empty() {
|
|
self.ui_state
|
|
.set_status("Aucun serveur disponible. Vérifie le backend.");
|
|
return Ok(false);
|
|
}
|
|
let info = self.servers[self.server_index].clone();
|
|
self.selected_server = Some(info.clone());
|
|
self.ui_state.server_name = Some(info.friendly_name.clone());
|
|
match self.load_browser_for_server(&info) {
|
|
Ok(_) => {
|
|
self.mode = Mode::Browse;
|
|
self.status_line =
|
|
"Navigue avec ↑/↓, Entrée pour ouvrir, ←/Backspace ou r pour remonter, s pour sélectionner"
|
|
.to_string();
|
|
self.ui_state.set_status("Serveur sélectionné");
|
|
}
|
|
Err(err) => {
|
|
self.ui_state
|
|
.set_status(format!("Navigation impossible: {err}"));
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
Ok(false)
|
|
}
|
|
|
|
fn handle_browse_key(&mut self, key: KeyEvent) -> Result<bool> {
|
|
if key.code == KeyCode::Char('q') {
|
|
return Ok(true);
|
|
}
|
|
if self.browser.is_none() {
|
|
return Ok(false);
|
|
}
|
|
|
|
match key.code {
|
|
KeyCode::Up => {
|
|
if let Some(browser) = self.browser.as_mut() {
|
|
if browser.selected_index > 0 {
|
|
browser.selected_index -= 1;
|
|
}
|
|
}
|
|
}
|
|
KeyCode::Down => {
|
|
if let Some(browser) = self.browser.as_mut() {
|
|
if browser.selected_index + 1 < browser.entries.len() {
|
|
browser.selected_index += 1;
|
|
}
|
|
}
|
|
}
|
|
KeyCode::Enter => {
|
|
let entry = self
|
|
.browser
|
|
.as_ref()
|
|
.and_then(|b| b.current_entry().cloned());
|
|
if let Some(entry) = entry {
|
|
if entry.is_container {
|
|
self.enter_container(entry)?;
|
|
} else {
|
|
self.ui_state
|
|
.set_status("Sélectionne un dossier pour le binding.");
|
|
}
|
|
}
|
|
}
|
|
KeyCode::Char('s') | KeyCode::Char('b') => {
|
|
let entry = self
|
|
.browser
|
|
.as_ref()
|
|
.and_then(|b| b.current_entry().cloned());
|
|
if let Some(entry) = entry {
|
|
if entry.is_container {
|
|
self.pending_binding = Some(PendingBinding {
|
|
container_id: entry.id,
|
|
container_title: entry.title,
|
|
});
|
|
self.mode = Mode::BindingPrompt;
|
|
self.ui_state.set_status("Confirme le binding (y/n)");
|
|
} else {
|
|
self.ui_state
|
|
.set_status("Impossible de binder un item individuel.");
|
|
}
|
|
}
|
|
}
|
|
KeyCode::Left | KeyCode::Backspace => {
|
|
self.navigate_browser_up();
|
|
}
|
|
KeyCode::Char('r') | KeyCode::Char('R') => {
|
|
self.navigate_browser_up();
|
|
}
|
|
KeyCode::Char('h') => {
|
|
self.show_help_overlay = !self.show_help_overlay;
|
|
}
|
|
KeyCode::Esc => {
|
|
self.mode = Mode::SelectServer;
|
|
self.status_line = "Sélectionne un serveur avec ↑/↓ puis Entrée".to_string();
|
|
self.show_help_overlay = false;
|
|
self.show_queue_overlay = false;
|
|
}
|
|
_ => {}
|
|
}
|
|
Ok(false)
|
|
}
|
|
|
|
fn handle_binding_key(&mut self, key: KeyEvent) -> Result<bool> {
|
|
match key.code {
|
|
KeyCode::Char('y') => {
|
|
self.show_help_overlay = false;
|
|
self.show_queue_overlay = false;
|
|
self.attach_binding(true)?;
|
|
}
|
|
KeyCode::Char('n') | KeyCode::Esc => {
|
|
self.show_help_overlay = false;
|
|
self.show_queue_overlay = false;
|
|
self.attach_binding(false)?;
|
|
}
|
|
KeyCode::Char('q') => return Ok(true),
|
|
_ => {}
|
|
}
|
|
Ok(false)
|
|
}
|
|
|
|
fn handle_control_key(&mut self, key: KeyEvent) -> Result<bool> {
|
|
match key.code {
|
|
KeyCode::Char('q') => return Ok(true),
|
|
KeyCode::Char('R') => {
|
|
self.open_renderer_menu();
|
|
}
|
|
KeyCode::Char('S') => {
|
|
self.open_server_menu();
|
|
}
|
|
KeyCode::Char('B') => {
|
|
if self.browser.is_some() {
|
|
self.mode = Mode::Browse;
|
|
self.status_line =
|
|
"Navigue avec ↑/↓, Entrée pour ouvrir, ←/Backspace ou r pour remonter, s pour sélectionner"
|
|
.to_string();
|
|
} else {
|
|
self.ui_state
|
|
.set_status("Pas de navigateur actif. Reprends la sélection serveur.");
|
|
}
|
|
}
|
|
KeyCode::Char('h') => {
|
|
self.show_help_overlay = !self.show_help_overlay;
|
|
}
|
|
KeyCode::Char('k') => {
|
|
self.show_queue_overlay = !self.show_queue_overlay;
|
|
}
|
|
KeyCode::Esc => {
|
|
self.show_queue_overlay = false;
|
|
self.show_help_overlay = false;
|
|
}
|
|
KeyCode::Char(' ') => {
|
|
self.toggle_play_pause()?;
|
|
}
|
|
KeyCode::Char('p') => {
|
|
self.pause_renderer()?;
|
|
}
|
|
KeyCode::Char('s') => {
|
|
self.stop_renderer()?;
|
|
}
|
|
KeyCode::Char('n') => {
|
|
self.play_next()?;
|
|
}
|
|
KeyCode::Char('+') | KeyCode::Char('=') | KeyCode::Up => {
|
|
self.volume_up()?;
|
|
}
|
|
KeyCode::Char('-') | KeyCode::Down => {
|
|
self.volume_down()?;
|
|
}
|
|
KeyCode::Char('m') => {
|
|
self.toggle_mute()?;
|
|
}
|
|
_ => {}
|
|
}
|
|
Ok(false)
|
|
}
|
|
|
|
fn load_servers(&mut self) {
|
|
match self.client.list_servers() {
|
|
Ok(list) => {
|
|
self.servers = list;
|
|
self.server_index = 0;
|
|
if self.servers.is_empty() {
|
|
self.ui_state
|
|
.set_status("Aucun serveur disponible. Vérifie PMOMusic.");
|
|
}
|
|
}
|
|
Err(err) => {
|
|
self.ui_state
|
|
.set_status(format!("Erreur REST serveurs: {err}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn load_browser_for_server(&mut self, info: &MediaServerSummaryClient) -> Result<()> {
|
|
let (root_id, entries) = self.fetch_root_entries(&info.id)?;
|
|
self.browser = Some(BrowserState::new(
|
|
info.id.clone(),
|
|
root_id,
|
|
entries,
|
|
info.friendly_name.clone(),
|
|
));
|
|
Ok(())
|
|
}
|
|
|
|
fn fetch_root_entries(&self, server_id: &str) -> Result<(String, Vec<ContainerEntryClient>)> {
|
|
let mut last_err: Option<anyhow::Error> = None;
|
|
for &candidate in ROOT_CONTAINERS {
|
|
match self.client.browse_container(server_id, candidate) {
|
|
Ok(resp) => return Ok((resp.container_id, resp.entries)),
|
|
Err(err) => last_err = Some(err),
|
|
}
|
|
}
|
|
Err(last_err.unwrap_or_else(|| {
|
|
anyhow!("Impossible de parcourir la racine pour le serveur {server_id}")
|
|
}))
|
|
}
|
|
|
|
fn enter_container(&mut self, entry: ContainerEntryClient) -> Result<()> {
|
|
let Some(browser) = self.browser.as_mut() else {
|
|
return Ok(());
|
|
};
|
|
let container_id = entry.id.clone();
|
|
let container_title = entry.title.clone();
|
|
match self
|
|
.client
|
|
.browse_container(&browser.server_id, &container_id)
|
|
{
|
|
Ok(resp) => {
|
|
browser
|
|
.nav_state
|
|
.enter_container(container_id, container_title);
|
|
browser.entries = resp.entries;
|
|
browser.selected_index = 0;
|
|
}
|
|
Err(err) => {
|
|
self.ui_state
|
|
.set_status(format!("Impossible d'ouvrir: {err}"));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn navigate_browser_up(&mut self) {
|
|
let (server_id, container_id) = {
|
|
let Some(browser) = self.browser.as_mut() else {
|
|
return;
|
|
};
|
|
if browser.nav_state.go_back() {
|
|
(
|
|
browser.server_id.clone(),
|
|
browser.nav_state.current_container_id.clone(),
|
|
)
|
|
} else {
|
|
self.ui_state.set_status("Déjà à la racine.");
|
|
return;
|
|
}
|
|
};
|
|
match self.client.browse_container(&server_id, &container_id) {
|
|
Ok(resp) => {
|
|
if let Some(browser) = self.browser.as_mut() {
|
|
browser.entries = resp.entries;
|
|
browser.selected_index = 0;
|
|
}
|
|
}
|
|
Err(err) => {
|
|
self.ui_state
|
|
.set_status(format!("Retour impossible: {err}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn attach_binding(&mut self, attach: bool) -> Result<()> {
|
|
if attach {
|
|
if self.binding_worker.is_some() {
|
|
self.ui_state
|
|
.set_status("Binding déjà en cours. Patiente quelques secondes...");
|
|
return Ok(());
|
|
}
|
|
let Some(renderer) = self.selected_renderer.as_ref() else {
|
|
self.ui_state.set_status("Choisis un renderer en premier.");
|
|
return Ok(());
|
|
};
|
|
let Some(server) = self.selected_server.as_ref() else {
|
|
self.ui_state.set_status("Choisis un serveur en premier.");
|
|
return Ok(());
|
|
};
|
|
let Some(binding) = self.pending_binding.take() else {
|
|
return Ok(());
|
|
};
|
|
let client = self.client.clone();
|
|
let renderer_id = renderer.id.clone();
|
|
let server_id = server.id.clone();
|
|
let container_id = binding.container_id.clone();
|
|
let container_title = binding.container_title.clone();
|
|
let (tx, rx) = mpsc::channel();
|
|
self.binding_worker = Some(rx);
|
|
self.mode = Mode::Browse;
|
|
self.status_line =
|
|
"Binding en cours... patiente pendant la préparation de la playlist".to_string();
|
|
self.show_help_overlay = false;
|
|
self.show_queue_overlay = false;
|
|
self.ui_state
|
|
.set_status(format!("Association en cours: {}", container_title));
|
|
thread::spawn(move || {
|
|
let outcome = (|| -> Result<()> {
|
|
client.attach_playlist(&renderer_id, &server_id, &container_id)?;
|
|
// Préparer la lecture en sélectionnant le premier item
|
|
client.next(&renderer_id)?;
|
|
Ok(())
|
|
})();
|
|
let message = match outcome {
|
|
Ok(_) => BindingWorkerMessage::Success { container_title },
|
|
Err(err) => BindingWorkerMessage::Failure {
|
|
error: err.to_string(),
|
|
},
|
|
};
|
|
let _ = tx.send(message);
|
|
});
|
|
} else {
|
|
self.pending_binding = None;
|
|
self.mode = Mode::Browse;
|
|
self.status_line =
|
|
"Navigue avec ↑/↓, Entrée pour ouvrir, ←/Backspace ou r pour remonter, s pour sélectionner".to_string();
|
|
self.ui_state.set_status("Binding annulé");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn toggle_play_pause(&mut self) -> Result<()> {
|
|
let Some(renderer) = self.selected_renderer.as_ref() else {
|
|
return Ok(());
|
|
};
|
|
let current = self
|
|
.ui_state
|
|
.transport_state
|
|
.as_deref()
|
|
.unwrap_or("")
|
|
.to_string();
|
|
if current.eq_ignore_ascii_case("PLAYING") {
|
|
self.client.pause(&renderer.id)?;
|
|
self.ui_state.set_status("Pause envoyée");
|
|
} else {
|
|
self.client.play(&renderer.id)?;
|
|
self.ui_state.set_status("Lecture envoyée");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn pause_renderer(&mut self) -> Result<()> {
|
|
if let Some(renderer) = self.selected_renderer.as_ref() {
|
|
self.client.pause(&renderer.id)?;
|
|
self.ui_state.set_status("Pause envoyée");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn stop_renderer(&mut self) -> Result<()> {
|
|
if let Some(renderer) = self.selected_renderer.as_ref() {
|
|
self.client.stop(&renderer.id)?;
|
|
self.ui_state.set_status("Stop envoyé");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn play_next(&mut self) -> Result<()> {
|
|
if let Some(renderer) = self.selected_renderer.as_ref() {
|
|
self.client.next(&renderer.id)?;
|
|
self.ui_state.set_status("Piste suivante demandée");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn volume_up(&mut self) -> Result<()> {
|
|
if let Some(renderer) = self.selected_renderer.as_ref() {
|
|
self.client.volume_up(&renderer.id)?;
|
|
self.ui_state.set_status("Volume +");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn volume_down(&mut self) -> Result<()> {
|
|
if let Some(renderer) = self.selected_renderer.as_ref() {
|
|
self.client.volume_down(&renderer.id)?;
|
|
self.ui_state.set_status("Volume -");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn toggle_mute(&mut self) -> Result<()> {
|
|
if let Some(renderer) = self.selected_renderer.as_ref() {
|
|
self.client.toggle_mute(&renderer.id)?;
|
|
self.ui_state.set_status("Mute togglé");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn open_renderer_menu(&mut self) {
|
|
match self.client.list_renderers() {
|
|
Ok(list) => {
|
|
self.renderers = list;
|
|
self.renderer_index = 0;
|
|
self.mode = Mode::SelectRenderer;
|
|
self.status_line = "Sélectionne un renderer avec ↑/↓ puis Entrée".to_string();
|
|
self.show_help_overlay = false;
|
|
self.show_queue_overlay = false;
|
|
}
|
|
Err(err) => {
|
|
self.ui_state
|
|
.set_status(format!("Impossible de rafraîchir les renderers: {err}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn open_server_menu(&mut self) {
|
|
if self.selected_renderer.is_none() {
|
|
self.ui_state.set_status("Sélectionne d'abord un renderer.");
|
|
return;
|
|
}
|
|
self.load_servers();
|
|
self.mode = Mode::SelectServer;
|
|
self.status_line = "Sélectionne un serveur avec ↑/↓ puis Entrée".to_string();
|
|
self.show_help_overlay = false;
|
|
self.show_queue_overlay = false;
|
|
}
|
|
|
|
fn refresh_renderer_state(&mut self) {
|
|
let Some(renderer) = self.selected_renderer.as_ref() else {
|
|
return;
|
|
};
|
|
match self.client.get_renderer_state(&renderer.id) {
|
|
Ok(state) => {
|
|
self.ui_state.transport_state = Some(state.transport_state.clone());
|
|
self.ui_state.volume = state.volume;
|
|
self.ui_state.mute = state.mute;
|
|
self.ui_state.progress = Some(PlaybackProgress {
|
|
position_ms: state.position_ms,
|
|
duration_ms: state.duration_ms,
|
|
});
|
|
if let Some(info) = state.attached_playlist {
|
|
self.binding_info = Some(info);
|
|
}
|
|
}
|
|
Err(err) => {
|
|
self.ui_state
|
|
.set_status(format!("Erreur REST renderer: {err}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn refresh_queue(&mut self) {
|
|
let Some(renderer) = self.selected_renderer.as_ref() else {
|
|
return;
|
|
};
|
|
let current_signature = self.capture_current_queue_signature();
|
|
match self.client.get_renderer_queue(&renderer.id) {
|
|
Ok(snapshot) => {
|
|
let next_items = snapshot.items;
|
|
let mut next_index = snapshot.current_index;
|
|
if !Self::is_valid_queue_index(next_index, &next_items) {
|
|
next_index = current_signature
|
|
.and_then(|sig| Self::find_queue_index_by_signature(&next_items, &sig));
|
|
}
|
|
self.queue_snapshot = next_items;
|
|
self.queue_current_index = next_index;
|
|
self.update_current_track_metadata();
|
|
}
|
|
Err(err) => {
|
|
self.ui_state
|
|
.set_status(format!("Erreur REST queue: {err}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn capture_current_queue_signature(&self) -> Option<QueueItemSignature> {
|
|
let idx = self.queue_current_index?;
|
|
let item = self.queue_snapshot.get(idx)?;
|
|
Some(QueueItemSignature::from_item(item))
|
|
}
|
|
|
|
fn is_valid_queue_index(index: Option<usize>, items: &[QueueItemClient]) -> bool {
|
|
match index {
|
|
Some(idx) => idx < items.len(),
|
|
None => false,
|
|
}
|
|
}
|
|
|
|
fn find_queue_index_by_signature(
|
|
items: &[QueueItemClient],
|
|
signature: &QueueItemSignature,
|
|
) -> Option<usize> {
|
|
items.iter().enumerate().find_map(|(idx, item)| {
|
|
if signature.matches(item) {
|
|
Some(idx)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
}
|
|
|
|
fn refresh_binding_info(&mut self) {
|
|
let Some(renderer) = self.selected_renderer.as_ref() else {
|
|
return;
|
|
};
|
|
match self.client.get_renderer_binding(&renderer.id) {
|
|
Ok(binding) => {
|
|
self.binding_info = binding;
|
|
}
|
|
Err(err) => {
|
|
self.ui_state
|
|
.set_status(format!("Erreur REST binding: {err}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn update_current_track_metadata(&mut self) {
|
|
if let Some(idx) = self.queue_current_index {
|
|
if let Some(item) = self.queue_snapshot.get(idx) {
|
|
self.ui_state.metadata = Some(TrackMetadata {
|
|
title: item.title.clone(),
|
|
artist: item.artist.clone(),
|
|
album: item.album.clone(),
|
|
album_art_uri: item.album_art_uri.clone(),
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
self.ui_state.metadata = None;
|
|
}
|
|
|
|
fn on_tick(&mut self) {
|
|
self.poll_binding_worker();
|
|
if self.mode != Mode::Control {
|
|
return;
|
|
}
|
|
self.refresh_renderer_state();
|
|
self.refresh_queue();
|
|
self.refresh_binding_info();
|
|
}
|
|
|
|
fn poll_binding_worker(&mut self) {
|
|
let Some(receiver) = self.binding_worker.as_ref() else {
|
|
return;
|
|
};
|
|
match receiver.try_recv() {
|
|
Ok(BindingWorkerMessage::Success { container_title }) => {
|
|
self.binding_worker = None;
|
|
self.finish_binding_success(container_title);
|
|
}
|
|
Ok(BindingWorkerMessage::Failure { error }) => {
|
|
self.binding_worker = None;
|
|
self.finish_binding_failure(error);
|
|
}
|
|
Err(TryRecvError::Empty) => {}
|
|
Err(TryRecvError::Disconnected) => {
|
|
self.binding_worker = None;
|
|
self.finish_binding_failure("Worker binding interrompu (canal fermé)".to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
fn finish_binding_success(&mut self, container_title: String) {
|
|
self.mode = Mode::Control;
|
|
self.status_line = "Espace=Play/Pause, n=Next, +/- volume, k=Queue, B=Browse".to_string();
|
|
self.show_help_overlay = false;
|
|
self.show_queue_overlay = false;
|
|
self.ui_state
|
|
.set_status(format!("Lecture lancée depuis {container_title}"));
|
|
self.refresh_binding_info();
|
|
self.refresh_queue();
|
|
self.refresh_renderer_state();
|
|
}
|
|
|
|
fn finish_binding_failure(&mut self, error: String) {
|
|
self.mode = Mode::Browse;
|
|
self.status_line =
|
|
"Navigue avec ↑/↓, Entrée pour ouvrir, ←/Backspace ou r pour remonter, s pour sélectionner".to_string();
|
|
self.show_help_overlay = false;
|
|
self.show_queue_overlay = false;
|
|
self.ui_state
|
|
.set_status(format!("Binding impossible: {error}"));
|
|
}
|
|
}
|
|
|
|
impl UiState {
|
|
fn new(renderer_name: String) -> Self {
|
|
Self {
|
|
renderer_name,
|
|
server_name: None,
|
|
transport_state: None,
|
|
progress: None,
|
|
volume: None,
|
|
mute: None,
|
|
metadata: None,
|
|
last_status: Some("Interface initialisée.".to_string()),
|
|
}
|
|
}
|
|
|
|
fn placeholder() -> Self {
|
|
Self::new("<renderer non sélectionné>".to_string())
|
|
}
|
|
|
|
fn set_status<S: Into<String>>(&mut self, status: S) {
|
|
self.last_status = Some(status.into());
|
|
}
|
|
}
|
|
|
|
impl BrowserState {
|
|
fn new(
|
|
server_id: String,
|
|
root_container_id: String,
|
|
entries: Vec<ContainerEntryClient>,
|
|
friendly_name: String,
|
|
) -> Self {
|
|
Self {
|
|
server_id,
|
|
nav_state: NavigationState::new(root_container_id, friendly_name),
|
|
entries,
|
|
selected_index: 0,
|
|
}
|
|
}
|
|
|
|
fn current_entry(&self) -> Option<&ContainerEntryClient> {
|
|
self.entries.get(self.selected_index)
|
|
}
|
|
}
|
|
|
|
impl NavigationState {
|
|
fn new(root_id: String, root_title: String) -> Self {
|
|
Self {
|
|
path_stack: Vec::new(),
|
|
current_container_id: root_id,
|
|
current_container_title: root_title,
|
|
}
|
|
}
|
|
|
|
fn enter_container(&mut self, container_id: String, container_title: String) {
|
|
self.path_stack.push((
|
|
self.current_container_id.clone(),
|
|
self.current_container_title.clone(),
|
|
));
|
|
self.current_container_id = container_id;
|
|
self.current_container_title = container_title;
|
|
}
|
|
|
|
fn go_back(&mut self) -> bool {
|
|
if let Some((parent_id, parent_title)) = self.path_stack.pop() {
|
|
self.current_container_id = parent_id;
|
|
self.current_container_title = parent_title;
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
fn run_app(mut app: App) -> Result<()> {
|
|
let terminal = setup_terminal()?;
|
|
let mut guard = TerminalGuard { terminal };
|
|
let mut last_tick = Instant::now();
|
|
|
|
let result = (|| -> Result<()> {
|
|
loop {
|
|
guard.terminal.draw(|f| app.draw(f))?;
|
|
|
|
let timeout = TICK_RATE
|
|
.checked_sub(last_tick.elapsed())
|
|
.unwrap_or_else(|| Duration::from_secs(0));
|
|
|
|
if event::poll(timeout)? {
|
|
if let Event::Key(key) = event::read()? {
|
|
if app.handle_key(key)? {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if last_tick.elapsed() >= TICK_RATE {
|
|
app.on_tick();
|
|
last_tick = Instant::now();
|
|
}
|
|
}
|
|
Ok(())
|
|
})();
|
|
|
|
// Restauration garantie via Drop de TerminalGuard
|
|
// On tente un stop avec timeout court
|
|
if let Some(renderer) = app.selected_renderer.as_ref() {
|
|
// Utiliser un timeout très court pour ne pas bloquer le shutdown
|
|
let _ = app.client.stop(&renderer.id);
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>> {
|
|
enable_raw_mode()?;
|
|
let mut stdout = io::stdout();
|
|
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
|
let backend = CrosstermBackend::new(stdout);
|
|
let terminal = Terminal::new(backend)?;
|
|
Ok(terminal)
|
|
}
|
|
|
|
fn render_metadata_block(metadata: Option<&TrackMetadata>) -> Vec<Line<'static>> {
|
|
let mut lines = Vec::new();
|
|
if let Some(meta) = metadata {
|
|
let title = meta
|
|
.title
|
|
.clone()
|
|
.unwrap_or_else(|| "<Titre inconnu>".to_string());
|
|
lines.push(Line::from(format!("Titre : {title}")));
|
|
if let Some(artist) = meta.artist.as_deref() {
|
|
lines.push(Line::from(format!("Artiste: {artist}")));
|
|
}
|
|
if let Some(album) = meta.album.as_deref() {
|
|
lines.push(Line::from(format!("Album : {album}")));
|
|
}
|
|
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 build_progress_gauge(progress: &PlaybackProgress) -> Gauge<'static> {
|
|
let ratio = match (progress.position_ms, progress.duration_ms) {
|
|
(Some(pos), Some(dur)) if dur > 0 => (pos as f64 / dur as f64).clamp(0.0, 1.0),
|
|
_ => 0.0,
|
|
};
|
|
let label = format!(
|
|
"{} / {}",
|
|
progress
|
|
.position_ms
|
|
.and_then(format_time_ms)
|
|
.unwrap_or_else(|| "--:--".to_string()),
|
|
progress
|
|
.duration_ms
|
|
.and_then(format_time_ms)
|
|
.unwrap_or_else(|| "--:--".to_string())
|
|
);
|
|
Gauge::default()
|
|
.block(Block::default().borders(Borders::ALL).title("Progression"))
|
|
.gauge_style(Style::default().fg(Color::Magenta))
|
|
.ratio(ratio)
|
|
.label(label)
|
|
}
|
|
|
|
fn format_time_ms(ms: u64) -> Option<String> {
|
|
let total_seconds = ms / 1000;
|
|
let hours = total_seconds / 3600;
|
|
let minutes = (total_seconds % 3600) / 60;
|
|
let seconds = total_seconds % 60;
|
|
if hours > 0 {
|
|
Some(format!("{hours:02}:{minutes:02}:{seconds:02}"))
|
|
} else {
|
|
Some(format!("{minutes:02}:{seconds:02}"))
|
|
}
|
|
}
|
|
|
|
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
|
|
let popup_layout = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints(
|
|
[
|
|
Constraint::Percentage((100 - percent_y) / 2),
|
|
Constraint::Percentage(percent_y),
|
|
Constraint::Percentage((100 - percent_y) / 2),
|
|
]
|
|
.as_ref(),
|
|
)
|
|
.split(r);
|
|
|
|
Layout::default()
|
|
.direction(Direction::Horizontal)
|
|
.constraints(
|
|
[
|
|
Constraint::Percentage((100 - percent_x) / 2),
|
|
Constraint::Percentage(percent_x),
|
|
Constraint::Percentage((100 - percent_x) / 2),
|
|
]
|
|
.as_ref(),
|
|
)
|
|
.split(popup_layout[1])[1]
|
|
}
|
|
|
|
/// RAII guard pour garantir la restauration du terminal même en cas d'erreur ou de panic
|
|
struct TerminalGuard {
|
|
terminal: Terminal<CrosstermBackend<Stdout>>,
|
|
}
|
|
|
|
impl Drop for TerminalGuard {
|
|
fn drop(&mut self) {
|
|
// Force la restauration du terminal, même si les appels échouent
|
|
let _ = disable_raw_mode();
|
|
let _ = execute!(
|
|
self.terminal.backend_mut(),
|
|
LeaveAlternateScreen,
|
|
DisableMouseCapture
|
|
);
|
|
let _ = self.terminal.show_cursor();
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// REST DTOs
|
|
// ============================================================================
|
|
|
|
#[allow(dead_code)]
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
struct RendererSummaryClient {
|
|
id: String,
|
|
friendly_name: String,
|
|
model_name: String,
|
|
protocol: String,
|
|
online: bool,
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
struct RendererStateClient {
|
|
id: String,
|
|
friendly_name: String,
|
|
transport_state: String,
|
|
position_ms: Option<u64>,
|
|
duration_ms: Option<u64>,
|
|
volume: Option<u8>,
|
|
mute: Option<bool>,
|
|
queue_len: usize,
|
|
attached_playlist: Option<AttachedPlaylistInfoClient>,
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
struct AttachedPlaylistInfoClient {
|
|
server_id: String,
|
|
container_id: String,
|
|
has_seen_update: bool,
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
struct QueueItemClient {
|
|
index: usize,
|
|
uri: String,
|
|
title: Option<String>,
|
|
artist: Option<String>,
|
|
album: Option<String>,
|
|
server_id: Option<String>,
|
|
object_id: Option<String>,
|
|
album_art_uri: Option<String>,
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
struct QueueSnapshotClient {
|
|
renderer_id: String,
|
|
#[serde(default, deserialize_with = "deserialize_nullable_vec")]
|
|
items: Vec<QueueItemClient>,
|
|
current_index: Option<usize>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct QueueItemSignature {
|
|
server_id: Option<String>,
|
|
object_id: Option<String>,
|
|
uri: String,
|
|
}
|
|
|
|
impl QueueItemSignature {
|
|
fn from_item(item: &QueueItemClient) -> Self {
|
|
Self {
|
|
server_id: item.server_id.clone(),
|
|
object_id: item.object_id.clone(),
|
|
uri: item.uri.clone(),
|
|
}
|
|
}
|
|
|
|
fn matches(&self, other: &QueueItemClient) -> bool {
|
|
if let (Some(sig_obj), Some(other_obj)) = (&self.object_id, &other.object_id) {
|
|
if sig_obj == other_obj {
|
|
if let (Some(sig_server), Some(other_server)) = (&self.server_id, &other.server_id)
|
|
{
|
|
return sig_server == other_server;
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
self.uri == other.uri
|
|
}
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
struct MediaServerSummaryClient {
|
|
id: String,
|
|
friendly_name: String,
|
|
model_name: String,
|
|
online: bool,
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
struct ContainerEntryClient {
|
|
id: String,
|
|
title: String,
|
|
class: String,
|
|
is_container: bool,
|
|
child_count: Option<u32>,
|
|
artist: Option<String>,
|
|
album: Option<String>,
|
|
album_art_uri: Option<String>,
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
struct BrowseResponseClient {
|
|
container_id: String,
|
|
#[serde(default, deserialize_with = "deserialize_nullable_vec")]
|
|
entries: Vec<ContainerEntryClient>,
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
#[derive(Debug, Serialize)]
|
|
struct VolumeSetRequest {
|
|
volume: u8,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct AttachPlaylistRequest<'a> {
|
|
server_id: &'a str,
|
|
container_id: &'a str,
|
|
}
|
|
|
|
fn deserialize_nullable_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
T: Deserialize<'de>,
|
|
{
|
|
let opt = Option::<Vec<T>>::deserialize(deserializer)?;
|
|
Ok(opt.unwrap_or_default())
|
|
}
|
|
|
|
// ============================================================================
|
|
// REST CLIENT
|
|
// ============================================================================
|
|
|
|
struct RestClient {
|
|
base_url: String,
|
|
agent: Agent,
|
|
}
|
|
|
|
impl Clone for RestClient {
|
|
fn clone(&self) -> Self {
|
|
Self {
|
|
base_url: self.base_url.clone(),
|
|
agent: self.agent.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl RestClient {
|
|
fn new(base_url: &str, timeout: Duration) -> Result<Self> {
|
|
let mut builder = Agent::config_builder();
|
|
builder = builder.timeout_global(Some(timeout));
|
|
builder = builder.http_status_as_error(false);
|
|
let config = builder.build();
|
|
let agent: Agent = config.into();
|
|
Ok(Self {
|
|
base_url: base_url.trim_end_matches('/').to_string(),
|
|
agent,
|
|
})
|
|
}
|
|
|
|
fn list_renderers(&self) -> Result<Vec<RendererSummaryClient>> {
|
|
self.get_json(&["renderers"])
|
|
}
|
|
|
|
fn get_renderer_state(&self, id: &str) -> Result<RendererStateClient> {
|
|
self.get_json(&["renderers", id])
|
|
}
|
|
|
|
fn get_renderer_queue(&self, id: &str) -> Result<QueueSnapshotClient> {
|
|
self.get_json(&["renderers", id, "queue"])
|
|
}
|
|
|
|
fn get_renderer_binding(&self, id: &str) -> Result<Option<AttachedPlaylistInfoClient>> {
|
|
self.get_json(&["renderers", id, "binding"])
|
|
}
|
|
|
|
fn play(&self, id: &str) -> Result<()> {
|
|
self.post_empty(&["renderers", id, "play"])
|
|
}
|
|
|
|
fn pause(&self, id: &str) -> Result<()> {
|
|
self.post_empty(&["renderers", id, "pause"])
|
|
}
|
|
|
|
fn stop(&self, id: &str) -> Result<()> {
|
|
self.post_empty(&["renderers", id, "stop"])
|
|
}
|
|
|
|
fn next(&self, id: &str) -> Result<()> {
|
|
self.post_empty(&["renderers", id, "next"])
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
fn set_volume(&self, id: &str, volume: u8) -> Result<()> {
|
|
let payload = VolumeSetRequest { volume };
|
|
self.post_json(&["renderers", id, "volume", "set"], &payload)
|
|
}
|
|
|
|
fn volume_up(&self, id: &str) -> Result<()> {
|
|
self.post_empty(&["renderers", id, "volume", "up"])
|
|
}
|
|
|
|
fn volume_down(&self, id: &str) -> Result<()> {
|
|
self.post_empty(&["renderers", id, "volume", "down"])
|
|
}
|
|
|
|
fn toggle_mute(&self, id: &str) -> Result<()> {
|
|
self.post_empty(&["renderers", id, "mute", "toggle"])
|
|
}
|
|
|
|
fn attach_playlist(&self, id: &str, server_id: &str, container_id: &str) -> Result<()> {
|
|
let payload = AttachPlaylistRequest {
|
|
server_id,
|
|
container_id,
|
|
};
|
|
self.post_json(&["renderers", id, "binding", "attach"], &payload)
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
fn detach_playlist(&self, id: &str) -> Result<()> {
|
|
self.post_empty(&["renderers", id, "binding", "detach"])
|
|
}
|
|
|
|
fn list_servers(&self) -> Result<Vec<MediaServerSummaryClient>> {
|
|
self.get_json(&["servers"])
|
|
}
|
|
|
|
fn browse_container(
|
|
&self,
|
|
server_id: &str,
|
|
container_id: &str,
|
|
) -> Result<BrowseResponseClient> {
|
|
self.get_json(&["servers", server_id, "containers", container_id])
|
|
}
|
|
|
|
fn get_json<T>(&self, segments: &[&str]) -> Result<T>
|
|
where
|
|
T: DeserializeOwned,
|
|
{
|
|
let url = self.build_url_segments(segments);
|
|
let response = self.agent.get(&url).call();
|
|
let mut response = Self::handle_response(response)?;
|
|
let text = response
|
|
.body_mut()
|
|
.read_to_string()
|
|
.with_context(|| format!("Échec de lecture JSON depuis {url}"))?;
|
|
let value = serde_json::from_str(&text)
|
|
.with_context(|| format!("Échec de parsing JSON depuis {url}"))?;
|
|
Ok(value)
|
|
}
|
|
|
|
fn post_empty(&self, segments: &[&str]) -> Result<()> {
|
|
let url = self.build_url_segments(segments);
|
|
let response = self.agent.post(&url).send_empty();
|
|
Self::handle_response(response)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn post_json<T>(&self, segments: &[&str], payload: &T) -> Result<()>
|
|
where
|
|
T: Serialize,
|
|
{
|
|
let url = self.build_url_segments(segments);
|
|
let body = serde_json::to_vec(payload)?;
|
|
let response = self
|
|
.agent
|
|
.post(&url)
|
|
.header("content-type", "application/json")
|
|
.send(body);
|
|
Self::handle_response(response)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn build_url_segments(&self, segments: &[&str]) -> String {
|
|
let mut url = self.base_url.clone();
|
|
for segment in segments {
|
|
url.push('/');
|
|
url.push_str(&utf8_percent_encode(segment, NON_ALPHANUMERIC).to_string());
|
|
}
|
|
url
|
|
}
|
|
|
|
fn handle_response(
|
|
response: Result<http::Response<Body>, ureq::Error>,
|
|
) -> Result<http::Response<Body>> {
|
|
match response {
|
|
Ok(resp) => {
|
|
if resp.status().is_success() {
|
|
Ok(resp)
|
|
} else {
|
|
let mut resp = resp;
|
|
let status = resp.status();
|
|
let body = resp
|
|
.body_mut()
|
|
.read_to_string()
|
|
.unwrap_or_else(|_| "<body illisible>".into());
|
|
Err(anyhow!("HTTP {}: {}", status, body))
|
|
}
|
|
}
|
|
Err(err) => Err(anyhow!(err)),
|
|
}
|
|
}
|
|
}
|