| 1 | //! The application: owns the state, routes events, and draws the frame. |
| 2 | |
| 3 | use std::time::{Duration, Instant}; |
| 4 | |
| 5 | use crossterm::event::{Event, EventStream, KeyEventKind, MouseButton, MouseEvent, MouseEventKind}; |
| 6 | use futures_util::StreamExt; |
| 7 | use ratatui::{DefaultTerminal, Frame, layout::Position}; |
| 8 | use tokio::{ |
| 9 | sync::mpsc, |
| 10 | time::{MissedTickBehavior, interval}, |
| 11 | }; |
| 12 | use tracing::info; |
| 13 | |
| 14 | use crate::{ |
| 15 | action::{Action, Context, ScreenId}, |
| 16 | config::{self, Config, KeyCombo}, |
| 17 | event::AppEvent, |
| 18 | lyrics::LyricsService, |
| 19 | mpd::{self, MpdEvent, MpdHandle, PlayState, Query, Reply, Subsystem, commands}, |
| 20 | popups::{ConfirmPopup, HelpPopup, Popup, PopupState}, |
| 21 | screens::{Ctx, Effect, Screens}, |
| 22 | state::MpdState, |
| 23 | tags, |
| 24 | ui::{self, FrameLayout, input::InputEvent, input::LineInput}, |
| 25 | visualizer::Visualizer, |
| 26 | }; |
| 27 | |
| 28 | struct Message { |
| 29 | text: String, |
| 30 | error: bool, |
| 31 | until: Instant, |
| 32 | } |
| 33 | |
| 34 | pub struct App { |
| 35 | config: Config, |
| 36 | mpd: MpdState, |
| 37 | client: MpdHandle, |
| 38 | events_tx: mpsc::UnboundedSender<AppEvent>, |
| 39 | events_rx: Option<mpsc::UnboundedReceiver<AppEvent>>, |
| 40 | screens: Screens, |
| 41 | active: ScreenId, |
| 42 | popups: Vec<Box<dyn Popup>>, |
| 43 | /// The search prompt, while it is open. |
| 44 | search: Option<LineInput>, |
| 45 | message: Option<Message>, |
| 46 | lyrics: LyricsService, |
| 47 | visualizer: Visualizer, |
| 48 | layout: Option<FrameLayout>, |
| 49 | /// The volume before muting. |
| 50 | muted_volume: Option<u8>, |
| 51 | queue_requested: bool, |
| 52 | running: bool, |
| 53 | } |
| 54 | |
| 55 | impl App { |
| 56 | pub fn new(config: Config) -> anyhow::Result<Self> { |
| 57 | let (address, password) = config.mpd_connection()?; |
| 58 | info!("connecting to MPD at {address}"); |
| 59 | // MPD only reports its music directory over a Unix socket; for a |
| 60 | // local server, its config file is the next best source. |
| 61 | let music_dir = config |
| 62 | .music_directory() |
| 63 | .or_else(|| config::local_music_directory(&address)); |
| 64 | let (events_tx, events_rx) = mpsc::unbounded_channel(); |
| 65 | let client = mpd::spawn(address, password, events_tx.clone()); |
| 66 | let visualizer = Visualizer::spawn(config.visualizer.clone()); |
| 67 | let screens = Screens::new(&config, visualizer.subscribe()); |
| 68 | let mut mpd = MpdState::default(); |
| 69 | mpd.music_dir = music_dir; |
| 70 | let mut app = Self { |
| 71 | lyrics: LyricsService::new(config.lyrics.clone(), events_tx.clone()), |
| 72 | active: config.ui.startup_screen, |
| 73 | config, |
| 74 | mpd, |
| 75 | client, |
| 76 | events_tx, |
| 77 | events_rx: Some(events_rx), |
| 78 | screens, |
| 79 | popups: Vec::new(), |
| 80 | search: None, |
| 81 | message: None, |
| 82 | visualizer, |
| 83 | layout: None, |
| 84 | muted_volume: None, |
| 85 | queue_requested: false, |
| 86 | running: true, |
| 87 | }; |
| 88 | app.switch_to(app.active); |
| 89 | Ok(app) |
| 90 | } |
| 91 | |
| 92 | pub async fn run(mut self, terminal: &mut DefaultTerminal) -> anyhow::Result<()> { |
| 93 | let mut events = self.events_rx.take().expect("run is called once"); |
| 94 | let mut input = EventStream::new(); |
| 95 | let mut spectrum = self.visualizer.subscribe(); |
| 96 | // Redraws the progress bar while playing and expires messages. |
| 97 | let mut tick = interval(Duration::from_millis(250)); |
| 98 | tick.set_missed_tick_behavior(MissedTickBehavior::Skip); |
| 99 | |
| 100 | while self.running { |
| 101 | terminal.draw(|frame| self.draw(frame))?; |
| 102 | tokio::select! { |
| 103 | event = input.next() => match event { |
| 104 | Some(Ok(event)) => self.on_terminal_event(event), |
| 105 | Some(Err(error)) => return Err(error.into()), |
| 106 | None => break, |
| 107 | }, |
| 108 | Some(event) = events.recv() => { |
| 109 | self.on_app_event(event); |
| 110 | // Apply everything that is already queued before redrawing. |
| 111 | while let Ok(event) = events.try_recv() { |
| 112 | self.on_app_event(event); |
| 113 | } |
| 114 | } |
| 115 | _ = tick.tick() => {} |
| 116 | Ok(()) = spectrum.changed(), if self.visualizer.is_active() => {} |
| 117 | } |
| 118 | } |
| 119 | Ok(()) |
| 120 | } |
| 121 | |
| 122 | /// Runs `f` with a context, then applies the effects it queued. |
| 123 | fn with_ctx<R>(&mut self, f: impl FnOnce(&mut Screens, &mut Ctx) -> R) -> R { |
| 124 | let mut ctx = Ctx::new(&self.config, &self.mpd, &self.client, &self.lyrics.state); |
| 125 | let result = f(&mut self.screens, &mut ctx); |
| 126 | let effects = ctx.effects; |
| 127 | self.apply_effects(effects); |
| 128 | result |
| 129 | } |
| 130 | |
| 131 | fn apply_effects(&mut self, effects: Vec<Effect>) { |
| 132 | for effect in effects { |
| 133 | match effect { |
| 134 | Effect::Notify { text, error } => self.notify(text, error), |
| 135 | Effect::Popup(popup) => self.popups.push(popup), |
| 136 | Effect::Message(id, message) => { |
| 137 | self.with_ctx(|screens, ctx| screens.get_mut(id).on_message(message, ctx)); |
| 138 | } |
| 139 | Effect::WriteTags(writes) => self.write_tags(writes), |
| 140 | Effect::ReloadLyrics => { |
| 141 | self.lyrics.ensure( |
| 142 | self.mpd.current.as_ref(), |
| 143 | self.mpd.music_dir.as_deref(), |
| 144 | true, |
| 145 | ); |
| 146 | } |
| 147 | } |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | fn notify(&mut self, text: String, error: bool) { |
| 152 | self.message = Some(Message { |
| 153 | text, |
| 154 | error, |
| 155 | until: Instant::now() + self.config.message_timeout(), |
| 156 | }); |
| 157 | } |
| 158 | |
| 159 | fn switch_to(&mut self, screen: ScreenId) { |
| 160 | self.active = screen; |
| 161 | self.search = None; |
| 162 | self.visualizer.set_active(screen == ScreenId::Visualizer); |
| 163 | self.with_ctx(|screens, ctx| screens.get_mut(screen).on_enter(ctx)); |
| 164 | self.refresh_lyrics(); |
| 165 | } |
| 166 | |
| 167 | fn lyrics_visible(&self) -> bool { |
| 168 | match self.active { |
| 169 | ScreenId::Lyrics => true, |
| 170 | ScreenId::Queue => self.screens.queue.lyrics_pane_visible(), |
| 171 | _ => false, |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | /// Loads lyrics for the current song if they are on screen. |
| 176 | fn refresh_lyrics(&mut self) { |
| 177 | if self.lyrics_visible() { |
| 178 | self.lyrics.ensure( |
| 179 | self.mpd.current.as_ref(), |
| 180 | self.mpd.music_dir.as_deref(), |
| 181 | false, |
| 182 | ); |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | // ----- terminal input ------------------------------------------------- |
| 187 | |
| 188 | fn on_terminal_event(&mut self, event: Event) { |
| 189 | match event { |
| 190 | Event::Key(key) if key.kind != KeyEventKind::Release => self.on_key(key.into()), |
| 191 | Event::Mouse(mouse) if self.config.ui.mouse => self.on_mouse(mouse), |
| 192 | _ => {} |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | fn on_key(&mut self, key: KeyCombo) { |
| 197 | if let Some(mut popup) = self.popups.pop() { |
| 198 | let mut ctx = Ctx::new(&self.config, &self.mpd, &self.client, &self.lyrics.state); |
| 199 | let state = popup.handle_key(key, &mut ctx); |
| 200 | let effects = ctx.effects; |
| 201 | if state == PopupState::Open { |
| 202 | self.popups.push(popup); |
| 203 | } |
| 204 | self.apply_effects(effects); |
| 205 | return; |
| 206 | } |
| 207 | if self.search.is_some() { |
| 208 | self.on_search_key(key); |
| 209 | return; |
| 210 | } |
| 211 | let contexts = [self.active.context(), Context::Navigation, Context::Global]; |
| 212 | let Some(action) = self.config.keymap.resolve(&contexts, key) else { |
| 213 | return; |
| 214 | }; |
| 215 | if action.contexts().contains(&Context::Global) { |
| 216 | self.global_action(action); |
| 217 | } else if action == Action::Search { |
| 218 | self.open_search(); |
| 219 | } else { |
| 220 | let active = self.active; |
| 221 | let lyrics_before = self.lyrics_visible(); |
| 222 | self.with_ctx(|screens, ctx| screens.get_mut(active).handle_action(action, ctx)); |
| 223 | if !lyrics_before { |
| 224 | self.refresh_lyrics(); |
| 225 | } |
| 226 | } |
| 227 | } |
| 228 | |
| 229 | fn open_search(&mut self) { |
| 230 | let active = self.active; |
| 231 | let ctx = Ctx::new(&self.config, &self.mpd, &self.client, &self.lyrics.state); |
| 232 | if self.screens.get_mut(active).search("", &ctx) { |
| 233 | self.search = Some(LineInput::default()); |
| 234 | } else { |
| 235 | self.notify("Nothing to search on this screen".into(), false); |
| 236 | } |
| 237 | } |
| 238 | |
| 239 | fn on_search_key(&mut self, key: KeyCombo) { |
| 240 | let Some(input) = self.search.as_mut() else { |
| 241 | return; |
| 242 | }; |
| 243 | let event = match self.config.keymap.resolve(&[Context::Input], key) { |
| 244 | Some(action) => input.apply(action), |
| 245 | None => match key.as_text() { |
| 246 | Some(ch) => { |
| 247 | input.insert(ch); |
| 248 | InputEvent::Changed |
| 249 | } |
| 250 | None => InputEvent::Ignored, |
| 251 | }, |
| 252 | }; |
| 253 | let query = input.text().to_owned(); |
| 254 | let active = self.active; |
| 255 | let ctx = Ctx::new(&self.config, &self.mpd, &self.client, &self.lyrics.state); |
| 256 | match event { |
| 257 | InputEvent::Changed => { |
| 258 | self.screens.get_mut(active).search(&query, &ctx); |
| 259 | } |
| 260 | // Keep the results for `n`/`N`. |
| 261 | InputEvent::Submit => self.search = None, |
| 262 | InputEvent::Cancel => { |
| 263 | self.screens.get_mut(active).search("", &ctx); |
| 264 | self.search = None; |
| 265 | } |
| 266 | InputEvent::Moved | InputEvent::Ignored => {} |
| 267 | } |
| 268 | } |
| 269 | |
| 270 | fn global_action(&mut self, action: Action) { |
| 271 | let status = &self.mpd.status; |
| 272 | let seek = i64::from(self.config.playback.seek_seconds); |
| 273 | let step = i32::from(self.config.playback.volume_step); |
| 274 | let client = &self.client; |
| 275 | match action { |
| 276 | Action::Quit => self.running = false, |
| 277 | Action::Help => { |
| 278 | let ctx = Ctx::new(&self.config, &self.mpd, &self.client, &self.lyrics.state); |
| 279 | let popup = HelpPopup::new(self.active.context(), &ctx); |
| 280 | self.popups.push(Box::new(popup)); |
| 281 | } |
| 282 | Action::TogglePause => client.exec(match status.state { |
| 283 | PlayState::Play => commands::pause(true), |
| 284 | PlayState::Pause => commands::pause(false), |
| 285 | PlayState::Stop => commands::play(), |
| 286 | }), |
| 287 | Action::Pause => client.exec(commands::pause(true)), |
| 288 | Action::Stop => client.exec(commands::stop()), |
| 289 | Action::NextTrack => client.exec(commands::next()), |
| 290 | Action::PreviousTrack => client.exec(commands::previous()), |
| 291 | Action::SeekForward | Action::SeekBackward if status.state != PlayState::Stop => { |
| 292 | let delta = if action == Action::SeekForward { |
| 293 | seek |
| 294 | } else { |
| 295 | -seek |
| 296 | }; |
| 297 | client.exec(commands::seek_relative(delta)); |
| 298 | } |
| 299 | Action::SeekForward | Action::SeekBackward => {} |
| 300 | Action::VolumeUp | Action::VolumeDown => match status.volume { |
| 301 | Some(_) => { |
| 302 | let delta = if action == Action::VolumeUp { |
| 303 | step |
| 304 | } else { |
| 305 | -step |
| 306 | }; |
| 307 | client.exec(commands::change_volume(delta)); |
| 308 | } |
| 309 | None => self.notify("MPD has no mixer".into(), true), |
| 310 | }, |
| 311 | Action::ToggleMute => match status.volume { |
| 312 | Some(0) => { |
| 313 | let volume = self.muted_volume.take().unwrap_or(50); |
| 314 | client.exec(commands::set_volume(volume)); |
| 315 | } |
| 316 | Some(volume) => { |
| 317 | self.muted_volume = Some(volume); |
| 318 | client.exec(commands::set_volume(0)); |
| 319 | } |
| 320 | None => self.notify("MPD has no mixer".into(), true), |
| 321 | }, |
| 322 | Action::ToggleRepeat => client.exec(commands::repeat(!status.repeat)), |
| 323 | Action::ToggleRandom => client.exec(commands::random(!status.random)), |
| 324 | Action::ToggleSingle => client.exec(commands::single(!status.single.is_on())), |
| 325 | Action::ToggleConsume => client.exec(commands::consume(!status.consume.is_on())), |
| 326 | Action::ClearQueue if self.mpd.queue.is_empty() => {} |
| 327 | Action::ClearQueue if self.config.ui.confirm_clear_queue => { |
| 328 | let popup = ConfirmPopup::new( |
| 329 | format!("Remove all {} songs from the queue?", self.mpd.queue.len()), |
| 330 | vec![commands::clear()], |
| 331 | ); |
| 332 | self.popups.push(Box::new(popup)); |
| 333 | } |
| 334 | Action::ClearQueue => { |
| 335 | client.exec(commands::clear()); |
| 336 | self.notify("Cleared the queue".into(), false); |
| 337 | } |
| 338 | Action::UpdateDatabase => { |
| 339 | client.exec(commands::update(None)); |
| 340 | self.notify("Updating the database…".into(), false); |
| 341 | } |
| 342 | Action::NextScreen => self.switch_to(self.active.next()), |
| 343 | Action::PreviousScreen => self.switch_to(self.active.previous()), |
| 344 | Action::ScreenQueue => self.switch_to(ScreenId::Queue), |
| 345 | Action::ScreenBrowser => self.switch_to(ScreenId::Browser), |
| 346 | Action::ScreenPlaylists => self.switch_to(ScreenId::Playlists), |
| 347 | Action::ScreenLibrary => self.switch_to(ScreenId::Library), |
| 348 | Action::ScreenTagEditor => self.switch_to(ScreenId::TagEditor), |
| 349 | Action::ScreenVisualizer => self.switch_to(ScreenId::Visualizer), |
| 350 | Action::ScreenLyrics => self.switch_to(ScreenId::Lyrics), |
| 351 | _ => {} |
| 352 | } |
| 353 | } |
| 354 | |
| 355 | fn on_mouse(&mut self, mouse: MouseEvent) { |
| 356 | if !self.popups.is_empty() { |
| 357 | return; |
| 358 | } |
| 359 | let Some(layout) = &self.layout else { |
| 360 | return; |
| 361 | }; |
| 362 | let position = Position::new(mouse.column, mouse.row); |
| 363 | let left_click = mouse.kind == MouseEventKind::Down(MouseButton::Left); |
| 364 | if layout.progress.contains(position) { |
| 365 | if left_click |
| 366 | && self.mpd.status.state != PlayState::Stop |
| 367 | && let Some(target) = |
| 368 | ui::progress_seek_target(layout.progress, mouse.column, &self.mpd) |
| 369 | { |
| 370 | self.client.exec(commands::seek_to(target.as_secs_f64())); |
| 371 | } |
| 372 | } else if layout.body.contains(position) { |
| 373 | let active = self.active; |
| 374 | self.with_ctx(|screens, ctx| screens.get_mut(active).on_mouse(mouse, ctx)); |
| 375 | } |
| 376 | } |
| 377 | |
| 378 | // ----- background events ---------------------------------------------- |
| 379 | |
| 380 | fn on_app_event(&mut self, event: AppEvent) { |
| 381 | match event { |
| 382 | AppEvent::Mpd(event) => self.on_mpd_event(event), |
| 383 | AppEvent::Lyrics { file, result } => { |
| 384 | self.lyrics.finish(file, result); |
| 385 | } |
| 386 | AppEvent::TagsWritten(Ok(dirs)) => { |
| 387 | let count = dirs.len(); |
| 388 | self.client |
| 389 | .exec_all(dirs.iter().map(|dir| commands::update(Some(dir))).collect()); |
| 390 | self.notify(format!("Saved tags; rescanning {count} folder(s)"), false); |
| 391 | } |
| 392 | AppEvent::TagsWritten(Err(error)) => self.notify(error, true), |
| 393 | } |
| 394 | } |
| 395 | |
| 396 | fn on_mpd_event(&mut self, event: MpdEvent) { |
| 397 | match &event { |
| 398 | MpdEvent::Connected { version } => { |
| 399 | info!("connected to MPD {version}"); |
| 400 | self.mpd.connected = true; |
| 401 | self.mpd.reset_queue(); |
| 402 | self.queue_requested = false; |
| 403 | self.client.query(Query::Status); |
| 404 | self.client.query(Query::Playlists); |
| 405 | if self.config.music_directory().is_none() { |
| 406 | self.client.query(Query::MusicDirectory); |
| 407 | } |
| 408 | self.notify(format!("Connected to MPD {version}"), false); |
| 409 | } |
| 410 | MpdEvent::Disconnected(reason) => { |
| 411 | self.mpd.connected = false; |
| 412 | self.notify(reason.clone(), true); |
| 413 | } |
| 414 | MpdEvent::Changed(subsystems) => { |
| 415 | let mut status = false; |
| 416 | for subsystem in subsystems { |
| 417 | match subsystem { |
| 418 | Subsystem::StoredPlaylist => self.client.query(Query::Playlists), |
| 419 | Subsystem::Database => { |
| 420 | self.mpd.db_version += 1; |
| 421 | status = true; |
| 422 | } |
| 423 | _ => status = true, |
| 424 | } |
| 425 | } |
| 426 | if status { |
| 427 | self.client.query(Query::Status); |
| 428 | } |
| 429 | } |
| 430 | MpdEvent::Reply(Reply::Status { status, current }) => { |
| 431 | let song_changed = self.mpd.set_status(status.clone(), current.clone()); |
| 432 | self.request_queue_if_stale(); |
| 433 | if song_changed { |
| 434 | self.refresh_lyrics(); |
| 435 | } |
| 436 | } |
| 437 | MpdEvent::Reply(Reply::QueueChanges { |
| 438 | status, |
| 439 | since, |
| 440 | changes, |
| 441 | }) => { |
| 442 | self.queue_requested = false; |
| 443 | self.mpd |
| 444 | .apply_queue_changes(status, *since, changes.clone()); |
| 445 | self.request_queue_if_stale(); |
| 446 | } |
| 447 | MpdEvent::Reply(Reply::Playlists(playlists)) => { |
| 448 | self.mpd.playlists.clone_from(playlists) |
| 449 | } |
| 450 | MpdEvent::Reply(Reply::MusicDirectory(dir)) => { |
| 451 | if self.config.music_directory().is_none() && dir.is_some() { |
| 452 | self.mpd.music_dir.clone_from(dir); |
| 453 | } |
| 454 | } |
| 455 | MpdEvent::Reply(_) => {} |
| 456 | MpdEvent::Error(error) => self.notify(error.clone(), true), |
| 457 | } |
| 458 | self.with_ctx(|screens, ctx| screens.for_each(|screen| screen.on_mpd(&event, ctx))); |
| 459 | } |
| 460 | |
| 461 | fn request_queue_if_stale(&mut self) { |
| 462 | if !self.queue_requested |
| 463 | && let Some(since) = self.mpd.stale_queue_since() |
| 464 | { |
| 465 | self.queue_requested = true; |
| 466 | self.client.query(Query::QueueChanges { since }); |
| 467 | } |
| 468 | } |
| 469 | |
| 470 | fn write_tags(&mut self, writes: Vec<tags::TagWrite>) { |
| 471 | let events = self.events_tx.clone(); |
| 472 | self.notify(format!("Writing tags of {} file(s)…", writes.len()), false); |
| 473 | tokio::task::spawn_blocking(move || { |
| 474 | let mut dirs: Vec<String> = Vec::new(); |
| 475 | let mut errors = Vec::new(); |
| 476 | for write in &writes { |
| 477 | match tags::write(write) { |
| 478 | Ok(()) => { |
| 479 | let dir = write |
| 480 | .uri |
| 481 | .rsplit_once('/') |
| 482 | .map_or("", |(dir, _)| dir) |
| 483 | .to_owned(); |
| 484 | if !dirs.contains(&dir) { |
| 485 | dirs.push(dir); |
| 486 | } |
| 487 | } |
| 488 | Err(error) => errors.push(error), |
| 489 | } |
| 490 | } |
| 491 | let result = match errors.as_slice() { |
| 492 | [] => Ok(dirs), |
| 493 | [error] => Err(error.clone()), |
| 494 | [first, rest @ ..] => Err(format!("{first} (and {} more errors)", rest.len())), |
| 495 | }; |
| 496 | let _ = events.send(AppEvent::TagsWritten(result)); |
| 497 | }); |
| 498 | } |
| 499 | |
| 500 | // ----- drawing -------------------------------------------------------- |
| 501 | |
| 502 | fn draw(&mut self, frame: &mut Frame) { |
| 503 | let layout = FrameLayout::new(frame.area()); |
| 504 | let theme = &self.config.theme; |
| 505 | let ctx = Ctx::new(&self.config, &self.mpd, &self.client, &self.lyrics.state); |
| 506 | let screen = self.screens.get_mut(self.active); |
| 507 | ui::draw_title(frame, &layout, screen.title(&ctx), &self.mpd, theme); |
| 508 | screen.draw(frame, layout.body, &ctx); |
| 509 | ui::draw_progress(frame, layout.progress, &self.mpd, theme); |
| 510 | |
| 511 | if self |
| 512 | .message |
| 513 | .as_ref() |
| 514 | .is_some_and(|m| m.until <= Instant::now()) |
| 515 | { |
| 516 | self.message = None; |
| 517 | } |
| 518 | if let Some(search) = &self.search { |
| 519 | search.render(frame, layout.status, "/", theme); |
| 520 | } else if let Some(message) = &self.message { |
| 521 | ui::draw_message(frame, layout.status, &message.text, message.error, theme); |
| 522 | } else { |
| 523 | ui::draw_now_playing(frame, layout.status, &self.mpd, theme); |
| 524 | } |
| 525 | for popup in &mut self.popups { |
| 526 | popup.draw(frame, frame.area(), &ctx); |
| 527 | } |
| 528 | self.layout = Some(layout); |
| 529 | } |
| 530 | } |