mirror of
https://github.com/talwat/lowfi
synced 2025-01-14 12:21:28 +00:00
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:
parent
2a4645ca51
commit
86f3f56edb
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
168
src/player/ui.rs
168
src/player/ui.rs
@ -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 {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user