1pub mod app;
8pub mod colors;
9pub mod components;
10pub mod widgets;
11
12use std::{
13 io::{self, Stdout},
14 sync::Arc,
15 time::Duration,
16};
17
18use anyhow::Context as _;
19use app::App;
20use components::{
21 Component, ComponentRender,
22 content_view::{
23 ActiveView,
24 views::{
25 AlbumViewProps, ArtistViewProps, CollectionViewProps, DynamicPlaylistViewProps,
26 PlaylistViewProps, RadioViewProps, RandomViewProps, SongViewProps, ViewData,
27 },
28 },
29};
30use crossterm::{
31 event::{
32 DisableMouseCapture, EnableMouseCapture, Event, EventStream, PopKeyboardEnhancementFlags,
33 },
34 execute,
35 terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
36};
37use mecomp_core::{
38 config::Settings,
39 rpc::{MusicPlayerClient, SearchResult},
40 state::{StateAudio, library::LibraryBrief},
41};
42use mecomp_storage::db::schemas::{RecordId, album, artist, collection, dynamic, playlist, song};
43use one_or_many::OneOrMany;
44use ratatui::prelude::*;
45use tarpc::context::Context;
46use tokio::sync::{broadcast, mpsc};
47use tokio_stream::StreamExt;
48
49use crate::{
50 state::{Receivers, action::Action, component::ActiveComponent},
51 termination::Interrupted,
52};
53
54#[derive(Debug, Default)]
55pub struct AppState {
56 pub active_component: ActiveComponent,
57 pub audio: StateAudio,
58 pub search: SearchResult,
59 pub library: LibraryBrief,
60 pub active_view: ActiveView,
61 pub additional_view_data: ViewData,
62 pub settings: Settings,
63}
64
65const RENDERING_TICK_RATE: Duration = Duration::from_millis(250);
66
67#[allow(clippy::module_name_repetitions)]
68pub struct UiManager {
69 action_tx: mpsc::UnboundedSender<Action>,
70}
71
72impl UiManager {
73 #[must_use]
74 pub const fn new(action_tx: mpsc::UnboundedSender<Action>) -> Self {
75 Self { action_tx }
76 }
77
78 pub async fn main_loop(
86 self,
87 daemon: Arc<MusicPlayerClient>,
88 settings: Settings,
89 mut state_rx: Receivers,
90 mut interrupt_rx: broadcast::Receiver<Interrupted>,
91 ) -> anyhow::Result<Interrupted> {
92 let mut state = AppState {
94 active_component: ActiveComponent::default(),
95 audio: state_rx.audio.recv().await.unwrap_or_default(),
96 search: state_rx.search.recv().await.unwrap_or_default(),
97 library: state_rx.library.recv().await.unwrap_or_default(),
98 active_view: state_rx.view.recv().await.unwrap_or_default(),
99 additional_view_data: ViewData::default(),
100 settings,
101 };
102 let mut app = App::new(&state, self.action_tx.clone());
103
104 let mut terminal = setup_terminal()?;
105 let mut ticker = tokio::time::interval(RENDERING_TICK_RATE);
106 let mut crossterm_events = EventStream::new();
107
108 let result: anyhow::Result<Interrupted> = loop {
109 tokio::select! {
110 _ = ticker.tick() => (),
112 maybe_event = crossterm_events.next() => match maybe_event {
114 Some(Ok(Event::Key(key))) => {
115 app.handle_key_event(key);
116 },
117 Some(Ok(Event::Mouse(mouse))) => {
118 let terminal_size = terminal.size().context("could not get terminal size")?;
119 let area = Rect::new(0, 0, terminal_size.width, terminal_size.height);
120 app.handle_mouse_event(mouse, area);
121 },
122 None => break Ok(Interrupted::UserInt),
123 _ => (),
124 },
125 Some(audio) = state_rx.audio.recv() => {
127 state = AppState {
128 audio,
129 ..state
130 };
131 app = app.move_with_audio(&state);
132 },
133 Some(search) = state_rx.search.recv() => {
134 state = AppState {
135 search,
136 ..state
137 };
138 app = app.move_with_search(&state);
139 },
140 Some(library) = state_rx.library.recv() => {
141 state = AppState {
142 library,
143 additional_view_data: Box::pin(handle_additional_view_data(daemon.clone(), &state, &state.active_view)).await.unwrap_or(state.additional_view_data),
145 ..state
146 };
147 app = app.move_with_library(&state);
148 },
149 Some(active_view) = state_rx.view.recv() => {
150 let additional_view_data = Box::pin(handle_additional_view_data(daemon.clone(), &state, &active_view)).await.unwrap_or(state.additional_view_data);
152
153 state = AppState {
154 active_view,
155 additional_view_data,
156 ..state
157 };
158 app = app.move_with_view(&state);
159 },
160 Some(active_component) = state_rx.component.recv() => {
161 state = AppState {
162 active_component,
163 ..state
164 };
165 app = app.move_with_component(&state);
166 },
167 Some(popup) = state_rx.popup.recv() => {
168 app = app.move_with_popup( popup.map(|popup| {
169 popup.into_popup(&state, self.action_tx.clone())
170 }));
171 }
172 Ok(interrupted) = interrupt_rx.recv() => {
174 break Ok(interrupted);
175 }
176 }
177
178 if let Err(err) = terminal
179 .draw(|frame| app.render(frame, frame.area()))
180 .context("could not render to the terminal")
181 {
182 break Err(err);
183 }
184 };
185
186 restore_terminal(&mut terminal)?;
187
188 result
189 }
190}
191
192#[cfg(not(tarpaulin_include))]
193fn setup_terminal() -> anyhow::Result<Terminal<CrosstermBackend<Stdout>>> {
194 let mut stdout = io::stdout();
195
196 enable_raw_mode()?;
197
198 execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
199
200 Ok(Terminal::new(CrosstermBackend::new(stdout))?)
201}
202
203#[cfg(not(tarpaulin_include))]
204fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> anyhow::Result<()> {
205 disable_raw_mode()?;
206
207 execute!(
208 terminal.backend_mut(),
209 LeaveAlternateScreen,
210 DisableMouseCapture,
211 PopKeyboardEnhancementFlags,
212 )?;
213
214 Ok(terminal.show_cursor()?)
215}
216
217#[cfg(not(tarpaulin_include))]
218pub fn init_panic_hook() {
219 let original_hook = std::panic::take_hook();
220 std::panic::set_hook(Box::new(move |panic_info| {
221 let _ = disable_raw_mode();
223 let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture);
224
225 original_hook(panic_info);
226 }));
227}
228
229#[allow(clippy::too_many_lines)]
231async fn handle_additional_view_data(
232 daemon: Arc<MusicPlayerClient>,
233 state: &AppState,
234 active_view: &ActiveView,
235) -> Option<ViewData> {
236 match active_view {
237 ActiveView::Song(id) => {
238 let song_id = RecordId {
239 tb: song::TABLE_NAME.to_string(),
240 id: id.to_owned(),
241 };
242
243 if let Ok((
244 Some(song),
245 artists @ (OneOrMany::Many(_) | OneOrMany::One(_)),
246 Some(album),
247 playlists,
248 collections,
249 )) = tokio::try_join!(
250 daemon.library_song_get(Context::current(), song_id.clone()),
251 daemon.library_song_get_artist(Context::current(), song_id.clone()),
252 daemon.library_song_get_album(Context::current(), song_id.clone()),
253 daemon.library_song_get_playlists(Context::current(), song_id.clone()),
254 daemon.library_song_get_collections(Context::current(), song_id.clone()),
255 ) {
256 let artists = artists.into_iter().map(Into::into).collect();
257 let album = album.into();
258 let playlists = playlists.into_iter().map(Into::into).collect();
259 let collections = collections.into_iter().map(Into::into).collect();
260 let song_view_props = SongViewProps {
261 id: song_id,
262 song,
263 artists,
264 album,
265 playlists,
266 collections,
267 };
268 Some(ViewData {
269 song: Some(song_view_props),
270 ..state.additional_view_data.clone()
271 })
272 } else {
273 Some(ViewData {
274 song: None,
275 ..state.additional_view_data.clone()
276 })
277 }
278 }
279 ActiveView::Album(id) => {
280 let album_id = RecordId {
281 tb: album::TABLE_NAME.to_string(),
282 id: id.to_owned(),
283 };
284
285 if let Ok((Some(album), artists, Some(songs))) = tokio::try_join!(
286 daemon.library_album_get(Context::current(), album_id.clone()),
287 daemon.library_album_get_artist(Context::current(), album_id.clone()),
288 daemon.library_album_get_songs(Context::current(), album_id.clone()),
289 ) {
290 let artists = artists.into_iter().map(Into::into).collect();
291 let songs = songs.into_iter().map(Into::into).collect();
292 let album_view_props = AlbumViewProps {
293 id: album_id,
294 album,
295 artists,
296 songs,
297 };
298 Some(ViewData {
299 album: Some(album_view_props),
300 ..state.additional_view_data.clone()
301 })
302 } else {
303 Some(ViewData {
304 album: None,
305 ..state.additional_view_data.clone()
306 })
307 }
308 }
309 ActiveView::Artist(id) => {
310 let artist_id = RecordId {
311 tb: artist::TABLE_NAME.to_string(),
312 id: id.to_owned(),
313 };
314
315 if let Ok((Some(artist), Some(albums), Some(songs))) = tokio::try_join!(
316 daemon.library_artist_get(Context::current(), artist_id.clone()),
317 daemon.library_artist_get_albums(Context::current(), artist_id.clone()),
318 daemon.library_artist_get_songs(Context::current(), artist_id.clone()),
319 ) {
320 let albums = albums.into_iter().map(Into::into).collect();
321 let songs = songs.into_iter().map(Into::into).collect();
322 let artist_view_props = ArtistViewProps {
323 id: artist_id,
324 artist,
325 albums,
326 songs,
327 };
328 Some(ViewData {
329 artist: Some(artist_view_props),
330 ..state.additional_view_data.clone()
331 })
332 } else {
333 Some(ViewData {
334 artist: None,
335 ..state.additional_view_data.clone()
336 })
337 }
338 }
339 ActiveView::Playlist(id) => {
340 let playlist_id = RecordId {
341 tb: playlist::TABLE_NAME.to_string(),
342 id: id.to_owned(),
343 };
344
345 if let Ok((Some(playlist), Some(songs))) = tokio::try_join!(
346 daemon.playlist_get(Context::current(), playlist_id.clone()),
347 daemon.playlist_get_songs(Context::current(), playlist_id.clone()),
348 ) {
349 let songs = songs.into_iter().map(Into::into).collect();
350 let playlist_view_props = PlaylistViewProps {
351 id: playlist_id,
352 playlist,
353 songs,
354 };
355 Some(ViewData {
356 playlist: Some(playlist_view_props),
357 ..state.additional_view_data.clone()
358 })
359 } else {
360 Some(ViewData {
361 playlist: None,
362 ..state.additional_view_data.clone()
363 })
364 }
365 }
366 ActiveView::DynamicPlaylist(id) => {
367 let dynamic_playlist_id = RecordId {
368 tb: dynamic::TABLE_NAME.to_string(),
369 id: id.to_owned(),
370 };
371
372 if let Ok((Some(dynamic_playlist), Some(songs))) = tokio::try_join!(
373 daemon.dynamic_playlist_get(Context::current(), dynamic_playlist_id.clone()),
374 daemon.dynamic_playlist_get_songs(Context::current(), dynamic_playlist_id.clone()),
375 ) {
376 let songs = songs.into_iter().map(Into::into).collect();
377 let dynamic_playlist_view_props = DynamicPlaylistViewProps {
378 id: dynamic_playlist_id,
379 dynamic_playlist,
380 songs,
381 };
382 Some(ViewData {
383 dynamic_playlist: Some(dynamic_playlist_view_props),
384 ..state.additional_view_data.clone()
385 })
386 } else {
387 Some(ViewData {
388 dynamic_playlist: None,
389 ..state.additional_view_data.clone()
390 })
391 }
392 }
393 ActiveView::Collection(id) => {
394 let collection_id = RecordId {
395 tb: collection::TABLE_NAME.to_string(),
396 id: id.to_owned(),
397 };
398
399 if let Ok((Some(collection), Some(songs))) = tokio::try_join!(
400 daemon.collection_get(Context::current(), collection_id.clone()),
401 daemon.collection_get_songs(Context::current(), collection_id.clone()),
402 ) {
403 let songs = songs.into_iter().map(Into::into).collect();
404 let collection_view_props = CollectionViewProps {
405 id: collection_id,
406 collection,
407 songs,
408 };
409 Some(ViewData {
410 collection: Some(collection_view_props),
411 ..state.additional_view_data.clone()
412 })
413 } else {
414 Some(ViewData {
415 collection: None,
416 ..state.additional_view_data.clone()
417 })
418 }
419 }
420 ActiveView::Radio(ids) => {
421 let count = state.settings.tui.radio_count;
422 let radio_view_props = if let Ok(Ok(songs)) = daemon
423 .radio_get_similar(Context::current(), ids.clone(), count)
424 .await
425 {
426 let songs = songs.into_iter().map(Into::into).collect();
427 Some(RadioViewProps { count, songs })
428 } else {
429 None
430 };
431 Some(ViewData {
432 radio: radio_view_props,
433 ..state.additional_view_data.clone()
434 })
435 }
436 ActiveView::Random => {
437 if let Ok((Some(album), Some(artist), Some(song))) = tokio::try_join!(
438 daemon.rand_album(Context::current()),
439 daemon.rand_artist(Context::current()),
440 daemon.rand_song(Context::current()),
441 ) {
442 let random_view_props = RandomViewProps {
443 album: album.id.into(),
444 artist: artist.id.into(),
445 song: song.id.into(),
446 };
447 Some(ViewData {
448 random: Some(random_view_props),
449 ..state.additional_view_data.clone()
450 })
451 } else {
452 Some(ViewData {
453 random: None,
454 ..state.additional_view_data.clone()
455 })
456 }
457 }
458
459 ActiveView::None
460 | ActiveView::Search
461 | ActiveView::Songs
462 | ActiveView::Albums
463 | ActiveView::Artists
464 | ActiveView::Playlists
465 | ActiveView::DynamicPlaylists
466 | ActiveView::Collections => None,
467 }
468}