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 content_view::{
22 views::{
23 AlbumViewProps, ArtistViewProps, CollectionViewProps, DynamicPlaylistViewProps,
24 PlaylistViewProps, RadioViewProps, RandomViewProps, SongViewProps, ViewData,
25 },
26 ActiveView,
27 },
28 Component, ComponentRender,
29};
30use crossterm::{
31 event::{
32 DisableMouseCapture, EnableMouseCapture, Event, EventStream, PopKeyboardEnhancementFlags,
33 },
34 execute,
35 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
36};
37use mecomp_core::{
38 config::Settings,
39 rpc::{MusicPlayerClient, SearchResult},
40 state::{library::LibraryFull, StateAudio},
41};
42use mecomp_storage::db::schemas::{album, artist, collection, dynamic, playlist, song, RecordId};
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::{action::Action, component::ActiveComponent, Receivers},
51 termination::Interrupted,
52};
53
54#[derive(Debug, Clone, Default)]
55pub struct AppState {
56 pub active_component: ActiveComponent,
57 pub audio: StateAudio,
58 pub search: SearchResult,
59 pub library: LibraryFull,
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 song_view_props = SongViewProps {
257 id: song_id,
258 song,
259 artists,
260 album,
261 playlists,
262 collections,
263 };
264 Some(ViewData {
265 song: Some(song_view_props),
266 ..state.additional_view_data.clone()
267 })
268 } else {
269 Some(ViewData {
270 song: None,
271 ..state.additional_view_data.clone()
272 })
273 }
274 }
275 ActiveView::Album(id) => {
276 let album_id = RecordId {
277 tb: album::TABLE_NAME.to_string(),
278 id: id.to_owned(),
279 };
280
281 if let Ok((Some(album), artists, Some(songs))) = tokio::try_join!(
282 daemon.library_album_get(Context::current(), album_id.clone()),
283 daemon.library_album_get_artist(Context::current(), album_id.clone()),
284 daemon.library_album_get_songs(Context::current(), album_id.clone()),
285 ) {
286 let album_view_props = AlbumViewProps {
287 id: album_id,
288 album,
289 artists,
290 songs,
291 };
292 Some(ViewData {
293 album: Some(album_view_props),
294 ..state.additional_view_data.clone()
295 })
296 } else {
297 Some(ViewData {
298 album: None,
299 ..state.additional_view_data.clone()
300 })
301 }
302 }
303 ActiveView::Artist(id) => {
304 let artist_id = RecordId {
305 tb: artist::TABLE_NAME.to_string(),
306 id: id.to_owned(),
307 };
308
309 if let Ok((Some(artist), Some(albums), Some(songs))) = tokio::try_join!(
310 daemon.library_artist_get(Context::current(), artist_id.clone()),
311 daemon.library_artist_get_albums(Context::current(), artist_id.clone()),
312 daemon.library_artist_get_songs(Context::current(), artist_id.clone()),
313 ) {
314 let artist_view_props = ArtistViewProps {
315 id: artist_id,
316 artist,
317 albums,
318 songs,
319 };
320 Some(ViewData {
321 artist: Some(artist_view_props),
322 ..state.additional_view_data.clone()
323 })
324 } else {
325 Some(ViewData {
326 artist: None,
327 ..state.additional_view_data.clone()
328 })
329 }
330 }
331 ActiveView::Playlist(id) => {
332 let playlist_id = RecordId {
333 tb: playlist::TABLE_NAME.to_string(),
334 id: id.to_owned(),
335 };
336
337 if let Ok((Some(playlist), Some(songs))) = tokio::try_join!(
338 daemon.playlist_get(Context::current(), playlist_id.clone()),
339 daemon.playlist_get_songs(Context::current(), playlist_id.clone()),
340 ) {
341 let playlist_view_props = PlaylistViewProps {
342 id: playlist_id,
343 playlist,
344 songs,
345 };
346 Some(ViewData {
347 playlist: Some(playlist_view_props),
348 ..state.additional_view_data.clone()
349 })
350 } else {
351 Some(ViewData {
352 playlist: None,
353 ..state.additional_view_data.clone()
354 })
355 }
356 }
357 ActiveView::DynamicPlaylist(id) => {
358 let dynamic_playlist_id = RecordId {
359 tb: dynamic::TABLE_NAME.to_string(),
360 id: id.to_owned(),
361 };
362
363 if let Ok((Some(dynamic_playlist), Some(songs))) = tokio::try_join!(
364 daemon.dynamic_playlist_get(Context::current(), dynamic_playlist_id.clone()),
365 daemon.dynamic_playlist_get_songs(Context::current(), dynamic_playlist_id.clone()),
366 ) {
367 let dynamic_playlist_view_props = DynamicPlaylistViewProps {
368 id: dynamic_playlist_id,
369 songs,
370 dynamic_playlist,
371 };
372 Some(ViewData {
373 dynamic_playlist: Some(dynamic_playlist_view_props),
374 ..state.additional_view_data.clone()
375 })
376 } else {
377 Some(ViewData {
378 dynamic_playlist: None,
379 ..state.additional_view_data.clone()
380 })
381 }
382 }
383 ActiveView::Collection(id) => {
384 let collection_id = RecordId {
385 tb: collection::TABLE_NAME.to_string(),
386 id: id.to_owned(),
387 };
388
389 if let Ok((Some(collection), Some(songs))) = tokio::try_join!(
390 daemon.collection_get(Context::current(), collection_id.clone()),
391 daemon.collection_get_songs(Context::current(), collection_id.clone()),
392 ) {
393 let collection_view_props = CollectionViewProps {
394 id: collection_id,
395 collection,
396 songs,
397 };
398 Some(ViewData {
399 collection: Some(collection_view_props),
400 ..state.additional_view_data.clone()
401 })
402 } else {
403 Some(ViewData {
404 collection: None,
405 ..state.additional_view_data.clone()
406 })
407 }
408 }
409 ActiveView::Radio(ids) => {
410 let count = state.settings.tui.radio_count;
411 if let Ok(Ok(songs)) = daemon
412 .radio_get_similar(Context::current(), ids.clone(), count)
413 .await
414 {
415 let radio_view_props = RadioViewProps { count, songs };
416 Some(ViewData {
417 radio: Some(radio_view_props),
418 ..state.additional_view_data.clone()
419 })
420 } else {
421 Some(ViewData {
422 radio: None,
423 ..state.additional_view_data.clone()
424 })
425 }
426 }
427 ActiveView::Random => {
428 if let Ok((Some(album), Some(artist), Some(song))) = tokio::try_join!(
429 daemon.rand_album(Context::current()),
430 daemon.rand_artist(Context::current()),
431 daemon.rand_song(Context::current()),
432 ) {
433 let random_view_props = RandomViewProps {
434 album: album.id.into(),
435 artist: artist.id.into(),
436 song: song.id.into(),
437 };
438 Some(ViewData {
439 random: Some(random_view_props),
440 ..state.additional_view_data.clone()
441 })
442 } else {
443 Some(ViewData {
444 random: None,
445 ..state.additional_view_data.clone()
446 })
447 }
448 }
449
450 ActiveView::None
451 | ActiveView::Search
452 | ActiveView::Songs
453 | ActiveView::Albums
454 | ActiveView::Artists
455 | ActiveView::Playlists
456 | ActiveView::DynamicPlaylists
457 | ActiveView::Collections => None,
458 }
459}