| 1 | use std::{env, io, net::Ipv6Addr, str::FromStr, time::Duration}; |
| 2 | |
| 3 | use crossterm::event::{Event as CrosstermEvent, EventStream, KeyEventKind}; |
| 4 | use futures_util::StreamExt; |
| 5 | use ratatui::prelude::*; |
| 6 | use rmptui::{ |
| 7 | app::App, |
| 8 | config::Config, |
| 9 | connection::{Connection, MpdSnapshot}, |
| 10 | event_handler::handler, |
| 11 | tui, AppResult, |
| 12 | }; |
| 13 | use tokio::{sync::mpsc, time}; |
| 14 | |
| 15 | #[tokio::main] |
| 16 | async fn main() -> AppResult<()> { |
| 17 | let (config, config_path) = Config::load_or_create()?; |
| 18 | eprintln!("Using configuration: {}", config_path.display()); |
| 19 | |
| 20 | let configured_host = env::var("MPD_HOST").unwrap_or_else(|_| config.mpd.host.clone()); |
| 21 | let (host, password) = configured_host |
| 22 | .split_once('@') |
| 23 | .map(|(password, host)| (host, Some(password.to_string()))) |
| 24 | .unwrap_or_else(|| { |
| 25 | ( |
| 26 | configured_host.as_str(), |
| 27 | (!config.mpd.password.is_empty()).then(|| config.mpd.password.clone()), |
| 28 | ) |
| 29 | }); |
| 30 | if host.is_empty() { |
| 31 | return Err(io::Error::new(io::ErrorKind::InvalidInput, "MPD host cannot be empty").into()); |
| 32 | } |
| 33 | let port = env::var("MPD_PORT") |
| 34 | .map(|port| { |
| 35 | port.parse::<u16>().map_err(|error| { |
| 36 | io::Error::new( |
| 37 | std::io::ErrorKind::InvalidInput, |
| 38 | format!("invalid MPD_PORT value `{port}`: {error}"), |
| 39 | ) |
| 40 | }) |
| 41 | }) |
| 42 | .unwrap_or(Ok(config.mpd.port))?; |
| 43 | if port == 0 { |
| 44 | return Err(io::Error::new( |
| 45 | std::io::ErrorKind::InvalidInput, |
| 46 | "MPD port must be between 1 and 65535", |
| 47 | ) |
| 48 | .into()); |
| 49 | } |
| 50 | let address = mpd_address(host, port); |
| 51 | let mut app = App::new(&address, password.as_deref(), config).await?; |
| 52 | |
| 53 | let backend = CrosstermBackend::new(io::stderr()); |
| 54 | let terminal = Terminal::new(backend)?; |
| 55 | let mut tui = tui::Tui::new(terminal); |
| 56 | tui.init()?; |
| 57 | |
| 58 | let (updates_tx, mut updates_rx) = mpsc::channel(16); |
| 59 | let watcher_address = address.clone(); |
| 60 | let watcher_password = password.clone(); |
| 61 | let watcher = |
| 62 | tokio::spawn(async move { watch_mpd(watcher_address, watcher_password, updates_tx).await }); |
| 63 | let mut terminal_events = EventStream::new(); |
| 64 | let mut redraw = time::interval(Duration::from_millis(50)); |
| 65 | redraw.set_missed_tick_behavior(time::MissedTickBehavior::Skip); |
| 66 | |
| 67 | let run_result: AppResult<()> = async { |
| 68 | while app.running { |
| 69 | tui.draw(&mut app)?; |
| 70 | tokio::select! { |
| 71 | event = terminal_events.next() => { |
| 72 | match event { |
| 73 | Some(Ok(CrosstermEvent::Key(key))) |
| 74 | if key.kind != KeyEventKind::Release => |
| 75 | { |
| 76 | handler::handle_key_events(key, &mut app).await?; |
| 77 | } |
| 78 | Some(Ok(CrosstermEvent::Key(_))) => {} |
| 79 | Some(Ok(CrosstermEvent::Mouse(mouse))) => { |
| 80 | handler::handle_mouse_events(mouse, &mut app).await?; |
| 81 | } |
| 82 | Some(Ok(CrosstermEvent::Resize(_, _))) |
| 83 | | Some(Ok(CrosstermEvent::FocusGained)) |
| 84 | | Some(Ok(CrosstermEvent::FocusLost)) |
| 85 | | Some(Ok(CrosstermEvent::Paste(_))) => {} |
| 86 | Some(Err(error)) => { |
| 87 | return Err(Box::new(error) as Box<dyn std::error::Error + Send + Sync>); |
| 88 | } |
| 89 | None => { |
| 90 | return Err(std::io::Error::other("terminal event stream ended").into()); |
| 91 | } |
| 92 | } |
| 93 | } |
| 94 | _ = redraw.tick() => {} |
| 95 | snapshot = updates_rx.recv() => { |
| 96 | match snapshot { |
| 97 | Some(snapshot) => app.apply_snapshot(snapshot).await?, |
| 98 | None => { |
| 99 | return Err(std::io::Error::other( |
| 100 | "MPD change watcher stopped unexpectedly", |
| 101 | ) |
| 102 | .into()); |
| 103 | } |
| 104 | } |
| 105 | } |
| 106 | } |
| 107 | } |
| 108 | Ok(()) |
| 109 | } |
| 110 | .await; |
| 111 | |
| 112 | watcher.abort(); |
| 113 | let exit_result = tui.exit(); |
| 114 | run_result?; |
| 115 | exit_result?; |
| 116 | Ok(()) |
| 117 | } |
| 118 | |
| 119 | async fn watch_mpd( |
| 120 | address: String, |
| 121 | password: Option<String>, |
| 122 | updates: mpsc::Sender<MpdSnapshot>, |
| 123 | ) -> AppResult<()> { |
| 124 | let mut client = Connection::connect(&address, password.as_deref()).await?; |
| 125 | loop { |
| 126 | client.idle().await?; |
| 127 | let snapshot = client.snapshot().await?; |
| 128 | if updates.send(snapshot).await.is_err() { |
| 129 | return Ok(()); |
| 130 | } |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | fn mpd_address(host: &str, port: u16) -> String { |
| 135 | if host.starts_with('[') && host.contains("]:") { |
| 136 | host.to_string() |
| 137 | } else if let Some(ipv6) = host |
| 138 | .strip_prefix('[') |
| 139 | .and_then(|host| host.strip_suffix(']')) |
| 140 | .filter(|host| Ipv6Addr::from_str(host).is_ok()) |
| 141 | { |
| 142 | format!("[{ipv6}]:{port}") |
| 143 | } else if host.contains(':') && Ipv6Addr::from_str(host).is_ok() { |
| 144 | format!("[{host}]:{port}") |
| 145 | } else if host |
| 146 | .rsplit_once(':') |
| 147 | .is_some_and(|(_, suffix)| suffix.parse::<u16>().is_ok()) |
| 148 | { |
| 149 | host.to_string() |
| 150 | } else { |
| 151 | format!("{host}:{port}") |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | #[cfg(test)] |
| 156 | mod tests { |
| 157 | use super::mpd_address; |
| 158 | |
| 159 | #[test] |
| 160 | fn formats_mpd_host_addresses() { |
| 161 | assert_eq!(mpd_address("localhost", 6600), "localhost:6600"); |
| 162 | assert_eq!(mpd_address("localhost:6601", 6600), "localhost:6601"); |
| 163 | assert_eq!(mpd_address("[::1]", 6600), "[::1]:6600"); |
| 164 | assert_eq!(mpd_address("[::1]:6601", 6600), "[::1]:6601"); |
| 165 | assert_eq!(mpd_address("::1", 6600), "[::1]:6600"); |
| 166 | } |
| 167 | } |