feat: add volume control & display (#4)

* feat: added volume control

I added a simple volume control to the program, using native
functionality in `rodio::sink`.

This logic has also been linked through to the UI so that users will be
aware of this fucntionality (bound to the '-' and '+' keys) and adding a
volume readout to the UI as well.

* feat: add volume bindings which work without shift

A small issue I noticed I had was that I had to press shift to hit '+',
I now bound the volume up fucntionality to '+' and '=' and the volume
down functionality to '-' and '_', to make both undependant of shift
(assuming most default western keyboard layouts)

* feat: support arrow keys

* feat: add temporarily appearing audio bar

* fix: polish input controls

---------

Co-authored-by: talwat <83217276+talwat@users.noreply.github.com>
This commit is contained in:
Brendan Mesters 2024-10-02 21:20:16 +02:00 committed by GitHub
parent 2a4645ca51
commit 86f3f56edb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 134 additions and 53 deletions

View File

@ -23,6 +23,7 @@ pub mod downloader;
pub mod ui; pub mod ui;
/// Handles communication between the frontend & audio player. /// Handles communication between the frontend & audio player.
#[derive(PartialEq)]
pub enum Messages { pub enum Messages {
/// Notifies the audio server that it should update the track. /// Notifies the audio server that it should update the track.
Next, Next,
@ -36,6 +37,12 @@ pub enum Messages {
/// Pauses the [Sink]. This will also unpause it if it is paused. /// Pauses the [Sink]. This will also unpause it if it is paused.
Pause, Pause,
/// Increase the volume of playback
VolumeUp,
/// Decrease the volume of playback
VolumeDown,
} }
const TIMEOUT: Duration = Duration::from_secs(8); const TIMEOUT: Duration = Duration::from_secs(8);
@ -183,6 +190,18 @@ impl Player {
player.sink.pause(); player.sink.pause();
} }
} }
Messages::VolumeUp => {
// Increase the volume, if possible.
if player.sink.volume() < 1.0 {
player.sink.set_volume(player.sink.volume() + 0.1);
}
}
Messages::VolumeDown => {
// Decreaes the volume, if possible.
if player.sink.volume() > 0.0 {
player.sink.set_volume(player.sink.volume() - 0.1);
}
}
} }
} }
} }

View File

@ -1,6 +1,13 @@
//! The module which manages all user interface, including inputs. //! The module which manages all user interface, including inputs.
use std::{io::stderr, sync::Arc, time::Duration}; use std::{
io::stderr,
sync::{
atomic::{AtomicUsize, Ordering},
Arc,
},
time::Duration,
};
use crate::tracks::TrackInfo; use crate::tracks::TrackInfo;
@ -19,10 +26,26 @@ use tokio::{
use super::Messages; use super::Messages;
/// The total width of the UI.
const WIDTH: usize = 27;
/// The width of the progress bar, not including the borders (`[` and `]`) or padding.
const PROGRESS_WIDTH: usize = WIDTH - 16;
/// The width of the audio bar, again not including borders or padding.
const AUDIO_WIDTH: usize = WIDTH - 17;
/// Self explanitory.
const FPS: usize = 12;
/// How long the audio bar will be visible for when audio is adjusted.
/// This is in frames.
const AUDIO_BAR_DURATION: usize = 9;
/// How long to wait in between frames. /// How long to wait in between frames.
/// This is fairly arbitrary, but an ideal value should be enough to feel /// This is fairly arbitrary, but an ideal value should be enough to feel
/// snappy but not require too many resources. /// snappy but not require too many resources.
const FRAME_DELTA: f32 = 5.0 / 60.0; const FRAME_DELTA: f32 = 1.0 / FPS as f32;
/// Small helper function to format durations. /// Small helper function to format durations.
fn format_duration(duration: &Duration) -> String { fn format_duration(duration: &Duration) -> String {
@ -61,22 +84,59 @@ impl ActionBar {
} }
} }
/// Creates the progress bar, as well as all the padding needed.
fn progress_bar(player: &Arc<Player>) -> String {
let mut duration = Duration::new(0, 0);
let elapsed = player.sink.get_pos();
let mut filled = 0;
if let Some(current) = player.current.load().as_ref() {
if let Some(x) = current.duration {
duration = x;
let elapsed = elapsed.as_secs() as f32 / duration.as_secs() as f32;
filled = (elapsed * PROGRESS_WIDTH as f32).round() as usize;
}
};
format!(
" [{}{}] {}/{} ",
"/".repeat(filled),
" ".repeat(PROGRESS_WIDTH.saturating_sub(filled)),
format_duration(&elapsed),
format_duration(&duration),
)
}
/// Creates the audio bar, as well as all the padding needed.
fn audio_bar(player: &Arc<Player>) -> String {
let volume = player.sink.volume();
let audio = (player.sink.volume() * AUDIO_WIDTH as f32).round() as usize;
let percentage = format!("{}%", (volume * 100.0).ceil().abs());
format!(
" volume: [{}{}] {}{} ",
"/".repeat(audio),
" ".repeat(AUDIO_WIDTH.saturating_sub(audio)),
" ".repeat(4usize.saturating_sub(percentage.len())),
percentage,
)
}
/// The code for the interface itself. /// The code for the interface itself.
async fn interface(queue: Arc<Player>) -> eyre::Result<()> { ///
/// The total width of the UI. /// `volume_timer` is a bit strange, but it tracks how long the `volume` bar
const WIDTH: usize = 27; /// has been displayed for, so that it's only displayed for a certain amount of frames.
async fn interface(player: Arc<Player>, volume_timer: Arc<AtomicUsize>) -> eyre::Result<()> {
/// The width of the progress bar, not including the borders (`[` and `]`) or padding.
const PROGRESS_WIDTH: usize = WIDTH - 16;
loop { loop {
let (mut main, len) = queue let (mut main, len) = player
.current .current
.load() .load()
.as_ref() .as_ref()
.map_or(ActionBar::Loading, |x| { .map_or(ActionBar::Loading, |x| {
let name = (*Arc::clone(x)).clone(); let name = (*Arc::clone(x)).clone();
if queue.sink.is_paused() { if player.sink.is_paused() {
ActionBar::Paused(name) ActionBar::Paused(name)
} else { } else {
ActionBar::Playing(name) ActionBar::Playing(name)
@ -90,34 +150,26 @@ async fn interface(queue: Arc<Player>) -> eyre::Result<()> {
main = format!("{}{}", main, " ".repeat(WIDTH - len)); main = format!("{}{}", main, " ".repeat(WIDTH - len));
} }
let mut duration = Duration::new(0, 0); let timer = volume_timer.load(Ordering::Relaxed);
let elapsed = queue.sink.get_pos(); let middle = match timer {
0 => progress_bar(&player),
let mut filled = 0; _ => audio_bar(&player),
if let Some(current) = queue.current.load().as_ref() {
if let Some(x) = current.duration {
duration = x;
let elapsed = elapsed.as_secs() as f32 / duration.as_secs() as f32;
filled = (elapsed * PROGRESS_WIDTH as f32).round() as usize;
}
}; };
let progress = format!( if timer > 0 && timer <= AUDIO_BAR_DURATION {
" [{}{}] {}/{} ", volume_timer.fetch_add(1, Ordering::Relaxed);
"/".repeat(filled), } else if timer > AUDIO_BAR_DURATION {
" ".repeat(PROGRESS_WIDTH.saturating_sub(filled)), volume_timer.store(0, Ordering::Relaxed);
format_duration(&elapsed), }
format_duration(&duration),
); let controls = [
let bar = [
format!("{}kip", "[s]".bold()), format!("{}kip", "[s]".bold()),
format!("{}ause", "[p]".bold()), format!("{}ause", "[p]".bold()),
format!("{}uit", "[q]".bold()), format!("{}uit", "[q]".bold()),
]; ];
// Formats the menu properly // Formats the menu properly
let menu = [main, progress, bar.join(" ")] let menu = [main, middle, controls.join(" ")]
.map(|x| format!("{}\r\n", x.reset()).to_string()); .map(|x| format!("{}\r\n", x.reset()).to_string());
crossterm::execute!(stderr(), Clear(ClearType::FromCursorDown))?; crossterm::execute!(stderr(), Clear(ClearType::FromCursorDown))?;
@ -155,37 +207,47 @@ pub async fn start(
crossterm::execute!(stderr(), EnterAlternateScreen, MoveTo(0, 0))?; crossterm::execute!(stderr(), EnterAlternateScreen, MoveTo(0, 0))?;
} }
task::spawn(interface(Arc::clone(&queue))); let volume_timer = Arc::new(AtomicUsize::new(0));
task::spawn(interface(Arc::clone(&queue), volume_timer.clone()));
loop { loop {
let event::Event::Key(event) = event::read()? else { let event::Event::Key(event) = event::read()? else {
continue; continue;
}; };
let KeyCode::Char(code) = event.code else { let messages = match event.code {
continue; // Arrow key volume controls.
KeyCode::Up | KeyCode::Right => Messages::VolumeUp,
KeyCode::Down | KeyCode::Left => Messages::VolumeDown,
KeyCode::Char(character) => match character {
// Ctrl+C
'c' if event.modifiers == KeyModifiers::CONTROL => break,
// Quit
'q' => break,
// Skip/Next
's' | 'n' if !queue.current.load().is_none() => Messages::Next,
// Pause
'p' => Messages::Pause,
// Volume up & down
'+' | '=' => Messages::VolumeUp,
'-' | '_' => Messages::VolumeDown,
_ => continue,
},
_ => continue,
}; };
match code { // If it's modifying the volume, then we'll set the `volume_timer` to 1
'c' => { // so that the ui thread will know that it should show the audio bar.
// Handles Ctrl+C. if messages == Messages::VolumeDown || messages == Messages::VolumeUp {
if event.modifiers == KeyModifiers::CONTROL { volume_timer.store(1, Ordering::Relaxed);
break;
}
}
'q' => {
break;
}
's' => {
if !queue.current.load().is_none() {
sender.send(Messages::Next).await?
}
}
'p' => {
sender.send(Messages::Pause).await?;
}
_ => {}
} }
sender.send(messages).await?;
} }
if alternate { if alternate {