Split up cbd-tui into components
This commit is contained in:
parent
f1db89ca99
commit
7f48bca5df
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue