Split up cbd-tui into components
This commit is contained in:
parent
f1db89ca99
commit
7f48bca5df
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
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()
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
#[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 {
|
async fn orchestrate<'a>(
|
||||||
fn get_size(&self) -> usize;
|
config: &'static Config,
|
||||||
fn select(&mut self, idx: Option<usize>);
|
(tx, rx): (Sender<MessageToUi>, Receiver<MessageFromUi>),
|
||||||
fn selected(&self) -> Option<usize>;
|
) -> Result<(), Box<dyn Error>> {
|
||||||
|
let mut rpc_client = rpc::RpcClient::connect(&config.server.address).await?;
|
||||||
|
|
||||||
fn first(&mut self) {
|
if let Some(root_node) = rpc_client.get_library_node("node:/").await? {
|
||||||
if self.is_empty() {
|
tx.send(MessageToUi::ReplaceLibraryNode(root_node.clone()));
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.select(Some(0));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn last(&mut self) {
|
let init_data = rpc_client.init().await?;
|
||||||
if self.is_empty() {
|
tx.send_async(MessageToUi::Init(init_data)).await?;
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.select(Some(self.get_size() - 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn next(&mut self) {
|
loop {
|
||||||
if self.is_empty() {
|
poll(&mut rpc_client, &rx, &tx).await.ok();
|
||||||
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();
|
|
||||||
|
|
||||||
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(
|
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]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue