Krolyxon
Commit 96dd350

rmptuisrcapp.rs

533 lines · 20.1 KB · Rust

1//! The application: owns the state, routes events, and draws the frame.
2
3use std::time::{Duration, Instant};
4
5use crossterm::event::{Event, EventStream, KeyEventKind, MouseButton, MouseEvent, MouseEventKind};
6use futures_util::StreamExt;
7use ratatui::{DefaultTerminal, Frame, layout::Position};
8use tokio::{
9 sync::mpsc,
10 time::{MissedTickBehavior, interval},
11};
12use tracing::info;
13
14use 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
28struct Message {
29 text: String,
30 error: bool,
31 until: Instant,
32}
33
34pub 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
56impl 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}