Krolyxon
Commit b139f20

rmptuisrcapp.rs

530 lines · 20.2 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::{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
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 /// The volume before muting.
50 muted_volume: Option<u8>,
51 queue_requested: bool,
52 running: bool,
53}
54
55impl 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}