diff --git a/Cargo.lock b/Cargo.lock index 8842f60..e47da8e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -375,19 +375,14 @@ checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" name = "cbd-tui" version = "0.1.0" dependencies = [ - "clap", - "clap-serde-derive", "crabidy-core", "crossterm", - "dirs", "flume", - "lazy_static", "notify-rust", "ratatui", "serde", "tokio", "tokio-stream", - "toml 0.7.4", "tonic", ] @@ -638,8 +633,12 @@ name = "crabidy-core" version = "0.1.0" dependencies = [ "async-trait", + "clap", + "clap-serde-derive", + "dirs", "prost", "serde", + "toml 0.7.4", "tonic", "tonic-build", ] diff --git a/cbd-tui/Cargo.toml b/cbd-tui/Cargo.toml index 4a4f8a9..51b5994 100644 --- a/cbd-tui/Cargo.toml +++ b/cbd-tui/Cargo.toml @@ -14,9 +14,4 @@ tokio = { version = "1", features = ["full"] } tokio-stream = "0.1" tonic = "0.9" notify-rust = "4.8.0" -clap = "4.3.3" -clap-serde-derive = "0.2.0" serde = "1.0.164" -toml = "0.7.4" -dirs = "5.0.1" -lazy_static = "1.4.0" diff --git a/cbd-tui/src/app/library.rs b/cbd-tui/src/app/library.rs new file mode 100644 index 0000000..6ac6c38 --- /dev/null +++ b/cbd-tui/src/app/library.rs @@ -0,0 +1,230 @@ +use std::collections::HashMap; + +use flume::{Receiver, Sender}; +use ratatui::{ + backend::Backend, + layout::{Alignment, Constraint, Corner, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Span, Spans}, + widgets::{ + Block, BorderType, Borders, Gauge, LineGauge, List, ListItem, ListState, Paragraph, Wrap, + }, + Frame, +}; + +use crabidy_core::proto::crabidy::LibraryNode; + +use super::{ + MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_GREEN, COLOR_PRIMARY, COLOR_PRIMARY_DARK, +}; + +pub struct Library { + title: String, + uuid: String, + list: Vec, + list_state: ListState, + parent: Option, + positions: HashMap, + tx: Sender, +} + +impl Library { + pub fn new(tx: Sender) -> Self { + Self { + title: "Library".to_string(), + uuid: "node:/".to_string(), + list: Vec::new(), + list_state: ListState::default(), + positions: HashMap::new(), + parent: None, + tx, + } + } + pub fn get_selected(&self) -> Option> { + if self.list.iter().any(|i| i.marked) { + return Some( + self.list + .iter() + .filter(|i| i.marked) + .map(|i| i.uuid.to_string()) + .collect(), + ); + } + if let Some(idx) = self.list_state.selected() { + return Some(vec![self.list[idx].uuid.to_string()]); + } + None + } + pub fn ascend(&mut self) { + if let Some(parent) = self.parent.as_ref() { + self.tx.send(MessageFromUi::GetLibraryNode(parent.clone())); + } + } + pub fn dive(&mut self) { + if let Some(idx) = self.list_state.selected() { + let item = &self.list[idx]; + if let UiItemKind::Node = item.kind { + self.tx + .send(MessageFromUi::GetLibraryNode(item.uuid.clone())); + } + } + } + pub fn queue_append(&mut self) { + if let Some(items) = self.get_selected() { + match self.tx.send(MessageFromUi::AppendTracks(items)) { + Ok(_) => self.remove_marks(), + Err(_) => { /* FIXME: warn */ } + } + } + } + pub fn queue_queue(&mut self) { + if let Some(items) = self.get_selected() { + match self.tx.send(MessageFromUi::QueueTracks(items)) { + Ok(_) => self.remove_marks(), + Err(_) => { /* FIXME: warn */ } + } + } + } + pub fn queue_replace(&mut self) { + if let Some(items) = self.get_selected() { + match self.tx.send(MessageFromUi::ReplaceQueue(items)) { + Ok(_) => self.remove_marks(), + Err(_) => { /* FIXME: warn */ } + } + } + } + pub fn queue_insert(&mut self, pos: usize) { + if let Some(items) = self.get_selected() { + match self.tx.send(MessageFromUi::InsertTracks(items, pos)) { + Ok(_) => self.remove_marks(), + Err(_) => { /* FIXME: warn */ } + } + } + } + pub fn prev_selected(&self) -> usize { + *self.positions.get(&self.uuid).unwrap_or(&0) + } + pub fn toggle_mark(&mut self) { + if let Some(idx) = self.list_state.selected() { + let mut item = &mut self.list[idx]; + if !item.is_queable { + return; + } + item.marked = !item.marked; + } + } + pub fn remove_marks(&mut self) { + if self.list.iter().any(|i| i.marked) { + self.list + .iter_mut() + .filter(|i| i.marked) + .for_each(|i| i.marked = false); + } + } + pub fn update(&mut self, node: LibraryNode) { + if node.tracks.is_empty() && node.children.is_empty() { + return; + } + + // if children empty and tracks empty return + self.uuid = node.uuid; + self.title = node.title; + self.parent = node.parent; + self.select(Some(self.prev_selected())); + + if !node.tracks.is_empty() { + self.list = node + .tracks + .iter() + .map(|t| UiItem { + uuid: t.uuid.clone(), + title: format!("{} - {}", t.artist, t.title), + kind: UiItemKind::Track, + marked: false, + is_queable: true, + }) + .collect(); + } else { + // if tracks not empty use tracks instead + self.list = node + .children + .iter() + .map(|c| UiItem { + uuid: c.uuid.clone(), + title: c.title.clone(), + kind: UiItemKind::Node, + marked: false, + is_queable: c.is_queable, + }) + .collect(); + } + + self.update_selection(); + } + + pub fn render(&mut self, f: &mut Frame, area: Rect, focused: bool) { + let library_items: Vec = self + .list + .iter() + .map(|i| { + let text = if i.marked { + format!("* {}", i.title) + } else { + i.title.to_string() + }; + let style = if i.marked { + Style::default() + .fg(COLOR_GREEN) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + return ListItem::new(Span::from(text)).style(style); + }) + .collect(); + + let library_list = List::new(library_items) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(if focused { + COLOR_PRIMARY + } else { + COLOR_PRIMARY_DARK + })) + .title(self.title.clone()), + ) + .highlight_style( + Style::default() + .bg(if focused { + COLOR_PRIMARY + } else { + COLOR_PRIMARY_DARK + }) + .add_modifier(Modifier::BOLD), + ); + + f.render_stateful_widget(library_list, area, &mut self.list_state); + } +} + +impl StatefulList for Library { + fn get_size(&self) -> usize { + self.list.len() + } + + fn select(&mut self, idx: Option) { + if let Some(pos) = idx { + self.positions + .entry(self.uuid.clone()) + .and_modify(|e| *e = pos) + .or_insert(pos); + } + self.list_state.select(idx); + } + + fn selected(&self) -> Option { + self.list_state.selected() + } +} diff --git a/cbd-tui/src/app/list.rs b/cbd-tui/src/app/list.rs new file mode 100644 index 0000000..8996457 --- /dev/null +++ b/cbd-tui/src/app/list.rs @@ -0,0 +1,98 @@ +pub use ratatui::widgets::ListState; + +pub trait StatefulList { + fn get_size(&self) -> usize; + fn select(&mut self, idx: Option); + fn selected(&self) -> Option; + + fn first(&mut self) { + if self.is_empty() { + return; + } + self.select(Some(0)); + } + + fn last(&mut self) { + if self.is_empty() { + return; + } + self.select(Some(self.get_size() - 1)); + } + + fn next(&mut self) { + if self.is_empty() { + return; + } + if let Some(i) = self.selected() { + let next = if i == self.get_size() - 1 { 0 } else { i + 1 }; + self.select(Some(next)); + } else { + self.select(Some(0)); + } + } + + fn prev(&mut self) { + if self.is_empty() { + return; + } + if let Some(i) = self.selected() { + let prev = if i == 0 { self.get_size() - 1 } else { i - 1 }; + self.select(Some(prev)); + } else { + self.select(Some(0)); + } + } + + fn down(&mut self) { + if self.is_empty() { + return; + } + if let Some(i) = self.selected() { + let next = if i < self.get_size().saturating_sub(15) { + i + 15 + } else { + self.get_size() - 1 + }; + self.select(Some(next)); + } else { + self.select(Some(0)); + } + } + + fn up(&mut self) { + if self.is_empty() { + return; + } + if let Some(i) = self.selected() { + let prev = if i < 15 { 0 } else { i.saturating_sub(15) }; + self.select(Some(prev)); + } else { + self.select(Some(0)); + } + } + + fn is_selected(&self) -> bool { + self.selected().is_some() + } + + fn is_empty(&self) -> bool { + self.get_size() == 0 + } + + fn update_selection(&mut self) { + if self.is_empty() { + self.select(None); + return; + } + match self.selected() { + None => { + self.select(Some(0)); + } + Some(selected) => { + if selected > self.get_size().saturating_sub(1) { + self.select(Some(self.get_size() - 1)); + } + } + } + } +} diff --git a/cbd-tui/src/app/mod.rs b/cbd-tui/src/app/mod.rs new file mode 100644 index 0000000..9cacc05 --- /dev/null +++ b/cbd-tui/src/app/mod.rs @@ -0,0 +1,132 @@ +mod library; +mod list; +mod now_playing; +mod queue; + +use flume::{Receiver, Sender}; +use ratatui::{ + backend::Backend, + layout::{Alignment, Constraint, Corner, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Span, Spans}, + widgets::{ + Block, BorderType, Borders, Gauge, LineGauge, List, ListItem, ListState, Paragraph, Wrap, + }, + Frame, +}; + +use crabidy_core::proto::crabidy::{ + get_update_stream_response::Update as StreamUpdate, InitResponse as InitialData, LibraryNode, +}; + +pub use list::StatefulList; + +use library::Library; +use now_playing::NowPlaying; +use queue::Queue; + +#[derive(Clone, Copy)] +pub enum UiFocus { + Library, + Queue, +} + +#[derive(Clone, Copy)] +enum UiItemKind { + Node, + Track, +} + +struct UiItem { + uuid: String, + title: String, + kind: UiItemKind, + marked: bool, + is_queable: bool, +} + +pub const COLOR_PRIMARY: Color = Color::Rgb(129, 161, 193); +// const COLOR_PRIMARY_DARK: Color = Color::Rgb(94, 129, 172); +pub const COLOR_PRIMARY_DARK: Color = Color::Rgb(59, 66, 82); +pub const COLOR_SECONDARY: Color = Color::Rgb(180, 142, 173); +pub const COLOR_RED: Color = Color::Rgb(191, 97, 106); +pub const COLOR_GREEN: Color = Color::Rgb(163, 190, 140); +// const COLOR_ORANGE: Color = Color::Rgb(208, 135, 112); +// const COLOR_BRIGHT: Color = Color::Rgb(216, 222, 233); + +// FIXME: Rename this +pub enum MessageToUi { + Init(InitialData), + ReplaceLibraryNode(LibraryNode), + Update(StreamUpdate), +} + +// FIXME: Rename this +pub enum MessageFromUi { + GetLibraryNode(String), + AppendTracks(Vec), + QueueTracks(Vec), + InsertTracks(Vec, usize), + RemoveTracks(Vec), + ReplaceQueue(Vec), + NextTrack, + PrevTrack, + RestartTrack, + SetCurrentTrack(usize), + TogglePlay, + ChangeVolume(f32), + ToggleMute, + ToggleShuffle, + ToggleRepeat, +} + +pub struct App { + pub focus: UiFocus, + pub library: Library, + pub now_playing: NowPlaying, + pub queue: Queue, +} + +impl App { + pub fn new(tx: Sender) -> App { + let library = Library::new(tx.clone()); + let queue = Queue::new(tx); + let now_playing = NowPlaying::default(); + App { + focus: UiFocus::Library, + library, + now_playing, + queue, + } + } + + pub fn cycle_active(&mut self) { + self.focus = match (self.focus, self.queue.is_empty()) { + (UiFocus::Library, false) => UiFocus::Queue, + (UiFocus::Library, true) => UiFocus::Library, + (UiFocus::Queue, _) => UiFocus::Library, + }; + } + + pub fn render(&mut self, f: &mut Frame) { + let full_screen = f.size(); + + let library_focused = matches!(self.focus, UiFocus::Library); + let queue_focused = matches!(self.focus, UiFocus::Queue); + + let main = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) + .split(f.size()); + + self.library.render(f, main[0], library_focused); + + let right_side = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(70), Constraint::Max(10)].as_ref()) + .split(main[1]); + + self.queue.render(f, right_side[0], queue_focused); + self.now_playing.render(f, right_side[1]); + } +} diff --git a/cbd-tui/src/app/now_playing.rs b/cbd-tui/src/app/now_playing.rs new file mode 100644 index 0000000..0617071 --- /dev/null +++ b/cbd-tui/src/app/now_playing.rs @@ -0,0 +1,171 @@ +use std::{ops::Div, time::Duration}; + +use notify_rust::Notification; + +use crabidy_core::proto::crabidy::{PlayState, QueueModifiers, Track, TrackPosition}; + +use ratatui::{ + backend::Backend, + layout::{Alignment, Constraint, Corner, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Span, Spans}, + widgets::{ + Block, BorderType, Borders, Gauge, LineGauge, List, ListItem, ListState, Paragraph, Wrap, + }, + Frame, +}; + +use super::COLOR_SECONDARY; + +pub struct NowPlaying { + play_state: PlayState, + duration: Option, + modifiers: QueueModifiers, + position: Option, + track: Option, +} + +impl Default for NowPlaying { + fn default() -> Self { + NowPlaying { + play_state: PlayState::Unspecified, + duration: None, + modifiers: QueueModifiers::default(), + position: None, + track: None, + } + } +} + +impl NowPlaying { + pub fn update_play_state(&mut self, play_state: PlayState) { + self.play_state = play_state; + } + pub fn update_position(&mut self, pos: TrackPosition) { + self.position = Some(Duration::from_millis(pos.position.into())); + self.duration = Some(Duration::from_millis(pos.duration.into())); + } + pub fn update_track(&mut self, active: Option) { + if let Some(track) = &active { + Notification::new() + .summary("Crabidy playing") + // FIXME: album + .body(&format!("{} by {}", track.title, track.artist)) + .show() + .unwrap(); + } + self.track = active; + } + pub fn update_modifiers(&mut self, mods: &QueueModifiers) { + self.modifiers = mods.clone(); + } + + pub fn render(&self, f: &mut Frame, area: Rect) { + let now_playing_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Max(8), Constraint::Max(1)]) + .split(area); + + let media_info_text = if let Some(track) = &self.track { + let play_text = match self.play_state { + PlayState::Loading => "▼", + PlayState::Paused => "■", + PlayState::Playing => "♫", + _ => "", + }; + let album_text = match &track.album { + Some(album) => album.title.to_string(), + None => "No album".to_string(), + }; + let mods = format!( + "Shuffle: {}, Repeat {}", + self.modifiers.shuffle, self.modifiers.repeat + ); + vec![ + Spans::from(Span::raw(mods)), + Spans::from(Span::raw(play_text)), + Spans::from(vec![ + Span::styled( + track.title.to_string(), + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(" by "), + Span::styled( + track.artist.to_string(), + Style::default().add_modifier(Modifier::BOLD), + ), + ]), + Spans::from(Span::raw(album_text)), + ] + } else { + vec![ + Spans::from(Span::raw("")), + Spans::from(Span::raw("")), + Spans::from(Span::raw("No track playing")), + ] + }; + + let media_info_p = Paragraph::new(media_info_text) + .block( + Block::default() + .title("Now playing") + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(COLOR_SECONDARY)), + ) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }); + + f.render_widget(media_info_p, now_playing_layout[0]); + + if let (Some(position), Some(duration), Some(track)) = + (self.position, self.duration, &self.track) + { + let pos = position.as_secs(); + let dur = duration.as_secs(); + + let completion_size = if dur < 3600 { 12 } else { 15 }; + + let elapsed_layout = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(10), Constraint::Max(completion_size)]) + .split(now_playing_layout[1]); + + let ratio = if duration.is_zero() { + 0.0 + } else { + position.as_secs_f64().div(duration.as_secs_f64()) + }; + + let progress = LineGauge::default() + .label("") + .block(Block::default().borders(Borders::NONE)) + .gauge_style(Style::default().fg(COLOR_SECONDARY).bg(Color::Black)) + .ratio(ratio); + f.render_widget(progress, elapsed_layout[0]); + + let pos_min = (pos / 60) % 60; + let pos_secs = pos % 60; + let dur_min = (dur / 60) % 60; + let dur_secs = dur % 60; + + let completion_text = if dur < 3600 { + format!( + "{:0>2}:{:0>2}/{:0>2}:{:0>2}", + pos_min, pos_secs, dur_min, dur_secs, + ) + } else { + let pos_hours = pos_secs / 60 / 60; + let dur_hours = dur_secs / 60 / 60; + format!( + "{:0>1}:{:0>2}:{:0>2}/{:0>1}:{:0>2}:{:0>2}", + pos_hours, pos_min, pos_secs, dur_hours, dur_min, dur_secs, + ) + }; + + let time_text = Span::raw(completion_text); + let time_p = Paragraph::new(Spans::from(time_text)); + f.render_widget(time_p, elapsed_layout[1]); + } + } +} diff --git a/cbd-tui/src/app/queue.rs b/cbd-tui/src/app/queue.rs new file mode 100644 index 0000000..dbf602a --- /dev/null +++ b/cbd-tui/src/app/queue.rs @@ -0,0 +1,129 @@ +use flume::{Receiver, Sender}; +use ratatui::{ + backend::Backend, + layout::{Alignment, Constraint, Corner, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Span, Spans}, + widgets::{ + Block, BorderType, Borders, Gauge, LineGauge, List, ListItem, ListState, Paragraph, Wrap, + }, + Frame, +}; + +use crabidy_core::proto::crabidy::Queue as QueueData; + +use super::{ + MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_PRIMARY, COLOR_PRIMARY_DARK, COLOR_RED, +}; + +pub struct Queue { + current_position: usize, + list: Vec, + list_state: ListState, + tx: Sender, +} + +impl Queue { + pub fn new(tx: Sender) -> Self { + Self { + current_position: 0, + list: Vec::new(), + list_state: ListState::default(), + tx, + } + } + pub fn play_next(&self) { + self.tx.send(MessageFromUi::NextTrack); + } + pub fn play_prev(&self) { + self.tx.send(MessageFromUi::PrevTrack); + } + pub fn play_selected(&self) { + if let Some(pos) = self.selected() { + self.tx.send(MessageFromUi::SetCurrentTrack(pos)); + } + } + pub fn remove_track(&mut self) { + if let Some(pos) = self.selected() { + // FIXME: mark multiple tracks on queue and remove them + self.tx.send(MessageFromUi::RemoveTracks(vec![pos])); + } + } + pub fn update_position(&mut self, pos: usize) { + self.current_position = pos; + } + pub fn update_queue(&mut self, queue: QueueData) { + self.current_position = queue.current_position as usize; + self.list = queue + .tracks + .iter() + .enumerate() + .map(|(i, t)| UiItem { + uuid: t.uuid.clone(), + title: format!("{} - {}", t.artist, t.title), + kind: UiItemKind::Track, + marked: false, + is_queable: false, + }) + .collect(); + + self.update_selection(); + } + + pub fn render(&mut self, f: &mut Frame, area: Rect, focused: bool) { + let queue_items: Vec = self + .list + .iter() + .enumerate() + .map(|(idx, item)| { + let active = idx == self.current_position; + + let title = if active { + format!("> {}", item.title) + } else { + item.title.to_string() + }; + let style = if active { + Style::default().fg(COLOR_RED).add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + ListItem::new(Span::from(title)).style(style) + }) + .collect(); + + let queue_list = List::new(queue_items) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(if focused { + COLOR_PRIMARY + } else { + COLOR_PRIMARY_DARK + })) + .title("Queue"), + ) + .highlight_style(Style::default().bg(if focused { + COLOR_PRIMARY + } else { + COLOR_PRIMARY_DARK + })); + + f.render_stateful_widget(queue_list, area, &mut self.list_state); + } +} + +impl StatefulList for Queue { + fn get_size(&self) -> usize { + self.list.len() + } + + fn select(&mut self, idx: Option) { + self.list_state.select(idx); + } + + fn selected(&self) -> Option { + self.list_state.selected() + } +} diff --git a/cbd-tui/src/config.rs b/cbd-tui/src/config.rs index d8cf2f0..7e908b2 100644 --- a/cbd-tui/src/config.rs +++ b/cbd-tui/src/config.rs @@ -4,8 +4,9 @@ use std::{ path::Path, }; -use clap_serde_derive::{ +use crabidy_core::{ clap::{self, Parser}, + clap_serde_derive, serde::Serialize, ClapSerde, }; @@ -25,30 +26,3 @@ pub struct ServerConfig { #[clap(short, long)] pub address: String, } - -pub fn init() -> Config { - if let Some(config_dir) = dirs::config_dir() { - let dir = Path::new(&config_dir).join("crabidy"); - if !dir.is_dir() { - create_dir_all(&dir); - } - let config_file_path = dir.join("cbd-tui.toml"); - if !config_file_path.is_file() { - let config = Config::default().merge_clap(); - let content = toml::to_string_pretty(&config).expect("Could not serialize config"); - let mut config_file = - File::create(config_file_path).expect("Could not open config file for writing"); - config_file - .write_all(content.as_bytes()) - .expect("Failed to write to file"); - config_file.flush().ok(); - return config; - } else { - let content = read_to_string(config_file_path).expect("Could not read config file"); - let parsed = toml::from_str::<::Opt>(&content).unwrap(); - let config: Config = Config::from(parsed).merge_clap(); - return config; - } - } - Config::default().merge_clap() -} diff --git a/cbd-tui/src/main.rs b/cbd-tui/src/main.rs index 618b435..c51577b 100644 --- a/cbd-tui/src/main.rs +++ b/cbd-tui/src/main.rs @@ -1,500 +1,82 @@ +mod app; mod config; mod rpc; -use config::Config; +use std::{ + cell::{OnceCell, RefCell}, + collections::HashMap, + error::Error, + fmt, io, + ops::{Div, IndexMut}, + println, + sync::OnceLock, + thread, + time::{Duration, Instant}, + vec, +}; + +use crabidy_core::init_config; use crabidy_core::proto::crabidy::{ crabidy_service_client::CrabidyServiceClient, get_update_stream_response::Update as StreamUpdate, GetLibraryNodeRequest, - InitResponse as InitialData, LibraryNode, PlayState, Queue, QueueModifiers, QueueTrack, Track, - TrackPosition, + InitResponse as InitialData, LibraryNode, PlayState, Queue as QueueData, QueueModifiers, + QueueTrack, Track, TrackPosition, }; use crossterm::{ event::{ self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers, - ModifierKeyCode, }, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use flume::{Receiver, Sender}; -use lazy_static::lazy_static; use notify_rust::Notification; -use ratatui::{ - backend::{Backend, CrosstermBackend}, - layout::{Alignment, Constraint, Corner, Direction, Layout}, - style::{Color, Modifier, Style}, - text::{Span, Spans}, - widgets::{ - Block, BorderType, Borders, Gauge, LineGauge, List, ListItem, ListState, Paragraph, Wrap, - }, - Frame, Terminal, -}; -use rpc::RpcClient; -use std::{ - cell::RefCell, - collections::HashMap, - error::Error, - fmt, io, - ops::{Div, IndexMut}, - println, thread, - time::{Duration, Instant}, - vec, -}; +use ratatui::{backend::CrosstermBackend, Terminal}; use tokio::{fs, select, signal, task}; use tokio_stream::StreamExt; use tonic::{transport::Channel, Request, Status, Streaming}; -const COLOR_PRIMARY: Color = Color::Rgb(129, 161, 193); -// const COLOR_PRIMARY_DARK: Color = Color::Rgb(94, 129, 172); -const COLOR_PRIMARY_DARK: Color = Color::Rgb(59, 66, 82); -const COLOR_SECONDARY: Color = Color::Rgb(180, 142, 173); -const COLOR_RED: Color = Color::Rgb(191, 97, 106); -const COLOR_GREEN: Color = Color::Rgb(163, 190, 140); -// const COLOR_ORANGE: Color = Color::Rgb(208, 135, 112); -// const COLOR_BRIGHT: Color = Color::Rgb(216, 222, 233); +use app::{App, MessageFromUi, MessageToUi, StatefulList, UiFocus}; +use config::Config; +use rpc::RpcClient; -// FIXME: is lazy-static needed here?? -lazy_static! { - static ref CONFIG: Config = config::init(); +static CONFIG: OnceLock = OnceLock::new(); + +#[tokio::main] +async fn main() -> Result<(), Box> { + 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(()) } -trait ListView { - fn get_size(&self) -> usize; - fn select(&mut self, idx: Option); - fn selected(&self) -> Option; +async fn orchestrate<'a>( + config: &'static Config, + (tx, rx): (Sender, Receiver), +) -> Result<(), Box> { + let mut rpc_client = rpc::RpcClient::connect(&config.server.address).await?; - fn first(&mut self) { - if self.is_empty() { - return; - } - self.select(Some(0)); + if let Some(root_node) = rpc_client.get_library_node("node:/").await? { + tx.send(MessageToUi::ReplaceLibraryNode(root_node.clone())); } - fn last(&mut self) { - if self.is_empty() { - return; - } - self.select(Some(self.get_size() - 1)); - } + let init_data = rpc_client.init().await?; + tx.send_async(MessageToUi::Init(init_data)).await?; - fn next(&mut self) { - if self.is_empty() { - return; - } - if let Some(i) = self.selected() { - let next = if i == self.get_size() - 1 { 0 } else { i + 1 }; - self.select(Some(next)); - } else { - self.select(Some(0)); - } + loop { + poll(&mut rpc_client, &rx, &tx).await.ok(); } - - fn prev(&mut self) { - if self.is_empty() { - return; - } - if let Some(i) = self.selected() { - let prev = if i == 0 { self.get_size() - 1 } else { i - 1 }; - self.select(Some(prev)); - } else { - self.select(Some(0)); - } - } - - fn down(&mut self) { - if self.is_empty() { - return; - } - if let Some(i) = self.selected() { - let next = if i < self.get_size().saturating_sub(15) { - i + 15 - } else { - self.get_size() - 1 - }; - self.select(Some(next)); - } else { - self.select(Some(0)); - } - } - - fn up(&mut self) { - if self.is_empty() { - return; - } - if let Some(i) = self.selected() { - let prev = if i < 15 { 0 } else { i.saturating_sub(15) }; - self.select(Some(prev)); - } else { - self.select(Some(0)); - } - } - - fn is_selected(&self) -> bool { - self.selected().is_some() - } - - fn is_empty(&self) -> bool { - self.get_size() == 0 - } - - fn update_selection(&mut self) { - if self.is_empty() { - self.select(None); - return; - } - match self.selected() { - None => { - self.select(Some(0)); - } - Some(selected) => { - if selected > self.get_size().saturating_sub(1) { - self.select(Some(self.get_size() - 1)); - } - } - } - } -} - -#[derive(Clone, Copy)] -enum UiFocus { - Library, - Queue, -} - -#[derive(Clone, Copy)] -enum UiItemKind { - Node, - Track, -} - -struct UiItem { - uuid: String, - title: String, - kind: UiItemKind, - marked: bool, - is_queable: bool, -} - -struct QueueView { - current_position: usize, - list: Vec, - list_state: ListState, - tx: Sender, -} - -impl ListView for QueueView { - fn get_size(&self) -> usize { - self.list.len() - } - - fn select(&mut self, idx: Option) { - self.list_state.select(idx); - } - - fn selected(&self) -> Option { - self.list_state.selected() - } -} - -impl QueueView { - fn play_next(&self) { - self.tx.send(MessageFromUi::NextTrack); - } - fn play_prev(&self) { - self.tx.send(MessageFromUi::PrevTrack); - } - fn play_selected(&self) { - if let Some(pos) = self.selected() { - self.tx.send(MessageFromUi::SetCurrentTrack(pos)); - } - } - fn remove_track(&mut self) { - if let Some(pos) = self.selected() { - // FIXME: mark multiple tracks on queue and remove them - self.tx.send(MessageFromUi::RemoveTracks(vec![pos])); - } - } - fn update_position(&mut self, pos: usize) { - self.current_position = pos; - } - fn update_queue(&mut self, queue: Queue) { - self.current_position = queue.current_position as usize; - self.list = queue - .tracks - .iter() - .enumerate() - .map(|(i, t)| UiItem { - uuid: t.uuid.clone(), - title: format!("{} - {}", t.artist, t.title), - kind: UiItemKind::Track, - marked: false, - is_queable: false, - }) - .collect(); - - self.update_selection(); - } -} - -struct LibraryView { - title: String, - uuid: String, - list: Vec, - list_state: ListState, - parent: Option, - positions: HashMap, - tx: Sender, -} - -impl ListView for LibraryView { - fn get_size(&self) -> usize { - self.list.len() - } - - fn select(&mut self, idx: Option) { - if let Some(pos) = idx { - self.positions - .entry(self.uuid.clone()) - .and_modify(|e| *e = pos) - .or_insert(pos); - } - self.list_state.select(idx); - } - - fn selected(&self) -> Option { - self.list_state.selected() - } -} - -impl LibraryView { - fn get_selected(&self) -> Option> { - if self.list.iter().any(|i| i.marked) { - return Some( - self.list - .iter() - .filter(|i| i.marked) - .map(|i| i.uuid.to_string()) - .collect(), - ); - } - if let Some(idx) = self.list_state.selected() { - return Some(vec![self.list[idx].uuid.to_string()]); - } - None - } - fn ascend(&mut self) { - if let Some(parent) = self.parent.as_ref() { - self.tx.send(MessageFromUi::GetLibraryNode(parent.clone())); - } - } - fn dive(&mut self) { - if let Some(idx) = self.list_state.selected() { - let item = &self.list[idx]; - if let UiItemKind::Node = item.kind { - self.tx - .send(MessageFromUi::GetLibraryNode(item.uuid.clone())); - } - } - } - fn queue_append(&mut self) { - if let Some(items) = self.get_selected() { - match self.tx.send(MessageFromUi::AppendTracks(items)) { - Ok(_) => self.remove_marks(), - Err(_) => { /* FIXME: warn */ } - } - } - } - fn queue_queue(&mut self) { - if let Some(items) = self.get_selected() { - match self.tx.send(MessageFromUi::QueueTracks(items)) { - Ok(_) => self.remove_marks(), - Err(_) => { /* FIXME: warn */ } - } - } - } - fn queue_replace(&mut self) { - if let Some(items) = self.get_selected() { - match self.tx.send(MessageFromUi::ReplaceQueue(items)) { - Ok(_) => self.remove_marks(), - Err(_) => { /* FIXME: warn */ } - } - } - } - fn queue_insert(&mut self, pos: usize) { - if let Some(items) = self.get_selected() { - match self.tx.send(MessageFromUi::InsertTracks(items, pos)) { - Ok(_) => self.remove_marks(), - Err(_) => { /* FIXME: warn */ } - } - } - } - fn prev_selected(&self) -> usize { - *self.positions.get(&self.uuid).unwrap_or(&0) - } - fn toggle_mark(&mut self) { - if let Some(idx) = self.list_state.selected() { - let mut item = &mut self.list[idx]; - if !item.is_queable { - return; - } - item.marked = !item.marked; - } - } - fn remove_marks(&mut self) { - if self.list.iter().any(|i| i.marked) { - self.list - .iter_mut() - .filter(|i| i.marked) - .for_each(|i| i.marked = false); - } - } - fn update(&mut self, node: LibraryNode) { - if node.tracks.is_empty() && node.children.is_empty() { - return; - } - - // if children empty and tracks empty return - self.uuid = node.uuid; - self.title = node.title; - self.parent = node.parent; - self.select(Some(self.prev_selected())); - - if !node.tracks.is_empty() { - self.list = node - .tracks - .iter() - .map(|t| UiItem { - uuid: t.uuid.clone(), - title: format!("{} - {}", t.artist, t.title), - kind: UiItemKind::Track, - marked: false, - is_queable: true, - }) - .collect(); - } else { - // if tracks not empty use tracks instead - self.list = node - .children - .iter() - .map(|c| UiItem { - uuid: c.uuid.clone(), - title: c.title.clone(), - kind: UiItemKind::Node, - marked: false, - is_queable: c.is_queable, - }) - .collect(); - } - - self.update_selection(); - } -} - -struct NowPlayingView { - play_state: PlayState, - duration: Option, - modifiers: QueueModifiers, - position: Option, - track: Option, -} - -impl NowPlayingView { - fn update_play_state(&mut self, play_state: PlayState) { - self.play_state = play_state; - } - fn update_position(&mut self, pos: TrackPosition) { - self.position = Some(Duration::from_millis(pos.position.into())); - self.duration = Some(Duration::from_millis(pos.duration.into())); - } - fn update_track(&mut self, active: Option) { - if let Some(track) = &active { - Notification::new() - .summary("Crabidy playing") - // FIXME: album - .body(&format!("{} by {}", track.title, track.artist)) - .show() - .unwrap(); - } - self.track = active; - } - fn update_modifiers(&mut self, mods: &QueueModifiers) { - self.modifiers = mods.clone(); - } -} - -struct App { - focus: UiFocus, - library: LibraryView, - now_playing: NowPlayingView, - queue: QueueView, -} - -impl App { - fn new(tx: Sender) -> App { - let mut library = LibraryView { - title: "Library".to_string(), - uuid: "node:/".to_string(), - list: Vec::new(), - list_state: ListState::default(), - positions: HashMap::new(), - parent: None, - tx: tx.clone(), - }; - let queue = QueueView { - current_position: 0, - list: Vec::new(), - list_state: ListState::default(), - tx, - }; - let now_playing = NowPlayingView { - play_state: PlayState::Unspecified, - duration: None, - modifiers: QueueModifiers::default(), - position: None, - track: None, - }; - App { - focus: UiFocus::Library, - library, - now_playing, - queue, - } - } - - fn cycle_active(&mut self) { - self.focus = match (self.focus, self.queue.is_empty()) { - (UiFocus::Library, false) => UiFocus::Queue, - (UiFocus::Library, true) => UiFocus::Library, - (UiFocus::Queue, _) => UiFocus::Library, - }; - } -} - -// FIXME: Rename this -enum MessageToUi { - Init(InitialData), - ReplaceLibraryNode(LibraryNode), - Update(StreamUpdate), -} - -// FIXME: Rename this -enum MessageFromUi { - GetLibraryNode(String), - AppendTracks(Vec), - QueueTracks(Vec), - InsertTracks(Vec, usize), - RemoveTracks(Vec), - ReplaceQueue(Vec), - NextTrack, - PrevTrack, - RestartTrack, - SetCurrentTrack(usize), - TogglePlay, - ChangeVolume(f32), - ToggleMute, - ToggleShuffle, - ToggleRepeat, } async fn poll( @@ -572,40 +154,6 @@ async fn poll( Ok(()) } -async fn orchestrate<'a>( - config: &'static Config, - (tx, rx): (Sender, Receiver), -) -> Result<(), Box> { - let mut rpc_client = rpc::RpcClient::connect(&config.server.address).await?; - - if let Some(root_node) = rpc_client.get_library_node("node:/").await? { - tx.send(MessageToUi::ReplaceLibraryNode(root_node.clone())); - } - - let init_data = rpc_client.init().await?; - tx.send_async(MessageToUi::Init(init_data)).await?; - - loop { - poll(&mut rpc_client, &rx, &tx).await.ok(); - } -} - -#[tokio::main] -async fn main() -> Result<(), Box> { - 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(()) -} - fn run_ui(tx: Sender, rx: Receiver) { // setup terminal enable_raw_mode().unwrap(); @@ -663,13 +211,13 @@ fn run_ui(tx: Sender, rx: Receiver) { } } - terminal.draw(|f| ui(f, &mut app)).unwrap(); + terminal.draw(|f| app.render(f)); let timeout = tick_rate .checked_sub(last_tick.elapsed()) .unwrap_or_else(|| Duration::from_secs(0)); - if crossterm::event::poll(timeout).unwrap() { + if event::poll(timeout).unwrap() { if let Event::Key(key) = event::read().unwrap() { if key.kind == KeyEventKind::Press { match (app.focus, key.modifiers, key.code) { @@ -790,216 +338,3 @@ fn run_ui(tx: Sender, rx: Receiver) { .unwrap(); terminal.show_cursor().unwrap(); } - -fn ui(f: &mut Frame, app: &mut App) { - let size = f.size(); - - let library_focused = matches!(app.focus, UiFocus::Library); - let queue_focused = matches!(app.focus, UiFocus::Queue); - - let main = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) - .split(size); - - let library_items: Vec = app - .library - .list - .iter() - .map(|i| { - let text = if i.marked { - format!("* {}", i.title) - } else { - i.title.to_string() - }; - let style = if i.marked { - Style::default() - .fg(COLOR_GREEN) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - }; - return ListItem::new(Span::from(text)).style(style); - }) - .collect(); - - let library_list = List::new(library_items) - .block( - Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(if library_focused { - COLOR_PRIMARY - } else { - COLOR_PRIMARY_DARK - })) - .title(app.library.title.clone()), - ) - .highlight_style( - Style::default() - .bg(if library_focused { - COLOR_PRIMARY - } else { - COLOR_PRIMARY_DARK - }) - .add_modifier(Modifier::BOLD), - ); - - f.render_stateful_widget(library_list, main[0], &mut app.library.list_state); - - let right_side = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Percentage(70), Constraint::Max(10)].as_ref()) - .split(main[1]); - - let queue_items: Vec = app - .queue - .list - .iter() - .enumerate() - .map(|(idx, item)| { - let active = idx == app.queue.current_position; - - let title = if active { - format!("> {}", item.title) - } else { - item.title.to_string() - }; - let style = if active { - Style::default().fg(COLOR_RED).add_modifier(Modifier::BOLD) - } else { - Style::default() - }; - ListItem::new(Span::from(title)).style(style) - }) - .collect(); - - let queue_list = List::new(queue_items) - .block( - Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(if queue_focused { - COLOR_PRIMARY - } else { - COLOR_PRIMARY_DARK - })) - .title("Queue"), - ) - .highlight_style(Style::default().bg(if queue_focused { - COLOR_PRIMARY - } else { - COLOR_PRIMARY_DARK - })); - - f.render_stateful_widget(queue_list, right_side[0], &mut app.queue.list_state); - - let now_playing_layout = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Max(8), Constraint::Max(1)]) - .split(right_side[1]); - - let media_info_text = if let Some(track) = &app.now_playing.track { - let play_text = match &app.now_playing.play_state { - PlayState::Loading => "▼", - PlayState::Paused => "■", - PlayState::Playing => "♫", - _ => "", - }; - let album_text = match &track.album { - Some(album) => album.title.to_string(), - None => "No album".to_string(), - }; - let mods = format!( - "Shuffle: {}, Repeat {}", - &app.now_playing.modifiers.shuffle, &app.now_playing.modifiers.repeat - ); - vec![ - Spans::from(Span::raw(mods)), - Spans::from(Span::raw(play_text)), - Spans::from(vec![ - Span::styled( - track.title.to_string(), - Style::default().add_modifier(Modifier::BOLD), - ), - Span::raw(" by "), - Span::styled( - track.artist.to_string(), - Style::default().add_modifier(Modifier::BOLD), - ), - ]), - Spans::from(Span::raw(album_text)), - ] - } else { - vec![ - Spans::from(Span::raw("")), - Spans::from(Span::raw("")), - Spans::from(Span::raw("No track playing")), - ] - }; - - let media_info_p = Paragraph::new(media_info_text) - .block( - Block::default() - .title("Now playing") - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(COLOR_SECONDARY)), - ) - .alignment(Alignment::Center) - .wrap(Wrap { trim: true }); - - f.render_widget(media_info_p, now_playing_layout[0]); - - if let (Some(position), Some(duration), Some(track)) = ( - app.now_playing.position, - app.now_playing.duration, - &app.now_playing.track, - ) { - let pos = position.as_secs(); - let dur = duration.as_secs(); - - let completion_size = if dur < 3600 { 12 } else { 15 }; - - let elapsed_layout = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Min(10), Constraint::Max(completion_size)]) - .split(now_playing_layout[1]); - - let ratio = if duration.is_zero() { - 0.0 - } else { - position.as_secs_f64().div(duration.as_secs_f64()) - }; - - let progress = LineGauge::default() - .label("") - .block(Block::default().borders(Borders::NONE)) - .gauge_style(Style::default().fg(COLOR_SECONDARY).bg(Color::Black)) - .ratio(ratio); - f.render_widget(progress, elapsed_layout[0]); - - let pos_min = (pos / 60) % 60; - let pos_secs = pos % 60; - let dur_min = (dur / 60) % 60; - let dur_secs = dur % 60; - - let completion_text = if dur < 3600 { - format!( - "{:0>2}:{:0>2}/{:0>2}:{:0>2}", - pos_min, pos_secs, dur_min, dur_secs, - ) - } else { - let pos_hours = pos_secs / 60 / 60; - let dur_hours = dur_secs / 60 / 60; - format!( - "{:0>1}:{:0>2}:{:0>2}/{:0>1}:{:0>2}:{:0>2}", - pos_hours, pos_min, pos_secs, dur_hours, dur_min, dur_secs, - ) - }; - - let time_text = Span::raw(completion_text); - let time_p = Paragraph::new(Spans::from(time_text)); - f.render_widget(time_p, elapsed_layout[1]); - } -} diff --git a/crabidy-core/Cargo.toml b/crabidy-core/Cargo.toml index d2f7fad..cf74237 100644 --- a/crabidy-core/Cargo.toml +++ b/crabidy-core/Cargo.toml @@ -7,8 +7,12 @@ edition = "2021" [dependencies] async-trait = "0.1.68" +clap = "4.3.3" +clap-serde-derive = "0.2.0" +dirs = "5.0.1" prost = "0.11" serde = "1.0.163" +toml = "0.7.4" tonic = "0.9" [build-dependencies] diff --git a/crabidy-core/src/lib.rs b/crabidy-core/src/lib.rs index bccd439..92d6697 100644 --- a/crabidy-core/src/lib.rs +++ b/crabidy-core/src/lib.rs @@ -1,4 +1,11 @@ +use std::{ + fs::{create_dir_all, read_to_string, File}, + io::Write, + path::Path, +}; + use async_trait::async_trait; +pub use clap_serde_derive::{self, clap, serde, ClapSerde}; use proto::crabidy::{LibraryNode, LibraryNodeChild, Track}; pub mod proto; @@ -58,3 +65,32 @@ impl LibraryNodeChild { pub enum QueueError { NotQueable, } + +pub fn init_config(config_file_name: &str) -> T +where + T: Default + ClapSerde + serde::Serialize + std::fmt::Debug, +{ + if let Some(config_dir) = dirs::config_dir() { + let dir = Path::new(&config_dir).join("crabidy"); + if !dir.is_dir() { + create_dir_all(&dir); + } + let config_file_path = dir.join(config_file_name); + if !config_file_path.is_file() { + let config = T::default().merge_clap(); + let content = toml::to_string_pretty(&config).expect("Could not serialize config"); + let mut config_file = + File::create(config_file_path).expect("Could not open config file for writing"); + config_file + .write_all(content.as_bytes()) + .expect("Failed to write to file"); + return config; + } else { + let content = read_to_string(config_file_path).expect("Could not read config file"); + let parsed = toml::from_str::<::Opt>(&content).unwrap(); + let config: T = T::from(parsed).merge_clap(); + return config; + } + } + T::default().merge_clap() +}