Compare commits

...

3 Commits

Author SHA1 Message Date
~erin 373de985f8
List songs & play from 2023-03-22 12:40:11 -04:00
~erin 377ee3b3cd
Clean up Cargo.toml, imports 2023-03-22 08:34:48 -04:00
~erin 98884cac19
Formatting 2023-03-22 08:21:44 -04:00
3 changed files with 196 additions and 106 deletions

View File

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

View File

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

View File

@ -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,
}