commit 2a3ee1b1d6ae2ca2ee6ec70a10e3fff6d3f215d0 Author: hheik <4469778+hheik@users.noreply.github.com> Date: Sun Sep 17 04:09:35 2023 +0300 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4fffb2f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +/Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..8e81bc7 --- /dev/null +++ b/Cargo.toml @@ -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" diff --git a/src/bin/client.rs b/src/bin/client.rs new file mode 100644 index 0000000..676042f --- /dev/null +++ b/src/bin/client.rs @@ -0,0 +1,3 @@ +fn main() { + rmp::client::run().unwrap(); +} diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..a8cdb8a --- /dev/null +++ b/src/client.rs @@ -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> { + let cli: Cli = argh::from_env(); + let tick_rate = Duration::from_millis(cli.tick_rate); + crossterm::run(tick_rate, cli.enhanced_graphics)?; + Ok(()) +} diff --git a/src/client/app.rs b/src/client/app.rs new file mode 100644 index 0000000..b9cd085 --- /dev/null +++ b/src/client/app.rs @@ -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, +} + +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) -> Self { + self.selected = selected; + self + } + + pub fn with_offset(mut self, offset: usize) -> Self { + self.offset = offset; + self + } + + pub fn selected(&self) -> Option { + self.selected + } + + pub fn select(&mut self, index: Option) { + self.selected = index; + if index.is_none() { + self.offset = 0; + } + } +} + +pub struct StatefulList { + pub state: ListState, + pub items: Vec, +} + +impl StatefulList +where + T: Deref + Eq, +{ + pub fn with_items(items: Vec) -> StatefulList { + 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 { + 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, +} + +#[derive(Default)] +pub struct PlayerState { + pub currently_playing: Option, + 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, + 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:: { + 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 + } +} diff --git a/src/client/crossterm.rs b/src/client/crossterm.rs new file mode 100644 index 0000000..5d8a92b --- /dev/null +++ b/src/client/crossterm.rs @@ -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> { + // 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( + terminal: &mut Terminal, + 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(()); + } + } +} diff --git a/src/client/ui.rs b/src/client/ui.rs new file mode 100644 index 0000000..45ad793 --- /dev/null +++ b/src/client/ui.rs @@ -0,0 +1,103 @@ +use ratatui::{prelude::*, widgets::*}; +use std::path::Path; + +use super::app::App; + +pub fn draw(f: &mut Frame, 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(f: &mut Frame, 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(f: &mut Frame, 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(), + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..b9babe5 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1 @@ +pub mod client;