Compare commits
3 Commits
bfbc791a7b
...
373de985f8
Author | SHA1 | Date |
---|---|---|
~erin | 373de985f8 | |
~erin | 377ee3b3cd | |
~erin | 98884cac19 |
|
@ -1,9 +1,9 @@
|
|||
[package]
|
||||
name = "xenmotif"
|
||||
description = "A TUI client for mpd"
|
||||
authors = ["Erin <contact@the-system.eu.org>"]
|
||||
homepage = "https://xenmotif.the-system.eu.org"
|
||||
repository = "https://git.lavender.software/erin/xenmotif"
|
||||
license = "CNPLv7+"
|
||||
license-file = "LICENSE.md"
|
||||
keywords = ["audio", "client", "tui"]
|
||||
categories = ["command-line-utilities"]
|
||||
|
@ -17,8 +17,6 @@ notifications = []
|
|||
dbus = []
|
||||
images = []
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
tui = "0.19"
|
||||
crossterm = "0.25"
|
||||
|
|
256
src/main.rs
256
src/main.rs
|
@ -5,29 +5,27 @@ use crossterm::{
|
|||
};
|
||||
use std::{
|
||||
error::Error,
|
||||
io,
|
||||
env,
|
||||
fs,
|
||||
fs, io,
|
||||
path::PathBuf,
|
||||
time::{Duration, Instant},
|
||||
process::exit,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tui::{
|
||||
backend::{Backend, CrosstermBackend},
|
||||
layout::{Alignment, Constraint, Direction, Layout},
|
||||
style::{Color, Modifier, Style},
|
||||
style::{Color, Style},
|
||||
text::{Span, Spans},
|
||||
widgets::{Block, Paragraph, Wrap, BorderType, Borders, Gauge, List, ListItem, ListState},
|
||||
widgets::{Block, BorderType, Borders, Gauge, List, ListItem, Paragraph, Wrap},
|
||||
Frame, Terminal,
|
||||
};
|
||||
use mpd::{search::{Query, Term}, Song};
|
||||
|
||||
use paris::{info, error};
|
||||
use clap::{Parser, Subcommand, ValueEnum};
|
||||
use toml::Table;
|
||||
use serde::{Serialize, Deserialize};
|
||||
use clap::{Parser, Subcommand};
|
||||
use paris::{error, info};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
mod structs;
|
||||
use structs::{App, StatefulList, InputMode};
|
||||
use structs::{App, InputMode, StatefulList};
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct Config {
|
||||
|
@ -64,12 +62,10 @@ enum Commands {
|
|||
Pause,
|
||||
}
|
||||
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
// Parse arguments
|
||||
let args = Args::parse();
|
||||
|
||||
|
||||
let mut default_config = dirs::config_dir().unwrap();
|
||||
default_config.push("xenmotif");
|
||||
default_config.push("config.toml");
|
||||
|
@ -95,7 +91,7 @@ fn main() -> Result<(), Box<dyn Error>> {
|
|||
|
||||
info!("Created new config file at {}", &default_config.display());
|
||||
exit(0);
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
let config: Config = match toml::from_str(&config_contents) {
|
||||
|
@ -103,7 +99,7 @@ fn main() -> Result<(), Box<dyn Error>> {
|
|||
Err(e) => {
|
||||
error!("Couldn't parse config file! {}", e);
|
||||
exit(1);
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
let port = match args.port {
|
||||
|
@ -126,16 +122,16 @@ fn main() -> Result<(), Box<dyn Error>> {
|
|||
Some(Commands::Stop) => {
|
||||
app.mpd_client.stop().unwrap();
|
||||
exit(0);
|
||||
},
|
||||
}
|
||||
Some(Commands::Play) => {
|
||||
app.mpd_client.play().unwrap();
|
||||
exit(0);
|
||||
},
|
||||
}
|
||||
Some(Commands::Pause) => {
|
||||
app.mpd_client.pause(true).unwrap();
|
||||
exit(0);
|
||||
}
|
||||
None => {},
|
||||
None => {}
|
||||
}
|
||||
|
||||
// setup terminal
|
||||
|
@ -146,7 +142,7 @@ fn main() -> Result<(), Box<dyn Error>> {
|
|||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
// create app and run it
|
||||
let tick_rate = Duration::from_millis(50);
|
||||
let tick_rate = Duration::from_millis(5);
|
||||
|
||||
let res = run_app(&mut terminal, app, tick_rate);
|
||||
|
||||
|
@ -166,41 +162,121 @@ fn main() -> Result<(), Box<dyn Error>> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut app: App, tick_rate: Duration) -> io::Result<()> {
|
||||
fn run_app<B: Backend>(
|
||||
terminal: &mut Terminal<B>,
|
||||
mut app: App,
|
||||
tick_rate: Duration,
|
||||
) -> io::Result<()> {
|
||||
let mut last_tick = Instant::now();
|
||||
loop {
|
||||
terminal.draw(|f| ui(f, &mut app))?;
|
||||
|
||||
|
||||
let timeout = tick_rate
|
||||
.checked_sub(last_tick.elapsed())
|
||||
.unwrap_or_else(|| Duration::from_secs(0));
|
||||
.unwrap_or_else(|| Duration::from_millis(10));
|
||||
if crossterm::event::poll(timeout)? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
match app.input_mode {
|
||||
InputMode::Normal => match key.code {
|
||||
KeyCode::Char('q') => return Ok(()),
|
||||
KeyCode::Char('h') => app.items.unselect(),
|
||||
KeyCode::Char('j') => app.items.next(),
|
||||
KeyCode::Char('k') => app.items.previous(),
|
||||
KeyCode::Char('p') => app.mpd_client.toggle_pause().unwrap(),
|
||||
KeyCode::Char('s') => app.mpd_client.stop().unwrap(),
|
||||
KeyCode::Char('i') => app.input_mode = InputMode::Editing,
|
||||
_ => {}
|
||||
InputMode::Normal => {
|
||||
match key.code {
|
||||
KeyCode::Char('q') => return Ok(()),
|
||||
KeyCode::Char('h') => app.songs.unselect(),
|
||||
KeyCode::Char('j') => app.songs.next(),
|
||||
KeyCode::Char('k') => app.songs.previous(),
|
||||
KeyCode::Char('l') => {
|
||||
match app.songs.play() {
|
||||
Some(s) => {
|
||||
match app.mpd_client.clear() {
|
||||
Ok(..) => {},
|
||||
Err(e) => error!("Could not clear queue: {}", e),
|
||||
}
|
||||
match app.mpd_client.push(s) {
|
||||
Ok(..) => {},
|
||||
Err(e) => error!("Cannot play song! {}", e),
|
||||
}
|
||||
app.mpd_client.play().unwrap();
|
||||
},
|
||||
None => {},
|
||||
}
|
||||
},
|
||||
KeyCode::Char('p') => app.mpd_client.toggle_pause().unwrap(),
|
||||
KeyCode::Char('s') => app.mpd_client.stop().unwrap(),
|
||||
KeyCode::Char('i') => app.input_mode = InputMode::Editing,
|
||||
_ => {}
|
||||
}
|
||||
},
|
||||
InputMode::Editing => match key.code {
|
||||
KeyCode::Enter => {
|
||||
app.messages.push(app.input.drain(..).collect());
|
||||
},
|
||||
KeyCode::Char(c) => {
|
||||
app.input.push(c);
|
||||
},
|
||||
KeyCode::Backspace => {
|
||||
app.input.pop();
|
||||
},
|
||||
KeyCode::Esc => {
|
||||
app.input_mode = InputMode::Normal;
|
||||
},
|
||||
_ => {}
|
||||
InputMode::Editing => {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
app.messages.push(app.input.drain(..).collect());
|
||||
let last_results = match app.messages.last() {
|
||||
Some(r) => r,
|
||||
None => "",
|
||||
};
|
||||
app.last_songs = match app.mpd_client.search(&Query::new().and(Term::File, last_results), (0,50)) {
|
||||
Ok(s) => {
|
||||
let mut results: Vec<(String, usize, Option<Song>)> = vec![];
|
||||
let mut x = 0;
|
||||
for i in s {
|
||||
let name = match i.title.clone() {
|
||||
Some(n) => n,
|
||||
None => "<unnamed>".to_string(),
|
||||
};
|
||||
results.push((name, x, Some(i.clone())));
|
||||
x+=1;
|
||||
}
|
||||
results
|
||||
},
|
||||
Err(e) => vec![("<no results>".to_string(), 0, None)],
|
||||
};
|
||||
app.songs = StatefulList::with_items(app.last_songs.clone());
|
||||
app.input_mode = InputMode::Normal;
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
app.input.push(c);
|
||||
let results = app.input.clone();
|
||||
app.last_songs = match app.mpd_client.search(&Query::new().and(Term::File, results), (0,50)) {
|
||||
Ok(s) => {
|
||||
let mut results: Vec<(String, usize, Option<Song>)> = vec![];
|
||||
let mut x = 0;
|
||||
for i in s {
|
||||
let name = match i.title.clone() {
|
||||
Some(n) => n,
|
||||
None => "<unnamed>".to_string(),
|
||||
};
|
||||
results.push((name, x, Some(i.clone())));
|
||||
x+=1;
|
||||
}
|
||||
results
|
||||
},
|
||||
Err(e) => vec![("<no results>".to_string(), 0, None)],
|
||||
};
|
||||
app.songs = StatefulList::with_items(app.last_songs.clone());
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
app.input.pop();
|
||||
let results = app.input.clone();
|
||||
app.last_songs = match app.mpd_client.search(&Query::new().and(Term::File, results), (0,50)) {
|
||||
Ok(s) => {
|
||||
let mut results: Vec<(String, usize, Option<Song>)> = vec![];
|
||||
let mut x = 0;
|
||||
for i in s {
|
||||
let name = match i.title.clone() {
|
||||
Some(n) => n,
|
||||
None => "<unnamed>".to_string(),
|
||||
};
|
||||
results.push((name, x, Some(i.clone())));
|
||||
x+=1;
|
||||
}
|
||||
results
|
||||
},
|
||||
Err(e) => vec![("<no results>".to_string(), 0, None)],
|
||||
};
|
||||
app.songs = StatefulList::with_items(app.last_songs.clone());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -308,7 +384,7 @@ fn ui<B: Backend>(f: &mut Frame<B>, app: &mut App) {
|
|||
let duration = match song.duration {
|
||||
Some(s) => {
|
||||
let minutes = s.num_minutes();
|
||||
let seconds = s.num_seconds() - minutes*60;
|
||||
let seconds = s.num_seconds() - minutes * 60;
|
||||
|
||||
let s_seconds;
|
||||
if seconds < 10 {
|
||||
|
@ -317,13 +393,13 @@ fn ui<B: Backend>(f: &mut Frame<B>, app: &mut App) {
|
|||
s_seconds = format!("{}", seconds);
|
||||
}
|
||||
format!("{}:{}", minutes, s_seconds)
|
||||
},
|
||||
}
|
||||
None => "none".to_string(),
|
||||
};
|
||||
let elapsed = match mpd_status.time {
|
||||
Some(t) => {
|
||||
let minutes = t.0.num_minutes();
|
||||
let seconds = t.0.num_seconds() - minutes*60;
|
||||
let seconds = t.0.num_seconds() - minutes * 60;
|
||||
|
||||
let s_seconds;
|
||||
if seconds < 10 {
|
||||
|
@ -332,27 +408,34 @@ fn ui<B: Backend>(f: &mut Frame<B>, app: &mut App) {
|
|||
s_seconds = format!("{}", seconds);
|
||||
}
|
||||
format!("{}:{}", minutes, s_seconds)
|
||||
},
|
||||
}
|
||||
None => "none".to_string(),
|
||||
};
|
||||
info.push(Spans::from(Span::styled(format!("({}/{})", elapsed, duration), Style::default())));
|
||||
info.push(Spans::from(Span::styled(
|
||||
format!("({}/{})", elapsed, duration),
|
||||
Style::default(),
|
||||
)));
|
||||
info
|
||||
},
|
||||
None => vec![Spans::from(Span::styled("Not Playing", Style::default().fg(Color::Red)))],
|
||||
}
|
||||
None => vec![Spans::from(Span::styled(
|
||||
"Not Playing",
|
||||
Style::default().fg(Color::Red),
|
||||
))],
|
||||
};
|
||||
|
||||
let song = Paragraph::new(song_text).block(
|
||||
Block::default()
|
||||
.title(vec![
|
||||
Span::styled("──┤ ", Style::default().fg(Color::White)),
|
||||
Span::raw(title),
|
||||
Span::styled(" ├──", Style::default().fg(Color::White)),
|
||||
])
|
||||
.title_alignment(Alignment::Center)
|
||||
)
|
||||
let song = Paragraph::new(song_text)
|
||||
.block(
|
||||
Block::default()
|
||||
.title(vec![
|
||||
Span::styled("──┤ ", Style::default().fg(Color::White)),
|
||||
Span::raw(title),
|
||||
Span::styled(" ├──", Style::default().fg(Color::White)),
|
||||
])
|
||||
.title_alignment(Alignment::Center),
|
||||
)
|
||||
.style(Style::default())
|
||||
.alignment(Alignment::Center)
|
||||
.wrap(Wrap { trim: true } );
|
||||
.wrap(Wrap { trim: true });
|
||||
f.render_widget(song, song_search[0]);
|
||||
|
||||
// Search
|
||||
|
@ -361,17 +444,14 @@ fn ui<B: Backend>(f: &mut Frame<B>, app: &mut App) {
|
|||
InputMode::Normal => Style::default(),
|
||||
InputMode::Editing => Style::default().fg(Color::Yellow),
|
||||
})
|
||||
.block(Block::default().borders(Borders::ALL).title("Search"));
|
||||
.block(Block::default().borders(Borders::ALL).title("Search"));
|
||||
f.render_widget(input, song_search[1]);
|
||||
match app.input_mode {
|
||||
InputMode::Normal =>
|
||||
{}
|
||||
InputMode::Editing => {
|
||||
f.set_cursor(
|
||||
song_search[1].x + app.input.len() as u16 + 1,
|
||||
song_search[1].y + 1,
|
||||
)
|
||||
}
|
||||
InputMode::Normal => {}
|
||||
InputMode::Editing => f.set_cursor(
|
||||
song_search[1].x + app.input.len() as u16 + 1,
|
||||
song_search[1].y + 1,
|
||||
),
|
||||
}
|
||||
|
||||
// Sort & View
|
||||
|
@ -389,29 +469,33 @@ fn ui<B: Backend>(f: &mut Frame<B>, app: &mut App) {
|
|||
.split(chunks[1]);
|
||||
|
||||
// Artist
|
||||
let artists: Vec<ListItem> = app
|
||||
.items
|
||||
.items
|
||||
.iter()
|
||||
.map(|i| {
|
||||
let mut lines = vec![Spans::from(i.0)];
|
||||
ListItem::new(lines)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let artists = vec![ListItem::new("a".to_string())];
|
||||
let artist = List::new(artists)
|
||||
.block(Block::default().title("Artist").borders(Borders::ALL))
|
||||
.style(Style::default().fg(Color::White))
|
||||
.highlight_style(Style::default().fg(Color::Yellow))
|
||||
.highlight_symbol(">");
|
||||
f.render_stateful_widget(artist, bottom_chunks[0], &mut app.items.state);
|
||||
f.render_widget(artist, bottom_chunks[0]);
|
||||
|
||||
// Album
|
||||
let albums = Block::default().title("Album").borders(Borders::ALL);
|
||||
f.render_widget(albums, bottom_chunks[1]);
|
||||
// Song
|
||||
let songs = Block::default().title("Song").borders(Borders::ALL);
|
||||
f.render_widget(songs, bottom_chunks[2]);
|
||||
let songs: Vec<ListItem> = app
|
||||
.songs
|
||||
.items
|
||||
.iter()
|
||||
.map(|i| {
|
||||
let lines = vec![Spans::from(i.0.clone())];
|
||||
ListItem::new(lines)
|
||||
})
|
||||
.collect();
|
||||
let song = List::new(songs)
|
||||
.block(Block::default().title("Songs").borders(Borders::ALL))
|
||||
.style(Style::default().fg(Color::White))
|
||||
.highlight_style(Style::default().fg(Color::Yellow))
|
||||
.highlight_symbol(">");
|
||||
f.render_stateful_widget(song, bottom_chunks[2], &mut app.songs.state);
|
||||
|
||||
// Timeline
|
||||
let percent = match mpd_status.time {
|
||||
|
@ -420,10 +504,8 @@ fn ui<B: Backend>(f: &mut Frame<B>, app: &mut App) {
|
|||
let total = t.1.num_seconds() as f64;
|
||||
let result = played / total;
|
||||
result * 100.0
|
||||
},
|
||||
None => {
|
||||
0.0
|
||||
},
|
||||
}
|
||||
None => 0.0,
|
||||
};
|
||||
let timeline = Gauge::default()
|
||||
.block(Block::default().borders(Borders::NONE))
|
||||
|
|
|
@ -1,17 +1,17 @@
|
|||
use mpd::Client;
|
||||
use tui::{
|
||||
widgets::{ListState},
|
||||
};
|
||||
use tui::widgets::ListState;
|
||||
|
||||
use paris::error;
|
||||
use std::process::exit;
|
||||
pub struct StatefulList<T> {
|
||||
pub struct StatefulList {
|
||||
pub state: ListState,
|
||||
pub items: Vec<T>,
|
||||
pub items: Vec<(String, usize, Option<Song>)>,
|
||||
}
|
||||
|
||||
impl<T> StatefulList<T> {
|
||||
pub fn with_items(items: Vec<T>) -> StatefulList<T> {
|
||||
use mpd::song::Song;
|
||||
|
||||
impl StatefulList {
|
||||
pub fn with_items(items: Vec<(String, usize, Option<Song>)>) -> StatefulList {
|
||||
StatefulList {
|
||||
state: ListState::default(),
|
||||
items,
|
||||
|
@ -46,6 +46,17 @@ impl<T> StatefulList<T> {
|
|||
self.state.select(Some(i));
|
||||
}
|
||||
|
||||
pub fn play(&mut self) -> Option<Song> {
|
||||
let selected = match self.state.selected() {
|
||||
Some(i) => i,
|
||||
None => 0,
|
||||
};
|
||||
|
||||
let songs = self.items[selected].2.clone();
|
||||
return songs;
|
||||
}
|
||||
|
||||
|
||||
pub fn unselect(&mut self) {
|
||||
self.state.select(None);
|
||||
}
|
||||
|
@ -57,20 +68,19 @@ impl<T> StatefulList<T> {
|
|||
///
|
||||
/// Check the event handling at the bottom to see how to change the state on incoming events.
|
||||
/// Check the drawing logic for items on how to specify the highlighting style for selected items.
|
||||
pub struct App<'a> {
|
||||
pub items: StatefulList<(&'a str, usize)>,
|
||||
pub struct App {
|
||||
pub songs: StatefulList,
|
||||
pub last_songs: Vec<(String, usize, Option<Song>)>,
|
||||
pub mpd_client: Client,
|
||||
pub input: String,
|
||||
pub input_mode: InputMode,
|
||||
pub messages: Vec<String>,
|
||||
}
|
||||
|
||||
impl<'a> App<'a> {
|
||||
pub fn new(port: u16, host: String) -> App<'a> {
|
||||
impl App {
|
||||
pub fn new(port: u16, host: String) -> App {
|
||||
let client = match Client::connect(format!("{}:{}", host, port)) {
|
||||
Ok(conn) => {
|
||||
conn
|
||||
},
|
||||
Ok(conn) => conn,
|
||||
Err(e) => {
|
||||
error!("Could not connect to MPD daemon: {}", e);
|
||||
exit(1);
|
||||
|
@ -78,7 +88,8 @@ impl<'a> App<'a> {
|
|||
};
|
||||
|
||||
App {
|
||||
items: StatefulList::with_items(vec![("Item0", 1), ("Item1", 2), ("Item2", 3)]),
|
||||
songs: StatefulList::with_items(vec![]),
|
||||
last_songs: vec![],
|
||||
mpd_client: client,
|
||||
input: String::new(),
|
||||
input_mode: InputMode::Normal,
|
||||
|
@ -91,4 +102,3 @@ pub enum InputMode {
|
|||
Normal,
|
||||
Editing,
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue