diff --git a/resources/timer_sound_looped.flac b/resources/timer_sound_looped.flac new file mode 100644 index 0000000..ebd7974 Binary files /dev/null and b/resources/timer_sound_looped.flac differ diff --git a/src/daemon/audio.rs b/src/daemon/audio.rs index 2e4a467..f9d13a7 100644 --- a/src/daemon/audio.rs +++ b/src/daemon/audio.rs @@ -2,15 +2,16 @@ use std::ffi::OsStr; use std::fmt::Debug; use std::fmt::{self, Display, Formatter}; use std::fs::File; -use std::io::{self, BufReader, ErrorKind}; +use std::io::{self, BufReader, Cursor, ErrorKind, Read}; use std::path::{Path, PathBuf}; -use std::sync::Arc; +use std::sync::{Arc, Weak}; use indoc::indoc; use notify::{RecursiveMode, Watcher as _}; +use rodio::decoder::LoopedDecoder; use rodio::source::Buffered; -use rodio::{Decoder, OutputStream, Source}; -use tokio::sync::RwLock; +use rodio::{Decoder, OutputStream, Sink, Source}; +use tokio::sync::{Mutex, MutexGuard, RwLock}; use tokio_stream::StreamExt as _; use tokio_stream::wrappers::ReceiverStream; @@ -54,19 +55,34 @@ impl From for SoundLoadError { type SoundLoadResult = Result; -type Sound = Buffered>>; +type OneShotSound = Buffered>>; +type LoopedSound = Buffered>>>; + +#[derive(Clone)] +enum Sound { + OneShot(OneShotSound), + Looped(LoopedSound), +} fn load_sound(path: &Path) -> SoundLoadResult { - use std::fs::File; - let file = File::open(path)?; - log::debug!( - "Found sound file at {}, attempting to load", - path.to_string_lossy() - ); + let buf = { + use std::fs::File; + let mut file = File::open(path)?; + log::debug!( + "Found sound file at {}, attempting to load", + path.to_string_lossy() + ); + let mut buf = Vec::with_capacity(1_000_000); + file.read_to_end(&mut buf)?; + buf + }; + let cursor = Cursor::new(buf); let decoder = - Decoder::try_from(file).map_err(|err| SoundLoadError::DecoderError(err.to_string()))?; - let buf = decoder.buffered(); - Ok(buf) + Decoder::new_looped(cursor).map_err(|err| SoundLoadError::DecoderError(err.to_string()))?; + // let decoder = + // Decoder::try_from(file).map_err(|err| SoundLoadError::DecoderError(err.to_string()))?; + let buffered = decoder.buffered(); + Ok(Sound::Looped(buffered)) } const SOUND_FILENAME: &str = "timer_sound"; @@ -213,9 +229,29 @@ impl Display for ElapsedSoundPlayerError { } } +/// While any task holds one of these handles, the sound will continue to loop. +/// Once all handles are dropped, the sound will stop playing. +/// Only one instance of the sound will play at a time. +#[must_use] +pub struct LoopedSoundPlayback( + // This field is not supposed to be accessed, we use its Drop for side + // effects + #[allow(dead_code)] Arc, +); + +/// The result of a call to ElapsedSoundPlayer::play() +/// if the daemon was started in oneshot mode, it will return OneShot, which can +/// be discarded. If the daemon was started in looped mode, it will return a +/// LoopedSoundPlayback, which should be held until the caller wants to stop the sound. +pub enum Playback { + OneShot, + Looped(#[allow(dead_code)] LoopedSoundPlayback), +} + pub struct ElapsedSoundPlayer { sound: Arc>, output_stream: OutputStream, + sink: Mutex>, } impl ElapsedSoundPlayer { @@ -229,13 +265,44 @@ impl ElapsedSoundPlayer { let player = Self { sound: sound, output_stream: stream, + sink: Mutex::new(Weak::new()), }; Ok(player) } - pub async fn play(&self) { + pub async fn play(&self) -> Playback { let s = self.sound.read().await.clone(); - self.output_stream.mixer().add(s); + match s { + Sound::OneShot(buffered) => { + self.output_stream.mixer().add(buffered); + Playback::OneShot + } + Sound::Looped(buffered) => Playback::Looped(self.play_looped(buffered).await), + } + } + + async fn play_looped(&self, sound: LoopedSound) -> LoopedSoundPlayback { + let sink_lock = self.sink.lock().await; + match sink_lock.upgrade() { + Some(sink) => LoopedSoundPlayback(sink), + None => { + let sink = self.new_looped_elapsed_sound_sink(sink_lock, sound).await; + LoopedSoundPlayback(sink) + } + } + } + + async fn new_looped_elapsed_sound_sink( + &self, + mut lock: MutexGuard<'_, Weak>, + sound: LoopedSound, + ) -> Arc { + let mixer = self.output_stream.mixer(); + let sink = Sink::connect_new(mixer); + sink.append(sound); + let arc = Arc::new(sink); + *lock = Arc::downgrade(&arc); + arc } } diff --git a/src/daemon/ctx.rs b/src/daemon/ctx.rs index be230bd..75c6c0a 100644 --- a/src/daemon/ctx.rs +++ b/src/daemon/ctx.rs @@ -5,6 +5,7 @@ use std::time::SystemTime; use logind_zbus::manager::ManagerProxy; use notify_rust::Notification; +use notify_rust::Timeout; use tokio::sync::Notify; use tokio::sync::RwLock; use tokio_stream::Stream; @@ -197,6 +198,7 @@ impl DaemonCtx { .icon("alarm") .urgency(notify_rust::Urgency::Critical) .action("restart", "⟳ Restart") + .timeout(Timeout::Never) .show_async() .await; let notification_handle = match notification { @@ -208,12 +210,13 @@ impl DaemonCtx { } }; - if let Some(ref player) = self.elapsed_sound_player { + let _looped_playback = if let Some(ref player) = self.elapsed_sound_player { log::debug!("playing sound"); - player.play().await; + Some(player.play().await) } else { log::debug!("player is None - not playing sound"); - } + None + }; notification_handle.wait_for_action(|s| match s { "restart" => {