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