diff options
Diffstat (limited to 'src-tauri/src/audio.rs')
| -rw-r--r-- | src-tauri/src/audio.rs | 117 |
1 files changed, 109 insertions, 8 deletions
diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index a439a0c..11c970e 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -1,7 +1,7 @@ use rodio::{Decoder, OutputStream, OutputStreamHandle, Sink, Source}; use std::fs::File; use std::io::BufReader; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::thread; @@ -17,6 +17,22 @@ pub enum AmbientSound { WhiteNoise, } +const EXPECTED_AUDIO_FILES: [&str; 3] = ["rain.ogg", "cafe.ogg", "white_noise.ogg"]; + +pub fn directory_has_any_audio_files(dir: &Path) -> bool { + EXPECTED_AUDIO_FILES + .iter() + .any(|filename| dir.join(filename).is_file()) +} + +pub fn choose_audio_dir(app_data_audio_dir: PathBuf, bundled_audio_dir: PathBuf) -> PathBuf { + if directory_has_any_audio_files(&app_data_audio_dir) { + app_data_audio_dir + } else { + bundled_audio_dir + } +} + impl AmbientSound { pub fn filename(&self) -> &'static str { match self { @@ -51,11 +67,16 @@ pub struct AudioEngine { volume: f32, ducked: bool, duck_volume: f32, - audio_dir: PathBuf, + app_data_audio_dir: PathBuf, + bundled_audio_dir: PathBuf, } impl AudioEngine { - fn new_with_handle(stream_handle: OutputStreamHandle, audio_dir: PathBuf) -> Self { + fn new_with_handle( + stream_handle: OutputStreamHandle, + app_data_audio_dir: PathBuf, + bundled_audio_dir: PathBuf, + ) -> Self { Self { stream_handle, sink: None, @@ -63,14 +84,33 @@ impl AudioEngine { volume: 0.5, ducked: false, duck_volume: 0.2, - audio_dir, + app_data_audio_dir, + bundled_audio_dir, + } + } + + fn resolve_audio_path(&self, filename: &str) -> PathBuf { + let app_data_path = self.app_data_audio_dir.join(filename); + if app_data_path.is_file() { + app_data_path + } else { + let bundled_path = self.bundled_audio_dir.join(filename); + if bundled_path.is_file() { + bundled_path + } else { + choose_audio_dir( + self.app_data_audio_dir.clone(), + self.bundled_audio_dir.clone(), + ) + .join(filename) + } } } pub fn play(&mut self, sound: AmbientSound) -> Result<(), String> { self.stop(); let filename = sound.filename(); - let path = self.audio_dir.join(filename); + let path = self.resolve_audio_path(filename); let file = File::open(&path).map_err(|e| format!("Cannot open {filename}: {e}"))?; let source = Decoder::new(BufReader::new(file)) .map_err(|e| format!("Decode error: {e}"))?; @@ -125,6 +165,11 @@ impl AudioEngine { pub fn volume(&self) -> f32 { self.volume } + + pub fn has_any_audio_files(&self) -> bool { + directory_has_any_audio_files(&self.app_data_audio_dir) + || directory_has_any_audio_files(&self.bundled_audio_dir) + } } pub fn should_duck_for_phase(phase: TimerPhase) -> bool { @@ -139,14 +184,14 @@ pub struct AudioState(pub Arc<Mutex<Option<AudioEngine>>>); /// /// Spawns a dedicated thread to keep `OutputStream` alive (it is `!Send`). /// Returns `AudioState` whose inner `Option` is `None` if no audio device exists. -pub fn init_audio(audio_dir: PathBuf) -> AudioState { +pub fn init_audio(app_data_audio_dir: PathBuf, bundled_audio_dir: PathBuf) -> AudioState { let state: Arc<Mutex<Option<AudioEngine>>> = Arc::new(Mutex::new(None)); let state_clone = Arc::clone(&state); thread::spawn(move || { match OutputStream::try_default() { Ok((_stream, handle)) => { - let engine = AudioEngine::new_with_handle(handle, audio_dir); + let engine = AudioEngine::new_with_handle(handle, app_data_audio_dir, bundled_audio_dir); { let mut guard = state_clone.lock().unwrap(); *guard = Some(engine); @@ -172,9 +217,29 @@ pub fn init_audio(audio_dir: PathBuf) -> AudioState { #[cfg(test)] mod tests { - use super::should_duck_for_phase; + use std::fs; + use std::path::Path; + use std::time::{SystemTime, UNIX_EPOCH}; + + use super::{choose_audio_dir, directory_has_any_audio_files, should_duck_for_phase}; use crate::state::TimerPhase; + fn temp_path(label: &str) -> std::path::PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("solstice-audio-{label}-{unique}")) + } + + fn make_dir(path: &Path) { + fs::create_dir_all(path).unwrap(); + } + + fn write_audio_file(dir: &Path, name: &str) { + fs::write(dir.join(name), b"test-audio").unwrap(); + } + #[test] fn work_phase_does_not_duck() { assert!(!should_duck_for_phase(TimerPhase::Work)); @@ -185,4 +250,40 @@ mod tests { assert!(should_duck_for_phase(TimerPhase::ShortBreak)); assert!(should_duck_for_phase(TimerPhase::LongBreak)); } + + #[test] + fn detects_expected_audio_files_in_directory() { + let dir = temp_path("detect-files"); + make_dir(&dir); + write_audio_file(&dir, "white_noise.ogg"); + + assert!(directory_has_any_audio_files(&dir)); + } + + #[test] + fn prefers_app_data_audio_when_files_exist_there() { + let app_dir = temp_path("app-data"); + let bundled_dir = temp_path("bundled"); + make_dir(&app_dir); + make_dir(&bundled_dir); + write_audio_file(&app_dir, "rain.ogg"); + write_audio_file(&bundled_dir, "cafe.ogg"); + + let chosen = choose_audio_dir(app_dir.clone(), bundled_dir.clone()); + + assert_eq!(chosen, app_dir); + } + + #[test] + fn falls_back_to_bundled_audio_when_app_data_is_empty() { + let app_dir = temp_path("app-data-empty"); + let bundled_dir = temp_path("bundled-fallback"); + make_dir(&app_dir); + make_dir(&bundled_dir); + write_audio_file(&bundled_dir, "rain.ogg"); + + let chosen = choose_audio_dir(app_dir, bundled_dir.clone()); + + assert_eq!(chosen, bundled_dir); + } } |
