1pub mod app;
8pub mod colors;
9pub mod components;
10pub mod widgets;
11
12use std::{
13 io::{self, Stdout},
14 time::Duration,
15};
16
17use anyhow::Context as _;
18use app::App;
19use components::{
20 Component, ComponentRender,
21 content_view::{
22 ActiveView,
23 views::{
24 AlbumViewProps, ArtistViewProps, CollectionViewProps, DynamicPlaylistViewProps,
25 PlaylistViewProps, RadioViewProps, RandomViewProps, SongViewProps, ViewData,
26 },
27 },
28};
29use crossterm::{
30 event::{
31 DisableMouseCapture, EnableMouseCapture, Event, EventStream, PopKeyboardEnhancementFlags,
32 },
33 execute,
34 terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
35};
36use mecomp_core::{config::Settings, state::StateAudio};
37use mecomp_prost::{LibraryBrief, MusicPlayerClient, RadioSimilarRequest, SearchResult, Ulid};
38use ratatui::prelude::*;
39use tokio::sync::{broadcast, mpsc};
40use tokio_stream::StreamExt;
41
42use crate::{
43 state::{Receivers, action::Action, component::ActiveComponent},
44 termination::Interrupted,
45};
46
47#[derive(Debug, Default)]
48pub struct AppState {
49 pub active_component: ActiveComponent,
50 pub audio: StateAudio,
51 pub search: SearchResult,
52 pub library: LibraryBrief,
53 pub active_view: ActiveView,
54 pub additional_view_data: ViewData,
55 pub settings: Settings,
56}
57
58const RENDERING_TICK_RATE: Duration = Duration::from_millis(250);
59
60#[allow(clippy::module_name_repetitions)]
61pub struct UiManager {
62 action_tx: mpsc::UnboundedSender<Action>,
63}
64
65impl UiManager {
66 #[must_use]
67 pub const fn new(action_tx: mpsc::UnboundedSender<Action>) -> Self {
68 Self { action_tx }
69 }
70
71 pub async fn main_loop(
79 self,
80 daemon: MusicPlayerClient,
81 settings: Settings,
82 mut state_rx: Receivers,
83 mut interrupt_rx: broadcast::Receiver<Interrupted>,
84 ) -> anyhow::Result<Interrupted> {
85 let mut state = AppState {
87 active_component: ActiveComponent::default(),
88 audio: state_rx.audio.recv().await.unwrap_or_default(),
89 search: state_rx.search.recv().await.unwrap_or_default(),
90 library: state_rx.library.recv().await.unwrap_or_default(),
91 active_view: state_rx.view.recv().await.unwrap_or_default(),
92 additional_view_data: ViewData::default(),
93 settings,
94 };
95 let mut app = App::new(&state, self.action_tx.clone());
96
97 let mut terminal = setup_terminal()?;
98 let mut ticker = tokio::time::interval(RENDERING_TICK_RATE);
99 let mut crossterm_events = EventStream::new();
100
101 let result: anyhow::Result<Interrupted> = loop {
102 tokio::select! {
103 _ = ticker.tick() => (),
105 maybe_event = crossterm_events.next() => match maybe_event {
107 Some(Ok(Event::Key(key))) => {
108 app.handle_key_event(key);
109 },
110 Some(Ok(Event::Mouse(mouse))) => {
111 let terminal_size = terminal.size().context("could not get terminal size")?;
112 let area = Rect::new(0, 0, terminal_size.width, terminal_size.height);
113 app.handle_mouse_event(mouse, area);
114 },
115 None => break Ok(Interrupted::UserInt),
116 _ => (),
117 },
118 Some(audio) = state_rx.audio.recv() => {
120 state = AppState {
121 audio,
122 ..state
123 };
124 app = app.move_with_audio(&state);
125 },
126 Some(search) = state_rx.search.recv() => {
127 state = AppState {
128 search,
129 ..state
130 };
131 app = app.move_with_search(&state);
132 },
133 Some(library) = state_rx.library.recv() => {
134 state = AppState {
135 library,
136 additional_view_data: Box::pin(handle_additional_view_data(daemon.clone(), &state, &state.active_view)).await.unwrap_or(state.additional_view_data),
138 ..state
139 };
140 app = app.move_with_library(&state);
141 },
142 Some(active_view) = state_rx.view.recv() => {
143 let additional_view_data = Box::pin(handle_additional_view_data(daemon.clone(), &state, &active_view)).await.unwrap_or(state.additional_view_data);
145
146 state = AppState {
147 active_view,
148 additional_view_data,
149 ..state
150 };
151 app = app.move_with_view(&state);
152 },
153 Some(active_component) = state_rx.component.recv() => {
154 state = AppState {
155 active_component,
156 ..state
157 };
158 app = app.move_with_component(&state);
159 },
160 Some(popup) = state_rx.popup.recv() => {
161 app = app.move_with_popup( popup.map(|popup| {
162 popup.into_popup(&state, self.action_tx.clone())
163 }));
164 }
165 Ok(interrupted) = interrupt_rx.recv() => {
167 break Ok(interrupted);
168 }
169 }
170
171 if let Err(err) = terminal
172 .draw(|frame| app.render(frame, frame.area()))
173 .context("could not render to the terminal")
174 {
175 break Err(err);
176 }
177 };
178
179 restore_terminal(&mut terminal)?;
180
181 result
182 }
183}
184
185#[cfg(not(tarpaulin_include))]
186fn setup_terminal() -> anyhow::Result<Terminal<CrosstermBackend<Stdout>>> {
187 let mut stdout = io::stdout();
188
189 enable_raw_mode()?;
190
191 execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
192
193 Ok(Terminal::new(CrosstermBackend::new(stdout))?)
194}
195
196#[cfg(not(tarpaulin_include))]
197fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> anyhow::Result<()> {
198 disable_raw_mode()?;
199
200 execute!(
201 terminal.backend_mut(),
202 LeaveAlternateScreen,
203 DisableMouseCapture,
204 PopKeyboardEnhancementFlags,
205 )?;
206
207 Ok(terminal.show_cursor()?)
208}
209
210#[cfg(not(tarpaulin_include))]
211pub fn init_panic_hook() {
212 let original_hook = std::panic::take_hook();
213 std::panic::set_hook(Box::new(move |panic_info| {
214 let _ = disable_raw_mode();
216 let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture);
217
218 original_hook(panic_info);
219 }));
220}
221
222async fn song_view_future(
223 daemon: MusicPlayerClient,
224 id: Ulid,
225) -> anyhow::Result<(
226 Option<mecomp_prost::Song>,
227 Vec<mecomp_prost::ArtistBrief>,
228 Option<mecomp_prost::AlbumBrief>,
229 Vec<mecomp_prost::Playlist>,
230 Vec<mecomp_prost::Collection>,
231)> {
232 let mut copy = daemon.clone();
233 let song = copy.library_song_get(id.clone());
234 let mut copy = daemon.clone();
235 let artists = copy.library_song_get_artists(id.clone());
236 let mut copy = daemon.clone();
237 let album = copy.library_song_get_album(id.clone());
238 let mut copy = daemon.clone();
239 let playlists = copy.library_song_get_playlists(id.clone());
240 let mut copy = daemon.clone();
241 let collections = copy.library_song_get_collections(id.clone());
242
243 Ok(
244 tokio::try_join!(song, artists, album, playlists, collections,).map(
245 |(song, artists, album, playlists, collections)| {
246 (
247 song.into_inner().song,
248 artists.into_inner().artists,
249 album.into_inner().album,
250 playlists.into_inner().playlists,
251 collections.into_inner().collections,
252 )
253 },
254 )?,
255 )
256}
257
258async fn album_view_future(
259 daemon: MusicPlayerClient,
260 id: Ulid,
261) -> anyhow::Result<(
262 Option<mecomp_prost::Album>,
263 Vec<mecomp_prost::ArtistBrief>,
264 Vec<mecomp_prost::SongBrief>,
265)> {
266 let mut copy = daemon.clone();
267 let album = copy.library_album_get(id.clone());
268 let mut copy = daemon.clone();
269 let artists = copy.library_album_get_artists(id.clone());
270 let mut copy = daemon.clone();
271 let songs = copy.library_album_get_songs(id.clone());
272
273 Ok(
274 tokio::try_join!(album, artists, songs).map(|(album, artists, songs)| {
275 (
276 album.into_inner().album,
277 artists.into_inner().artists,
278 songs.into_inner().songs,
279 )
280 })?,
281 )
282}
283
284async fn artist_view_future(
285 daemon: MusicPlayerClient,
286 id: Ulid,
287) -> anyhow::Result<(
288 Option<mecomp_prost::Artist>,
289 Vec<mecomp_prost::AlbumBrief>,
290 Vec<mecomp_prost::SongBrief>,
291)> {
292 let mut copy = daemon.clone();
293 let artist = copy.library_artist_get(id.clone());
294 let mut copy = daemon.clone();
295 let albums = copy.library_artist_get_albums(id.clone());
296 let mut copy = daemon.clone();
297 let songs = copy.library_artist_get_songs(id.clone());
298
299 Ok(
300 tokio::try_join!(artist, albums, songs,).map(|(artist, albums, songs)| {
301 (
302 artist.into_inner().artist,
303 albums.into_inner().albums,
304 songs.into_inner().songs,
305 )
306 })?,
307 )
308}
309
310async fn playlist_view_future(
311 daemon: MusicPlayerClient,
312 id: Ulid,
313) -> anyhow::Result<(Option<mecomp_prost::Playlist>, Vec<mecomp_prost::SongBrief>)> {
314 let mut copy = daemon.clone();
315 let playlist = copy.library_playlist_get(id.clone());
316 let mut copy = daemon.clone();
317 let songs = copy.library_playlist_get_songs(id.clone());
318 Ok(tokio::try_join!(playlist, songs,)
319 .map(|(playlist, songs)| (playlist.into_inner().playlist, songs.into_inner().songs))?)
320}
321
322async fn dynamic_playlist_view_future(
323 daemon: MusicPlayerClient,
324 id: Ulid,
325) -> anyhow::Result<(
326 Option<mecomp_prost::DynamicPlaylist>,
327 Vec<mecomp_prost::SongBrief>,
328)> {
329 let mut copy = daemon.clone();
330 let dynamic_playlist = copy.library_dynamic_playlist_get(id.clone());
331 let mut copy = daemon.clone();
332 let songs = copy.library_dynamic_playlist_get_songs(id.clone());
333 Ok(
334 tokio::try_join!(dynamic_playlist, songs,).map(|(dynamic_playlist, songs)| {
335 (
336 dynamic_playlist.into_inner().playlist,
337 songs.into_inner().songs,
338 )
339 })?,
340 )
341}
342
343async fn collection_view_future(
344 daemon: MusicPlayerClient,
345 id: Ulid,
346) -> anyhow::Result<(
347 Option<mecomp_prost::Collection>,
348 Vec<mecomp_prost::SongBrief>,
349)> {
350 let mut copy = daemon.clone();
351 let collection = copy.library_collection_get(id.clone());
352 let mut copy = daemon.clone();
353 let songs = copy.library_collection_get_songs(id.clone());
354 Ok(
355 tokio::try_join!(collection, songs,).map(|(collection, songs)| {
356 (collection.into_inner().collection, songs.into_inner().songs)
357 })?,
358 )
359}
360
361async fn random_view_future(
362 daemon: MusicPlayerClient,
363) -> anyhow::Result<(
364 Option<mecomp_prost::AlbumBrief>,
365 Option<mecomp_prost::ArtistBrief>,
366 Option<mecomp_prost::SongBrief>,
367)> {
368 let mut copy = daemon.clone();
369 let album = copy.rand_album(());
370 let mut copy = daemon.clone();
371 let artist = copy.rand_artist(());
372 let mut copy = daemon.clone();
373 let song = copy.rand_song(());
374
375 Ok(
376 tokio::try_join!(album, artist, song).map(|(album, artist, song)| {
377 (
378 album.into_inner().album,
379 artist.into_inner().artist,
380 song.into_inner().song,
381 )
382 })?,
383 )
384}
385
386#[allow(clippy::too_many_lines)]
388async fn handle_additional_view_data(
389 mut daemon: MusicPlayerClient,
390 state: &AppState,
391 active_view: &ActiveView,
392) -> Option<ViewData> {
393 match active_view {
394 ActiveView::Song(id) => {
395 if let Ok((Some(song), artists, Some(album), playlists, collections)) =
396 song_view_future(daemon, id.clone()).await
397 {
398 let song_view_props = SongViewProps {
399 id: song.id.clone(),
400 song,
401 artists,
402 album,
403 playlists,
404 collections,
405 };
406 Some(ViewData {
407 song: Some(song_view_props),
408 ..state.additional_view_data.clone()
409 })
410 } else {
411 Some(ViewData {
412 song: None,
413 ..state.additional_view_data.clone()
414 })
415 }
416 }
417 ActiveView::Album(id) => {
418 if let Ok((Some(album), artists, songs)) = album_view_future(daemon, id.clone()).await {
419 let album_view_props = AlbumViewProps {
420 id: album.id.clone(),
421 album,
422 artists,
423 songs,
424 };
425 Some(ViewData {
426 album: Some(album_view_props),
427 ..state.additional_view_data.clone()
428 })
429 } else {
430 Some(ViewData {
431 album: None,
432 ..state.additional_view_data.clone()
433 })
434 }
435 }
436 ActiveView::Artist(id) => {
437 if let Ok((Some(artist), albums, songs)) = artist_view_future(daemon, id.clone()).await
438 {
439 let artist_view_props = ArtistViewProps {
440 id: artist.id.clone(),
441 artist,
442 albums,
443 songs,
444 };
445 Some(ViewData {
446 artist: Some(artist_view_props),
447 ..state.additional_view_data.clone()
448 })
449 } else {
450 Some(ViewData {
451 artist: None,
452 ..state.additional_view_data.clone()
453 })
454 }
455 }
456 ActiveView::Playlist(id) => {
457 if let Ok((Some(playlist), songs)) = playlist_view_future(daemon, id.clone()).await {
458 let playlist_view_props = PlaylistViewProps {
459 id: playlist.id.clone(),
460 playlist,
461 songs,
462 };
463 Some(ViewData {
464 playlist: Some(playlist_view_props),
465 ..state.additional_view_data.clone()
466 })
467 } else {
468 Some(ViewData {
469 playlist: None,
470 ..state.additional_view_data.clone()
471 })
472 }
473 }
474 ActiveView::DynamicPlaylist(id) => {
475 if let Ok((Some(dynamic_playlist), songs)) =
476 dynamic_playlist_view_future(daemon, id.clone()).await
477 {
478 let dynamic_playlist_view_props = DynamicPlaylistViewProps {
479 id: dynamic_playlist.id.clone(),
480 dynamic_playlist,
481 songs,
482 };
483 Some(ViewData {
484 dynamic_playlist: Some(dynamic_playlist_view_props),
485 ..state.additional_view_data.clone()
486 })
487 } else {
488 Some(ViewData {
489 dynamic_playlist: None,
490 ..state.additional_view_data.clone()
491 })
492 }
493 }
494 ActiveView::Collection(id) => {
495 if let Ok((Some(collection), songs)) = collection_view_future(daemon, id.clone()).await
496 {
497 let collection_view_props = CollectionViewProps {
498 id: collection.id.clone(),
499 collection,
500 songs,
501 };
502 Some(ViewData {
503 collection: Some(collection_view_props),
504 ..state.additional_view_data.clone()
505 })
506 } else {
507 Some(ViewData {
508 collection: None,
509 ..state.additional_view_data.clone()
510 })
511 }
512 }
513 ActiveView::Radio(ids) => {
514 let count = state.settings.tui.radio_count;
515 let radio_view_props = daemon
516 .radio_get_similar(RadioSimilarRequest::new(ids.clone(), count))
517 .await
518 .ok()
519 .map(|resp| RadioViewProps {
520 count,
521 songs: resp.into_inner().songs,
522 });
523 Some(ViewData {
524 radio: radio_view_props,
525 ..state.additional_view_data.clone()
526 })
527 }
528 ActiveView::Random => {
529 if let Ok((Some(album), Some(artist), Some(song))) =
530 random_view_future(daemon.clone()).await
531 {
532 let random_view_props = RandomViewProps {
533 album: album.id,
534 artist: artist.id,
535 song: song.id,
536 };
537 Some(ViewData {
538 random: Some(random_view_props),
539 ..state.additional_view_data.clone()
540 })
541 } else {
542 Some(ViewData {
543 random: None,
544 ..state.additional_view_data.clone()
545 })
546 }
547 }
548
549 ActiveView::None
550 | ActiveView::Search
551 | ActiveView::Songs
552 | ActiveView::Albums
553 | ActiveView::Artists
554 | ActiveView::Playlists
555 | ActiveView::DynamicPlaylists
556 | ActiveView::Collections => None,
557 }
558}