Split up cbd-tui into components

This commit is contained in:
chmanie 2023-06-11 20:05:58 +02:00
parent f1db89ca99
commit 7f48bca5df
11 changed files with 858 additions and 755 deletions

9
Cargo.lock generated
View File

@ -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",
]

View File

@ -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"

230
cbd-tui/src/app/library.rs Normal file
View File

@ -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<UiItem>,
list_state: ListState,
parent: Option<String>,
positions: HashMap<String, usize>,
tx: Sender<MessageFromUi>,
}
impl Library {
pub fn new(tx: Sender<MessageFromUi>) -> 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<Vec<String>> {
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<B: Backend>(&mut self, f: &mut Frame<B>, area: Rect, focused: bool) {
let library_items: Vec<ListItem> = 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<usize>) {
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<usize> {
self.list_state.selected()
}
}

98
cbd-tui/src/app/list.rs Normal file
View File

@ -0,0 +1,98 @@
pub use ratatui::widgets::ListState;
pub trait StatefulList {
fn get_size(&self) -> usize;
fn select(&mut self, idx: Option<usize>);
fn selected(&self) -> Option<usize>;
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));
}
}
}
}
}

132
cbd-tui/src/app/mod.rs Normal file
View File

@ -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<String>),
QueueTracks(Vec<String>),
InsertTracks(Vec<String>, usize),
RemoveTracks(Vec<usize>),
ReplaceQueue(Vec<String>),
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<MessageFromUi>) -> 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<B: Backend>(&mut self, f: &mut Frame<B>) {
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]);
}
}

View File

@ -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<Duration>,
modifiers: QueueModifiers,
position: Option<Duration>,
track: Option<Track>,
}
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<Track>) {
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<B: Backend>(&self, f: &mut Frame<B>, 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]);
}
}
}

129
cbd-tui/src/app/queue.rs Normal file
View File

@ -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<UiItem>,
list_state: ListState,
tx: Sender<MessageFromUi>,
}
impl Queue {
pub fn new(tx: Sender<MessageFromUi>) -> 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<B: Backend>(&mut self, f: &mut Frame<B>, area: Rect, focused: bool) {
let queue_items: Vec<ListItem> = 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<usize>) {
self.list_state.select(idx);
}
fn selected(&self) -> Option<usize> {
self.list_state.selected()
}
}

View File

@ -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::<<Config as ClapSerde>::Opt>(&content).unwrap();
let config: Config = Config::from(parsed).merge_clap();
return config;
}
}
Config::default().merge_clap()
}

View File

@ -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<Config> = OnceLock::new();
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = CONFIG.get_or_init(|| crabidy_core::init_config("cbd-tui.toml"));
let (ui_tx, rx): (Sender<MessageFromUi>, Receiver<MessageFromUi>) = flume::unbounded();
let (tx, ui_rx): (Sender<MessageToUi>, Receiver<MessageToUi>) = 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<usize>);
fn selected(&self) -> Option<usize>;
async fn orchestrate<'a>(
config: &'static Config,
(tx, rx): (Sender<MessageToUi>, Receiver<MessageFromUi>),
) -> Result<(), Box<dyn Error>> {
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<UiItem>,
list_state: ListState,
tx: Sender<MessageFromUi>,
}
impl ListView for QueueView {
fn get_size(&self) -> usize {
self.list.len()
}
fn select(&mut self, idx: Option<usize>) {
self.list_state.select(idx);
}
fn selected(&self) -> Option<usize> {
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<UiItem>,
list_state: ListState,
parent: Option<String>,
positions: HashMap<String, usize>,
tx: Sender<MessageFromUi>,
}
impl ListView for LibraryView {
fn get_size(&self) -> usize {
self.list.len()
}
fn select(&mut self, idx: Option<usize>) {
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<usize> {
self.list_state.selected()
}
}
impl LibraryView {
fn get_selected(&self) -> Option<Vec<String>> {
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<Duration>,
modifiers: QueueModifiers,
position: Option<Duration>,
track: Option<Track>,
}
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<Track>) {
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<MessageFromUi>) -> 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<String>),
QueueTracks(Vec<String>),
InsertTracks(Vec<String>, usize),
RemoveTracks(Vec<usize>),
ReplaceQueue(Vec<String>),
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<MessageToUi>, Receiver<MessageFromUi>),
) -> Result<(), Box<dyn Error>> {
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<dyn std::error::Error>> {
let (ui_tx, rx): (Sender<MessageFromUi>, Receiver<MessageFromUi>) = flume::unbounded();
let (tx, ui_rx): (Sender<MessageToUi>, Receiver<MessageToUi>) = 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<MessageFromUi>, rx: Receiver<MessageToUi>) {
// setup terminal
enable_raw_mode().unwrap();
@ -663,13 +211,13 @@ fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>) {
}
}
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<MessageFromUi>, rx: Receiver<MessageToUi>) {
.unwrap();
terminal.show_cursor().unwrap();
}
fn ui<B: Backend>(f: &mut Frame<B>, 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<ListItem> = 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<ListItem> = 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]);
}
}

View File

@ -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]

View File

@ -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<T>(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::<<T as ClapSerde>::Opt>(&content).unwrap();
let config: T = T::from(parsed).merge_clap();
return config;
}
}
T::default().merge_clap()
}