feat(clock): sound notification (experimental) (#62)

This commit is contained in:
Jens Krause
2025-02-04 17:28:41 +01:00
committed by GitHub
parent 8f50bc5fc6
commit a54b1b409a
10 changed files with 818 additions and 52 deletions

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ mod logging;
mod args;
mod duration;
mod sound;
mod storage;
mod terminal;
mod utils;

View File

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