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" name = "cbd-tui"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"clap",
"clap-serde-derive",
"crabidy-core", "crabidy-core",
"crossterm", "crossterm",
"dirs",
"flume", "flume",
"lazy_static",
"notify-rust", "notify-rust",
"ratatui", "ratatui",
"serde", "serde",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"toml 0.7.4",
"tonic", "tonic",
] ]
@ -638,8 +633,12 @@ name = "crabidy-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"clap",
"clap-serde-derive",
"dirs",
"prost", "prost",
"serde", "serde",
"toml 0.7.4",
"tonic", "tonic",
"tonic-build", "tonic-build",
] ]

View File

@ -14,9 +14,4 @@ tokio = { version = "1", features = ["full"] }
tokio-stream = "0.1" tokio-stream = "0.1"
tonic = "0.9" tonic = "0.9"
notify-rust = "4.8.0" notify-rust = "4.8.0"
clap = "4.3.3"
clap-serde-derive = "0.2.0"
serde = "1.0.164" 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, path::Path,
}; };
use clap_serde_derive::{ use crabidy_core::{
clap::{self, Parser}, clap::{self, Parser},
clap_serde_derive,
serde::Serialize, serde::Serialize,
ClapSerde, ClapSerde,
}; };
@ -25,30 +26,3 @@ pub struct ServerConfig {
#[clap(short, long)] #[clap(short, long)]
pub address: String, 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 config;
mod rpc; 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::{ use crabidy_core::proto::crabidy::{
crabidy_service_client::CrabidyServiceClient, crabidy_service_client::CrabidyServiceClient,
get_update_stream_response::Update as StreamUpdate, GetLibraryNodeRequest, get_update_stream_response::Update as StreamUpdate, GetLibraryNodeRequest,
InitResponse as InitialData, LibraryNode, PlayState, Queue, QueueModifiers, QueueTrack, Track, InitResponse as InitialData, LibraryNode, PlayState, Queue as QueueData, QueueModifiers,
TrackPosition, QueueTrack, Track, TrackPosition,
}; };
use crossterm::{ use crossterm::{
event::{ event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers, self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers,
ModifierKeyCode,
}, },
execute, execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
}; };
use flume::{Receiver, Sender}; use flume::{Receiver, Sender};
use lazy_static::lazy_static;
use notify_rust::Notification; use notify_rust::Notification;
use ratatui::{ use ratatui::{backend::CrosstermBackend, Terminal};
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 tokio::{fs, select, signal, task}; use tokio::{fs, select, signal, task};
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
use tonic::{transport::Channel, Request, Status, Streaming}; use tonic::{transport::Channel, Request, Status, Streaming};
const COLOR_PRIMARY: Color = Color::Rgb(129, 161, 193); use app::{App, MessageFromUi, MessageToUi, StatefulList, UiFocus};
// const COLOR_PRIMARY_DARK: Color = Color::Rgb(94, 129, 172); use config::Config;
const COLOR_PRIMARY_DARK: Color = Color::Rgb(59, 66, 82); use rpc::RpcClient;
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);
// FIXME: is lazy-static needed here?? static CONFIG: OnceLock<Config> = OnceLock::new();
lazy_static! {
static ref CONFIG: Config = config::init();
}
trait ListView { #[tokio::main]
fn get_size(&self) -> usize; async fn main() -> Result<(), Box<dyn std::error::Error>> {
fn select(&mut self, idx: Option<usize>); let config = CONFIG.get_or_init(|| crabidy_core::init_config("cbd-tui.toml"));
fn selected(&self) -> Option<usize>;
fn first(&mut self) { let (ui_tx, rx): (Sender<MessageFromUi>, Receiver<MessageFromUi>) = flume::unbounded();
if self.is_empty() { let (tx, ui_rx): (Sender<MessageToUi>, Receiver<MessageToUi>) = flume::unbounded();
return;
}
self.select(Some(0));
}
fn last(&mut self) { // FIXME: unwrap
if self.is_empty() { tokio::spawn(async move { orchestrate(config, (tx, rx)).await.unwrap() });
return;
}
self.select(Some(self.get_size() - 1));
}
fn next(&mut self) { tokio::task::spawn_blocking(|| {
if self.is_empty() { run_ui(ui_tx, ui_rx);
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));
}
}
}
}
}
#[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(); .await;
self.update_selection(); Ok(())
}
} }
struct LibraryView { async fn orchestrate<'a>(
title: String, config: &'static Config,
uuid: String, (tx, rx): (Sender<MessageToUi>, Receiver<MessageFromUi>),
list: Vec<UiItem>, ) -> Result<(), Box<dyn Error>> {
list_state: ListState, let mut rpc_client = rpc::RpcClient::connect(&config.server.address).await?;
parent: Option<String>,
positions: HashMap<String, usize>, if let Some(root_node) = rpc_client.get_library_node("node:/").await? {
tx: Sender<MessageFromUi>, tx.send(MessageToUi::ReplaceLibraryNode(root_node.clone()));
} }
impl ListView for LibraryView { let init_data = rpc_client.init().await?;
fn get_size(&self) -> usize { tx.send_async(MessageToUi::Init(init_data)).await?;
self.list.len()
}
fn select(&mut self, idx: Option<usize>) { loop {
if let Some(pos) = idx { poll(&mut rpc_client, &rx, &tx).await.ok();
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( async fn poll(
@ -572,40 +154,6 @@ async fn poll(
Ok(()) 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>) { fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>) {
// setup terminal // setup terminal
enable_raw_mode().unwrap(); 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 let timeout = tick_rate
.checked_sub(last_tick.elapsed()) .checked_sub(last_tick.elapsed())
.unwrap_or_else(|| Duration::from_secs(0)); .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 let Event::Key(key) = event::read().unwrap() {
if key.kind == KeyEventKind::Press { if key.kind == KeyEventKind::Press {
match (app.focus, key.modifiers, key.code) { match (app.focus, key.modifiers, key.code) {
@ -790,216 +338,3 @@ fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>) {
.unwrap(); .unwrap();
terminal.show_cursor().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] [dependencies]
async-trait = "0.1.68" async-trait = "0.1.68"
clap = "4.3.3"
clap-serde-derive = "0.2.0"
dirs = "5.0.1"
prost = "0.11" prost = "0.11"
serde = "1.0.163" serde = "1.0.163"
toml = "0.7.4"
tonic = "0.9" tonic = "0.9"
[build-dependencies] [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; use async_trait::async_trait;
pub use clap_serde_derive::{self, clap, serde, ClapSerde};
use proto::crabidy::{LibraryNode, LibraryNodeChild, Track}; use proto::crabidy::{LibraryNode, LibraryNodeChild, Track};
pub mod proto; pub mod proto;
@ -58,3 +65,32 @@ impl LibraryNodeChild {
pub enum QueueError { pub enum QueueError {
NotQueable, 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()
}