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