feat(clock): sound notification (experimental) (#62)
This commit is contained in:
21
src/app.rs
21
src/app.rs
@@ -2,8 +2,8 @@ use crate::{
|
||||
args::Args,
|
||||
common::{AppEditMode, AppTime, AppTimeFormat, Content, Notification, Style},
|
||||
constants::TICK_VALUE_MS,
|
||||
events,
|
||||
events::TuiEventHandler,
|
||||
events::{self, TuiEventHandler},
|
||||
sound::Sound,
|
||||
storage::AppStorage,
|
||||
terminal::Terminal,
|
||||
widgets::{
|
||||
@@ -22,6 +22,7 @@ use ratatui::{
|
||||
layout::{Constraint, Layout, Rect},
|
||||
widgets::{StatefulWidget, Widget},
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use time::OffsetDateTime;
|
||||
use tracing::{debug, error};
|
||||
@@ -36,6 +37,7 @@ pub struct App {
|
||||
content: Content,
|
||||
mode: Mode,
|
||||
notification: Notification,
|
||||
sound_path: Option<PathBuf>,
|
||||
app_time: AppTime,
|
||||
countdown: CountdownState,
|
||||
timer: TimerState,
|
||||
@@ -49,6 +51,7 @@ pub struct AppArgs {
|
||||
pub style: Style,
|
||||
pub with_decis: bool,
|
||||
pub notification: Notification,
|
||||
pub sound_path: Option<PathBuf>,
|
||||
pub show_menu: bool,
|
||||
pub app_time_format: AppTimeFormat,
|
||||
pub content: Content,
|
||||
@@ -75,10 +78,12 @@ pub struct FromAppArgs {
|
||||
impl From<FromAppArgs> for App {
|
||||
fn from(args: FromAppArgs) -> Self {
|
||||
let FromAppArgs { args, stg, app_tx } = args;
|
||||
|
||||
App::new(AppArgs {
|
||||
with_decis: args.decis || stg.with_decis,
|
||||
show_menu: args.menu || stg.show_menu,
|
||||
notification: args.notification.unwrap_or(stg.notification),
|
||||
sound_path: args.sound,
|
||||
app_time_format: stg.app_time_format,
|
||||
content: args.mode.unwrap_or(stg.content),
|
||||
style: args.style.unwrap_or(stg.style),
|
||||
@@ -124,6 +129,7 @@ impl App {
|
||||
with_decis,
|
||||
pomodoro_mode,
|
||||
notification,
|
||||
sound_path,
|
||||
app_tx,
|
||||
} = args;
|
||||
let app_time = get_app_time();
|
||||
@@ -131,6 +137,7 @@ impl App {
|
||||
Self {
|
||||
mode: Mode::Running,
|
||||
notification,
|
||||
sound_path,
|
||||
content,
|
||||
app_time,
|
||||
style,
|
||||
@@ -236,10 +243,18 @@ impl App {
|
||||
};
|
||||
|
||||
// Closure to handle `AppEvent`'s
|
||||
let handle_app_events = |_: &mut Self, event: events::AppEvent| -> Result<()> {
|
||||
let handle_app_events = |app: &mut Self, event: events::AppEvent| -> Result<()> {
|
||||
match event {
|
||||
events::AppEvent::ClockDone => {
|
||||
debug!("AppEvent::ClockDone");
|
||||
if let Some(path) = app.sound_path.clone() {
|
||||
_ = Sound::new(path).and_then(|sound| sound.play()).or_else(
|
||||
|err| -> Result<()> {
|
||||
error!("Sound error: {:?}", err);
|
||||
Ok(())
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
20
src/args.rs
20
src/args.rs
@@ -1,8 +1,10 @@
|
||||
use crate::{
|
||||
common::{Content, Notification, Style},
|
||||
duration,
|
||||
duration, sound,
|
||||
sound::SoundError,
|
||||
};
|
||||
use clap::Parser;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Parser)]
|
||||
@@ -45,4 +47,20 @@ pub struct Args {
|
||||
help = "Toggle desktop notifications on or off. Experimental."
|
||||
)]
|
||||
pub notification: Option<Notification>,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
value_enum,
|
||||
help = "Path to sound file (.mp3 or .wav) to play as notification. Experimental.",
|
||||
value_hint = clap::ValueHint::FilePath,
|
||||
value_parser = sound_file_parser,
|
||||
)]
|
||||
pub sound: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Custom parser for sound file
|
||||
fn sound_file_parser(s: &str) -> Result<PathBuf, SoundError> {
|
||||
let path = PathBuf::from(s);
|
||||
sound::validate_sound_file(&path)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ mod logging;
|
||||
|
||||
mod args;
|
||||
mod duration;
|
||||
mod sound;
|
||||
mod storage;
|
||||
mod terminal;
|
||||
mod utils;
|
||||
|
||||
74
src/sound.rs
74
src/sound.rs
@@ -1,28 +1,72 @@
|
||||
use rodio::{Decoder, OutputStream, Sink};
|
||||
use std::fs;
|
||||
use std::fs::File;
|
||||
use std::io::BufReader;
|
||||
use std::path::PathBuf;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SoundError {
|
||||
#[error("Sound output stream error: {0}")]
|
||||
OutputStream(String),
|
||||
#[error("Sound file error: {0}")]
|
||||
File(String),
|
||||
#[error("Sound sink error: {0}")]
|
||||
Sink(String),
|
||||
#[error("Sound decoder error: {0}")]
|
||||
Decoder(String),
|
||||
}
|
||||
|
||||
pub fn validate_sound_file(path: &PathBuf) -> Result<&PathBuf, SoundError> {
|
||||
// validate path
|
||||
if !path.exists() {
|
||||
let err = SoundError::File(format!("File not found: {:?}", path));
|
||||
return Err(err);
|
||||
};
|
||||
|
||||
// Validate file extension
|
||||
path.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.filter(|ext| ["mp3", "wav"].contains(&ext.to_lowercase().as_str()))
|
||||
.ok_or_else(|| {
|
||||
SoundError::File(
|
||||
"Unsupported file extension. Only .mp3 and .wav are supported".to_owned(),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
// #[derive(Clone)]
|
||||
pub struct Sound {
|
||||
sound_data: Vec<u8>,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl Sound {
|
||||
pub fn new(path: &str) -> Result<Self, String> {
|
||||
let sound_data = fs::read(path).map_err(|e| format!("Failed to read sound file: {}", e))?;
|
||||
Ok(Self { sound_data })
|
||||
pub fn new(path: PathBuf) -> Result<Self, SoundError> {
|
||||
Ok(Self { path })
|
||||
}
|
||||
|
||||
pub fn play(&self) -> Result<(), String> {
|
||||
let (_stream, stream_handle) = OutputStream::try_default()
|
||||
.map_err(|e| format!("Failed to get audio output: {}", e))?;
|
||||
pub fn play(&self) -> Result<(), SoundError> {
|
||||
// validate file again
|
||||
validate_sound_file(&self.path)?;
|
||||
// before playing the sound
|
||||
let path = self.path.clone();
|
||||
|
||||
let sink = Sink::try_new(&stream_handle)
|
||||
.map_err(|e| format!("Failed to create audio sink: {}", e))?;
|
||||
std::thread::spawn(move || -> Result<(), SoundError> {
|
||||
// Important note: Never (ever) use a single `_` as a placeholder here. `_stream` or something is fine!
|
||||
// The value will dropped and the sound will fail without any errors
|
||||
// see https://github.com/RustAudio/rodio/issues/330
|
||||
let (_stream, handle) =
|
||||
OutputStream::try_default().map_err(|e| SoundError::OutputStream(e.to_string()))?;
|
||||
let file = File::open(&path).map_err(|e| SoundError::File(e.to_string()))?;
|
||||
let sink = Sink::try_new(&handle).map_err(|e| SoundError::Sink(e.to_string()))?;
|
||||
let decoder = Decoder::new(BufReader::new(file))
|
||||
.map_err(|e| SoundError::Decoder(e.to_string()))?;
|
||||
sink.append(decoder);
|
||||
sink.sleep_until_end();
|
||||
|
||||
let cursor = std::io::Cursor::new(self.sound_data.clone());
|
||||
let source = Decoder::new(cursor).map_err(|e| format!("Failed to decode audio: {}", e))?;
|
||||
|
||||
sink.append(source);
|
||||
sink.sleep_until_end();
|
||||
Ok(())
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user