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