mod app; mod config; mod rpc; use std::{ error::Error, io, sync::OnceLock, time::{Duration, Instant}, }; use crabidy_core::proto::crabidy::{get_update_stream_response::Update as StreamUpdate, PlayState}; use crossterm::{ event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use flume::{Receiver, Sender}; use ratatui::{backend::CrosstermBackend, Terminal}; use tokio::select; use tokio_stream::StreamExt; use app::{bindings, App, DispatchResult, MessageFromUi, MessageToUi}; use config::Config; use rpc::RpcClient; use tracing::{error, info, warn}; static CONFIG: OnceLock = OnceLock::new(); /// Logs to a file: the terminal is owned by the TUI, so writing log lines to /// stdout/stderr would corrupt the interface. fn init_tracing() -> Option { use tracing_subscriber::{prelude::*, EnvFilter}; let log_dir = dirs::state_dir() .or_else(dirs::cache_dir) .unwrap_or_else(std::env::temp_dir) .join("crabidy"); if let Err(err) = std::fs::create_dir_all(&log_dir) { eprintln!( "could not create log directory {}: {err}", log_dir.display() ); return None; } let file_appender = tracing_appender::rolling::daily(&log_dir, "cbd-tui.log"); let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); let env_filter = EnvFilter::try_from_default_env() .unwrap_or_else(|_| EnvFilter::new("info,cbd_tui=debug,crabidy_core=debug")); tracing_subscriber::registry() .with(env_filter) .with( tracing_subscriber::fmt::layer() .with_writer(non_blocking) .with_ansi(false) .with_target(true), ) .init(); Some(guard) } #[tokio::main] async fn main() -> Result<(), Box> { let _log_guard = init_tracing(); let config = CONFIG.get_or_init(|| crabidy_core::init_config("cbd-tui.toml")); let (ui_tx, rx): (Sender, Receiver) = flume::unbounded(); let (tx, ui_rx): (Sender, Receiver) = flume::unbounded(); // FIXME: unwrap tokio::spawn(async move { orchestrate(config, (tx, rx)).await.unwrap() }); tokio::task::spawn_blocking(|| { run_ui(ui_tx, ui_rx); }) .await?; Ok(()) } async fn orchestrate( config: &'static Config, (tx, rx): (Sender, Receiver), ) -> Result<(), Box> { info!(address = config.server.address, "connecting to server"); let mut rpc_client = rpc::RpcClient::connect(&config.server.address).await?; if let Some(root_node) = rpc_client.get_library_node(crabidy_core::ROOT_PATH).await? { tx.send(MessageToUi::ReplaceLibraryNode(root_node.clone()))?; } let init_data = rpc_client.init().await?; info!("received initial state from server"); tx.send_async(MessageToUi::Init(init_data)).await?; loop { if let Err(err) = poll(&mut rpc_client, &rx, &tx).await { error!("request to server failed: {err}"); } } } async fn poll( rpc_client: &mut RpcClient, rx: &Receiver, tx: &Sender, ) -> Result<(), Box> { select! { Ok(msg) = &mut rx.recv_async() => { match msg { MessageFromUi::GetLibraryNode(path) => { if let Some(node) = rpc_client.get_library_node(&path).await? { let _ = tx.send(MessageToUi::ReplaceLibraryNode(node.clone())); } }, MessageFromUi::CreateNode { parent_path, title } => { // Navigates the library into the created node on // success; on failure the library stays where it is. match rpc_client.create_library_node(&parent_path, &title).await { Ok(node) => { let _ = tx.send(MessageToUi::ReplaceLibraryNode(node.clone())); } Err(err) => { error!(parent_path, title, "failed to create node: {err}"); } } }, MessageFromUi::RenameNode { path, new_title } => { // Navigates the library into the renamed node on success; // on failure the library stays where it is. match rpc_client.rename_library_node(&path, &new_title).await { Ok(node) => { let _ = tx.send(MessageToUi::ReplaceLibraryNode(node.clone())); } Err(err) => { error!(path, new_title, "failed to rename node: {err}"); } } }, MessageFromUi::DeleteNode { path } => { // Shows the refreshed parent listing on success; on // failure the library stays where it is. match rpc_client.delete_library_node(&path).await { Ok(parent) => { let _ = tx.send(MessageToUi::ReplaceLibraryNode(parent.clone())); } Err(err) => { error!(path, "failed to delete node: {err}"); } } }, MessageFromUi::AppendTracks(uuids) => { rpc_client.append_tracks(uuids).await? } MessageFromUi::QueueTracks(uuids) => { rpc_client.queue_tracks(uuids).await? } MessageFromUi::InsertTracks(uuids, pos) => { rpc_client.insert_tracks(uuids, pos).await? } MessageFromUi::RemoveTracks(positions) => { rpc_client.remove_tracks(positions).await? } MessageFromUi::ReplaceQueue(uuids) => { rpc_client.replace_queue(uuids).await? } MessageFromUi::NextTrack => { rpc_client.next_track().await? } MessageFromUi::PrevTrack => { rpc_client.prev_track().await? } MessageFromUi::RestartTrack => { rpc_client.restart_track().await? } MessageFromUi::SetCurrentTrack(pos) => { rpc_client.set_current_track(pos).await? } MessageFromUi::TogglePlay => { rpc_client.toggle_play().await? } MessageFromUi::ChangeVolume(delta) => { rpc_client.change_volume(delta).await? } MessageFromUi::ToggleMute => { rpc_client.toggle_mute().await? } MessageFromUi::ToggleShuffle => { rpc_client.toggle_shuffle().await? } MessageFromUi::ToggleRepeat => { rpc_client.toggle_repeat().await? } MessageFromUi::ClearQueue(exclude_current) => { rpc_client.clear_queue(exclude_current).await? } } } Some(resp) = rpc_client.update_stream.next() => { match resp { Ok(resp) => { if let Some(update) = resp.update { tx.send_async(MessageToUi::Update(update)).await?; } } Err(err) => { warn!("update stream broke, reconnecting: {err}"); rpc_client.reconnect_update_stream().await; info!("update stream reconnected"); } } } } Ok(()) } fn run_ui(tx: Sender, rx: Receiver) { // setup terminal enable_raw_mode().unwrap(); let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen, EnableMouseCapture).unwrap(); let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend).unwrap(); // create app and run it let mut app = App::new(tx); let tick_rate = Duration::from_millis(100); let mut last_tick = Instant::now(); loop { for message in rx.try_iter() { match message { MessageToUi::ReplaceLibraryNode(node) => { app.library.update(node); } MessageToUi::Init(init_data) => { if let Some(queue) = init_data.queue { app.queue.update_queue(queue); } if let Some(track) = init_data.queue_track { app.now_playing.update_track(track.track); app.queue.update_position(track.queue_position as usize); } if let Ok(ps) = PlayState::try_from(init_data.play_state) { app.now_playing.update_play_state(ps); } if let Some(mods) = init_data.mods { app.now_playing.update_modifiers(&mods); } } MessageToUi::Update(update) => match update { StreamUpdate::Queue(queue) => { app.queue.update_queue(queue); } StreamUpdate::QueueTrack(track) => { app.now_playing.update_track(track.track); app.queue.update_position(track.queue_position as usize); } StreamUpdate::Position(pos) => app.now_playing.update_position(pos), StreamUpdate::PlayState(play_state) => { if let Ok(ps) = PlayState::try_from(play_state) { app.now_playing.update_play_state(ps); } } StreamUpdate::Mods(mods) => { app.now_playing.update_modifiers(&mods); } StreamUpdate::Mute(_) => { /* FIXME: implement */ } StreamUpdate::Volume(_) => { /* FIXME: implement */ } }, } } if let Err(err) = terminal.draw(|f| app.render(f)) { error!("failed to draw frame: {err}"); break; } let timeout = tick_rate .checked_sub(last_tick.elapsed()) .unwrap_or_else(|| Duration::from_secs(0)); if event::poll(timeout).unwrap() { if let Event::Key(key) = event::read().unwrap() { if key.kind == KeyEventKind::Press { // The input overlay is strictly modal: while it is open, // keys edit the buffer and the bindings table (including // quit) is unreachable. if app.input.is_some() { app.handle_input_key(key); } else if let Some(action) = bindings::lookup(app.focus, app.show_help, key) { if app.dispatch(action) == DispatchResult::Quit { break; } } } } } if last_tick.elapsed() >= tick_rate { last_tick = Instant::now(); } } // restore terminal disable_raw_mode().unwrap(); execute!( terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture ) .unwrap(); terminal.show_cursor().unwrap(); }