Initial commit

master
hheik 2023-09-17 04:09:35 +03:00
commit 2a3ee1b1d6
8 changed files with 475 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/target
/Cargo.lock

11
Cargo.toml Normal file
View File

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

3
src/bin/client.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
rmp::client::run().unwrap();
}

24
src/client.rs Normal file
View File

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

251
src/client/app.rs Normal file
View File

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

80
src/client/crossterm.rs Normal file
View File

@ -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(());
}
}
}

103
src/client/ui.rs Normal file
View File

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

1
src/lib.rs Normal file
View File

@ -0,0 +1 @@
pub mod client;