Initial commit
commit
2a3ee1b1d6
|
|
@ -0,0 +1,2 @@
|
||||||
|
/target
|
||||||
|
/Cargo.lock
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
[package]
|
||||||
|
name = "rmp"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
argh = "0.1.12"
|
||||||
|
crossterm = "0.27.0"
|
||||||
|
ratatui = "0.23.0"
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
fn main() {
|
||||||
|
rmp::client::run().unwrap();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
use argh::FromArgs;
|
||||||
|
use std::{error::Error, time::Duration};
|
||||||
|
|
||||||
|
pub mod app;
|
||||||
|
pub mod crossterm;
|
||||||
|
pub mod ui;
|
||||||
|
|
||||||
|
/// Demo
|
||||||
|
#[derive(Debug, FromArgs)]
|
||||||
|
struct Cli {
|
||||||
|
/// time in ms between two ticks.
|
||||||
|
#[argh(option, default = "250")]
|
||||||
|
tick_rate: u64,
|
||||||
|
/// whether unicode symbols are used to improve the overall look of the app
|
||||||
|
#[argh(option, default = "true")]
|
||||||
|
enhanced_graphics: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run() -> Result<(), Box<dyn Error>> {
|
||||||
|
let cli: Cli = argh::from_env();
|
||||||
|
let tick_rate = Duration::from_millis(cli.tick_rate);
|
||||||
|
crossterm::run(tick_rate, cli.enhanced_graphics)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,251 @@
|
||||||
|
use std::{ops::Deref, time::Duration};
|
||||||
|
|
||||||
|
const FILE_PATHS: [&str; 0] = [];
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
|
||||||
|
pub struct ListState {
|
||||||
|
offset: usize,
|
||||||
|
selected: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListState {
|
||||||
|
pub fn offset(&self) -> usize {
|
||||||
|
self.offset
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn offset_mut(&mut self) -> &mut usize {
|
||||||
|
&mut self.offset
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_selected(mut self, selected: Option<usize>) -> Self {
|
||||||
|
self.selected = selected;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_offset(mut self, offset: usize) -> Self {
|
||||||
|
self.offset = offset;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected(&self) -> Option<usize> {
|
||||||
|
self.selected
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select(&mut self, index: Option<usize>) {
|
||||||
|
self.selected = index;
|
||||||
|
if index.is_none() {
|
||||||
|
self.offset = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct StatefulList<T> {
|
||||||
|
pub state: ListState,
|
||||||
|
pub items: Vec<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> StatefulList<T>
|
||||||
|
where
|
||||||
|
T: Deref + Eq,
|
||||||
|
{
|
||||||
|
pub fn with_items(items: Vec<T>) -> StatefulList<T> {
|
||||||
|
StatefulList {
|
||||||
|
state: ListState::default(),
|
||||||
|
items,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next(&mut self) {
|
||||||
|
let i = match self.state.selected() {
|
||||||
|
Some(i) => {
|
||||||
|
if i >= self.items.len() - 1 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => 0,
|
||||||
|
};
|
||||||
|
self.state.select(Some(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn previous(&mut self) {
|
||||||
|
let i = match self.state.selected() {
|
||||||
|
Some(i) => {
|
||||||
|
if i == 0 {
|
||||||
|
self.items.len() - 1
|
||||||
|
} else {
|
||||||
|
i - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => 0,
|
||||||
|
};
|
||||||
|
self.state.select(Some(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn index_of(&self, item: T) -> Option<usize> {
|
||||||
|
self.items.iter().position(|element| *element == item)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn current(&self) -> Option<&T> {
|
||||||
|
self.state
|
||||||
|
.selected
|
||||||
|
.map_or(None, |index| self.items.get(index))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn current_mut(&mut self) -> Option<&mut T> {
|
||||||
|
self.state
|
||||||
|
.selected
|
||||||
|
.map_or(None, |index| self.items.get_mut(index))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implement this
|
||||||
|
#[derive(Default, Clone)]
|
||||||
|
pub struct TrackMetadata {
|
||||||
|
pub duration: Option<Duration>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct PlayerState {
|
||||||
|
pub currently_playing: Option<String>,
|
||||||
|
pub is_playing: bool,
|
||||||
|
pub time_ratio: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlayerState {
|
||||||
|
pub fn play(&mut self, path: &str) {
|
||||||
|
self.currently_playing = Some(path.to_string());
|
||||||
|
self.is_playing = true;
|
||||||
|
self.time_ratio = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_pause(&mut self) {
|
||||||
|
self.is_playing = !self.is_playing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct App {
|
||||||
|
pub title: String,
|
||||||
|
pub playlist: StatefulList<String>,
|
||||||
|
pub player_state: PlayerState,
|
||||||
|
pub should_quit: bool,
|
||||||
|
pub enhanced_graphics: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub fn new(title: &str, enhanced_graphics: bool) -> Self {
|
||||||
|
let mut playlist = StatefulList::<String> {
|
||||||
|
state: ListState::default(),
|
||||||
|
items: FILE_PATHS.iter().map(|path| path.to_string()).collect(),
|
||||||
|
};
|
||||||
|
playlist.state.selected = if playlist.items.len() > 0 {
|
||||||
|
Some(0)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
title: title.to_string(),
|
||||||
|
playlist,
|
||||||
|
player_state: PlayerState::default(),
|
||||||
|
should_quit: false,
|
||||||
|
enhanced_graphics,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn play_next(&mut self) {
|
||||||
|
let current = if let Some(currently_playing) = self.player_state.currently_playing.clone() {
|
||||||
|
if let Some(current_index) = self
|
||||||
|
.playlist
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.position(|path| *path == currently_playing)
|
||||||
|
{
|
||||||
|
current_index
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let track = if current >= self.playlist.items.len() - 1 {
|
||||||
|
self.playlist.items[0].as_str()
|
||||||
|
} else {
|
||||||
|
self.playlist.items[current + 1].as_str()
|
||||||
|
};
|
||||||
|
self.player_state.play(track);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn play_previous(&mut self) {
|
||||||
|
let current = if let Some(currently_playing) = self.player_state.currently_playing.clone() {
|
||||||
|
if let Some(current_index) = self
|
||||||
|
.playlist
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.position(|path| *path == currently_playing)
|
||||||
|
{
|
||||||
|
current_index
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let track = if current <= 0 {
|
||||||
|
self.playlist.items[self.playlist.items.len() - 1].as_str()
|
||||||
|
} else {
|
||||||
|
self.playlist.items[current - 1].as_str()
|
||||||
|
};
|
||||||
|
self.player_state.play(track);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn replay_current(&mut self) {
|
||||||
|
if let Some(current) = self.playlist.current() {
|
||||||
|
self.player_state.play(current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_key(&mut self, key: char) {
|
||||||
|
match key {
|
||||||
|
'b' => self.play_previous(),
|
||||||
|
'n' => self.play_next(),
|
||||||
|
'q' => self.should_quit = true,
|
||||||
|
' ' => self.player_state.toggle_pause(),
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_left(&mut self) {}
|
||||||
|
|
||||||
|
pub fn on_right(&mut self) {}
|
||||||
|
|
||||||
|
pub fn on_up(&mut self) {
|
||||||
|
if self.playlist.items.len() > 0 {
|
||||||
|
self.playlist.previous()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_down(&mut self) {
|
||||||
|
if self.playlist.items.len() > 0 {
|
||||||
|
self.playlist.next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_enter(&mut self) {
|
||||||
|
if let Some(path) = self.playlist.current() {
|
||||||
|
self.player_state.play(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_tab(&mut self) {}
|
||||||
|
|
||||||
|
pub fn on_tick(&mut self, duration: Duration) {
|
||||||
|
let time_mod = if self.player_state.is_playing {
|
||||||
|
0.25
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
self.player_state.time_ratio =
|
||||||
|
(self.player_state.time_ratio + duration.as_secs_f64() * time_mod) % 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
use std::{
|
||||||
|
error::Error,
|
||||||
|
io,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crossterm::{
|
||||||
|
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
|
||||||
|
execute,
|
||||||
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
|
};
|
||||||
|
use ratatui::prelude::*;
|
||||||
|
|
||||||
|
use super::{app::App, ui};
|
||||||
|
|
||||||
|
pub fn run(tick_rate: Duration, enhanced_graphics: bool) -> Result<(), Box<dyn Error>> {
|
||||||
|
// setup terminal
|
||||||
|
enable_raw_mode()?;
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||||
|
let backend = CrosstermBackend::new(stdout);
|
||||||
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
|
// create app and run it
|
||||||
|
let app = App::new("rmp - Rust Music Player", enhanced_graphics);
|
||||||
|
let res = run_app(&mut terminal, app, tick_rate);
|
||||||
|
|
||||||
|
// restore terminal
|
||||||
|
disable_raw_mode()?;
|
||||||
|
execute!(
|
||||||
|
terminal.backend_mut(),
|
||||||
|
LeaveAlternateScreen,
|
||||||
|
DisableMouseCapture
|
||||||
|
)?;
|
||||||
|
terminal.show_cursor()?;
|
||||||
|
|
||||||
|
if let Err(err) = res {
|
||||||
|
println!("{err:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
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::draw(f, &mut app))?;
|
||||||
|
|
||||||
|
let timeout = tick_rate
|
||||||
|
.checked_sub(last_tick.elapsed())
|
||||||
|
.unwrap_or_else(|| Duration::from_secs(0));
|
||||||
|
if crossterm::event::poll(timeout)? {
|
||||||
|
if let Event::Key(key) = event::read()? {
|
||||||
|
if key.kind == KeyEventKind::Press {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char(c) => app.on_key(c),
|
||||||
|
KeyCode::Left => app.on_left(),
|
||||||
|
KeyCode::Up => app.on_up(),
|
||||||
|
KeyCode::Right => app.on_right(),
|
||||||
|
KeyCode::Down => app.on_down(),
|
||||||
|
KeyCode::Enter => app.on_enter(),
|
||||||
|
KeyCode::Tab => app.on_tab(),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if last_tick.elapsed() >= tick_rate {
|
||||||
|
app.on_tick(last_tick.elapsed());
|
||||||
|
last_tick = Instant::now();
|
||||||
|
}
|
||||||
|
if app.should_quit {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
use ratatui::{prelude::*, widgets::*};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use super::app::App;
|
||||||
|
|
||||||
|
pub fn draw<B: Backend>(f: &mut Frame<B>, app: &mut App) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.constraints([Constraint::Min(3), Constraint::Length(2)].as_ref())
|
||||||
|
.split(f.size());
|
||||||
|
draw_playlist(f, app, chunks[0]);
|
||||||
|
draw_player(f, app, chunks[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
static PRIMARY_COLOR: Color = Color::Rgb(200, 150, 70);
|
||||||
|
static SECONDARY_COLOR: Color = Color::Rgb(200, 200, 200);
|
||||||
|
|
||||||
|
fn draw_playlist<B: Backend>(f: &mut Frame<B>, app: &App, area: Rect) {
|
||||||
|
let tracks: Vec<_> = app
|
||||||
|
.playlist
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, path)| {
|
||||||
|
let selected = app
|
||||||
|
.playlist
|
||||||
|
.state
|
||||||
|
.selected()
|
||||||
|
.map_or(false, |selected| index == selected);
|
||||||
|
let playing = app
|
||||||
|
.player_state
|
||||||
|
.currently_playing
|
||||||
|
.clone()
|
||||||
|
.map_or(false, |currently_playing| currently_playing == *path);
|
||||||
|
let mut style = Style::default();
|
||||||
|
match (selected, playing) {
|
||||||
|
(true, false) => {
|
||||||
|
style.fg = Some(Color::Black);
|
||||||
|
style.bg = Some(PRIMARY_COLOR);
|
||||||
|
}
|
||||||
|
(false, true) => style.fg = Some(PRIMARY_COLOR),
|
||||||
|
(true, true) => {
|
||||||
|
style.fg = None;
|
||||||
|
style.bg = Some(PRIMARY_COLOR);
|
||||||
|
}
|
||||||
|
(_, _) => (),
|
||||||
|
}
|
||||||
|
let content = Span::from(format_track_name(path));
|
||||||
|
ListItem::new(content).set_style(style)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let playlist = List::new(tracks)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(SECONDARY_COLOR)),
|
||||||
|
)
|
||||||
|
.start_corner(Corner::TopLeft);
|
||||||
|
f.render_widget(playlist, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_player<B: Backend>(f: &mut Frame<B>, app: &App, area: Rect) {
|
||||||
|
let title_content = match app.player_state.currently_playing.as_ref() {
|
||||||
|
Some(playing) => {
|
||||||
|
let symbol = match app.player_state.is_playing {
|
||||||
|
true => Span::from("||").fg(PRIMARY_COLOR),
|
||||||
|
false => Span::from("|>").fg(Color::Black).bg(PRIMARY_COLOR),
|
||||||
|
};
|
||||||
|
vec![
|
||||||
|
Span::from("[ "),
|
||||||
|
symbol,
|
||||||
|
Span::from(" "),
|
||||||
|
Span::from(format_track_name(playing.as_str())),
|
||||||
|
Span::from(" ]"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
None => vec![Span::from(" Nothing selected ")],
|
||||||
|
};
|
||||||
|
|
||||||
|
let player = Gauge::default()
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::TOP)
|
||||||
|
.border_style(Style::default().fg(SECONDARY_COLOR))
|
||||||
|
.title(title_content)
|
||||||
|
.title_position(block::Position::Top),
|
||||||
|
)
|
||||||
|
.gauge_style(Style::default().fg(PRIMARY_COLOR))
|
||||||
|
.ratio(app.player_state.time_ratio)
|
||||||
|
.label(if app.player_state.is_playing {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
"[ PAUSED ]"
|
||||||
|
});
|
||||||
|
|
||||||
|
f.render_widget(player, area)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_track_name(path: &str) -> String {
|
||||||
|
match Path::new(path).file_stem() {
|
||||||
|
Some(file_name) => file_name.to_string_lossy().to_string(),
|
||||||
|
None => path.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
pub mod client;
|
||||||
Loading…
Reference in New Issue