diff --git a/docs/src/reference/config-reference.md b/docs/src/reference/config-reference.md index 5cc01137..3aac7144 100644 --- a/docs/src/reference/config-reference.md +++ b/docs/src/reference/config-reference.md @@ -194,10 +194,11 @@ indicator_format = "IconAndText" # "Text", "IconAndText", or "Icon" indicator_fields = ["Artist", "Title"] max_text_length = 100 indicator_visualizer = "Background" # "Background", "Before", or "After"; omit to disable -menu_visualizer = false # bars behind the menu cards +menu_visualizer = false # bars behind the menu cards; cava runs only while the menu is open +visualizer_framerate = 30 # cava frames per second, clamped to 1-144 ``` -**Dependencies:** Any MPRIS-compatible media player (e.g., Spotify, Firefox, VLC, Strawberry). No extra system package is needed. +**Dependencies:** Any MPRIS-compatible media player (e.g., Spotify, Firefox, VLC, Strawberry). No extra system package is needed. The visualizer additionally needs `cava` on `$PATH`. ## Custom Modules diff --git a/src/app.rs b/src/app.rs index 831660ed..f750cc52 100644 --- a/src/app.rs +++ b/src/app.rs @@ -282,6 +282,12 @@ impl App { }, ); } + MenuType::MediaPlayer + if !self.outputs.menu_of_type_is_open(&MenuType::MediaPlayer) => + { + self.media_player + .update(modules::media_player::Message::MenuOpened); + } _ => {} }; cmd.push(self.outputs.toggle_menu( diff --git a/src/config.rs b/src/config.rs index 3c5eb138..dc8ea558 100644 --- a/src/config.rs +++ b/src/config.rs @@ -90,6 +90,7 @@ impl Config { } self.system_info.validate(); self.settings.validate(); + self.media_player.validate(); } } @@ -750,6 +751,7 @@ pub struct MediaPlayerModuleConfig { pub max_text_length: u32, pub indicator_visualizer: Option, pub menu_visualizer: bool, + pub visualizer_framerate: u32, } impl Default for MediaPlayerModuleConfig { @@ -760,6 +762,25 @@ impl Default for MediaPlayerModuleConfig { max_text_length: 100, indicator_visualizer: None, menu_visualizer: false, + visualizer_framerate: Self::DEFAULT_VISUALIZER_FRAMERATE, + } + } +} + +impl MediaPlayerModuleConfig { + const DEFAULT_VISUALIZER_FRAMERATE: u32 = 30; + const MAX_VISUALIZER_FRAMERATE: u32 = 144; + + fn validate(&mut self) { + let clamped = self + .visualizer_framerate + .clamp(1, Self::MAX_VISUALIZER_FRAMERATE); + if clamped != self.visualizer_framerate { + warn!( + "MediaPlayerModuleConfig.visualizer_framerate is {}, setting to {clamped}", + self.visualizer_framerate + ); + self.visualizer_framerate = clamped; } } } diff --git a/src/modules/media_player.rs b/src/modules/media_player.rs index 646151f7..501d8045 100644 --- a/src/modules/media_player.rs +++ b/src/modules/media_player.rs @@ -31,7 +31,6 @@ use iced::{ use std::any::TypeId; const VISUALIZER_BAR_COUNT: usize = 32; -const VISUALIZER_FRAMERATE: u32 = 60; const VISUALIZER_BAR_MIN_WIDTH: f32 = 2.0; const VISUALIZER_BAR_GAP: f32 = 2.0; @@ -153,6 +152,7 @@ pub enum Message { Event(ServiceEvent), ConfigReloaded(MediaPlayerModuleConfig), Bars(Vec), + MenuOpened, } pub enum Action { @@ -189,8 +189,10 @@ impl MediaPlayer { self.active_player().map(|p| p.state) == Some(PlaybackStatus::Playing) } - fn visualizer_enabled(&self) -> bool { - self.config.indicator_visualizer.is_some() || self.config.menu_visualizer + /// `menu_visualizer` only draws inside the menu, so it must not keep cava + /// running — and re-rendering every bar surface — while the menu is closed. + fn visualizer_enabled(&self, menu_open: bool) -> bool { + self.config.indicator_visualizer.is_some() || (self.config.menu_visualizer && menu_open) } pub fn update(&mut self, message: Message) -> Action { @@ -227,6 +229,15 @@ impl MediaPlayer { self.bars = bars; Action::None } + Message::MenuOpened => { + // With only `menu_visualizer` enabled, cava stops when the menu + // closes, so the last frame would sit frozen behind the card + // until a fresh frame arrives. Drop it instead. + if !self.visualizer_enabled(false) { + self.bars.clear(); + } + Action::None + } } } @@ -554,8 +565,8 @@ impl MediaPlayer { }) } - pub fn subscription(&self) -> Subscription { - let cava = (self.visualizer_enabled() && self.is_playing()) + pub fn subscription(&self, menu_open: bool) -> Subscription { + let cava = (self.visualizer_enabled(menu_open) && self.is_playing()) .then(|| self.cava_subscription().map(Message::Bars)); Subscription::batch( [ @@ -570,74 +581,100 @@ impl MediaPlayer { fn cava_subscription(&self) -> Subscription> { struct Cava; - Subscription::run_with(TypeId::of::(), |_| { - channel(16, async move |mut output| { - let cava_config = format!( - "[general]\nbars = {VISUALIZER_BAR_COUNT}\nframerate = {VISUALIZER_FRAMERATE}\n\n\ - [output]\nmethod = raw\nraw_target = /dev/stdout\ndata_format = ascii\nascii_max_range = 1000\n\n\ - [smoothing]\nmonstercat = 1\n" - ); - - let config_path = std::env::temp_dir().join("ashell_cava.cfg"); - if let Err(e) = tokio::fs::write(&config_path, &cava_config).await { - log::error!("cava: failed to write config: {e}"); - return; - } + // The framerate is part of the subscription id so that a config reload + // restarts cava instead of leaving the old process at the old rate. + Subscription::run_with( + (TypeId::of::(), self.config.visualizer_framerate), + |(_, framerate)| cava_stream(*framerate), + ) + } +} - let mut child = match tokio::process::Command::new("cava") - .arg("-p") - .arg(&config_path) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .kill_on_drop(true) - .spawn() - { - Ok(c) => c, - Err(e) => { - log::warn!("cava: failed to spawn process: {e}"); - return; - } - }; +/// One cava process, decoded into normalized bar frames. +fn cava_stream(framerate: u32) -> impl iced::futures::Stream> { + channel(16, async move |mut output| { + let cava_config = format!( + "[general]\nbars = {VISUALIZER_BAR_COUNT}\nframerate = {framerate}\n\n\ + [output]\nmethod = raw\nraw_target = /dev/stdout\ndata_format = ascii\nascii_max_range = 1000\n\n\ + [smoothing]\nmonstercat = 1\n" + ); + + let config_path = std::env::temp_dir().join("ashell_cava.cfg"); + if let Err(e) = tokio::fs::write(&config_path, &cava_config).await { + log::error!("cava: failed to write config: {e}"); + return; + } - let stdout = match child.stdout.take() { - Some(s) => s, - None => { - log::error!("cava: no stdout"); - return; - } - }; - let stderr = child.stderr.take(); - - use tokio::io::{AsyncBufReadExt, BufReader}; - let reader = BufReader::new(stdout); - let mut lines = reader.lines(); - - while let Ok(Some(line)) = lines.next_line().await { - let bars: Vec = line - .split(';') - .filter(|s| !s.is_empty()) - .filter_map(|s| s.trim().parse::().ok()) - .map(|v| v / 1000.0) - .collect(); - - if !bars.is_empty() { - let _ = output.send(bars).await; - } - } + let mut child = match tokio::process::Command::new("cava") + .arg("-p") + .arg(&config_path) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true) + .spawn() + { + Ok(c) => c, + Err(e) => { + log::warn!("cava: failed to spawn process: {e}"); + return; + } + }; - // stdout closed: cava exited on its own. Surface why, so a rejected - // config or an incompatible version is not a silent no-op. - if let Ok(status) = child.wait().await - && !status.success() - { - let mut err = String::new(); - if let Some(mut stderr) = stderr { - use tokio::io::AsyncReadExt; - let _ = stderr.read_to_string(&mut err).await; - } - log::warn!("cava exited ({status}): {}", err.trim()); - } - }) - }) - } + let stdout = match child.stdout.take() { + Some(s) => s, + None => { + log::error!("cava: no stdout"); + return; + } + }; + let stderr = child.stderr.take(); + + use tokio::io::{AsyncBufReadExt, BufReader}; + let reader = BufReader::new(stdout); + let mut lines = reader.lines(); + + // cava emits at its configured framerate regardless of what the + // audio is doing, so silence and the tail of the monstercat + // decay produce long runs of identical frames. Every frame that + // reaches the runtime rebuilds `view()` and presents on *every* + // output, so drop the ones that cannot look any different. + let mut last_sent: Option> = None; + + while let Ok(Some(line)) = lines.next_line().await { + let bars: Vec = line + .split(';') + .filter(|s| !s.is_empty()) + .filter_map(|s| s.trim().parse::().ok()) + .map(|v| v / 1000.0) + .collect(); + + if bars.is_empty() { + continue; + } + + let quantized: Vec = bars + .iter() + .map(|v| (v.clamp(0.0, 1.0) * 255.0) as u8) + .collect(); + if last_sent.as_ref() == Some(&quantized) { + continue; + } + last_sent = Some(quantized); + + let _ = output.send(bars).await; + } + + // stdout closed: cava exited on its own. Surface why, so a rejected + // config or an incompatible version is not a silent no-op. + if let Ok(status) = child.wait().await + && !status.success() + { + let mut err = String::new(); + if let Some(mut stderr) = stderr { + use tokio::io::AsyncReadExt; + let _ = stderr.read_to_string(&mut err).await; + } + log::warn!("cava exited ({status}): {}", err.trim()); + } + }) } diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 85f63bfc..a8a6adb3 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -356,9 +356,11 @@ impl App { ModuleName::Tray => Some(self.tray.subscription().map(Message::Tray)), ModuleName::Tempo => Some(self.tempo.subscription().map(Message::Tempo)), ModuleName::Privacy => Some(self.privacy.subscription().map(Message::Privacy)), - ModuleName::MediaPlayer => { - Some(self.media_player.subscription().map(Message::MediaPlayer)) - } + ModuleName::MediaPlayer => Some( + self.media_player + .subscription(self.outputs.menu_of_type_is_open(&MenuType::MediaPlayer)) + .map(Message::MediaPlayer), + ), ModuleName::Settings => Some(self.settings.subscription().map(Message::Settings)), ModuleName::Notifications => Some( self.notifications diff --git a/src/outputs.rs b/src/outputs.rs index 73901a8f..9cdc6dcf 100644 --- a/src/outputs.rs +++ b/src/outputs.rs @@ -594,6 +594,18 @@ impl Outputs { .any(|(_, shell_info, _)| shell_info.as_ref().is_some_and(|si| si.menu.is_open())) } + /// True while a menu of `menu_type` is open on any output. + pub fn menu_of_type_is_open(&self, menu_type: &MenuType) -> bool { + self.entries.iter().any(|(_, shell_info, _)| { + shell_info.as_ref().is_some_and(|si| { + si.menu + .open + .as_ref() + .is_some_and(|open| &open.menu_type == menu_type) + }) + }) + } + pub fn menu_is_closing(&self, id: SurfaceId) -> bool { self.entries.iter().any(|(_, shell_info, _)| { shell_info diff --git a/website/docs/configuration/full_config.md b/website/docs/configuration/full_config.md index e5187864..3a0891eb 100644 --- a/website/docs/configuration/full_config.md +++ b/website/docs/configuration/full_config.md @@ -90,7 +90,8 @@ alert_threshold = 85 # indicator_fields = ["Artist", "Title"] # (default), also supports "Album" # max_text_length = 100 # (default) # indicator_visualizer = "Background" # (default: None = disabled), "Before", or "After" -# menu_visualizer = false # (default) bars behind the menu cards +# menu_visualizer = false # (default) bars behind the menu cards; cava runs only while the menu is open +# visualizer_framerate = 30 # (default) cava frames per second, clamped to 1-144 [tray] # blocklist = ["regex"] # (default: []) hide tray items matching regex patterns diff --git a/website/docs/configuration/modules/media_player.md b/website/docs/configuration/modules/media_player.md index b3329f6c..b328783b 100644 --- a/website/docs/configuration/modules/media_player.md +++ b/website/docs/configuration/modules/media_player.md @@ -54,12 +54,21 @@ the field to disable it. | `After` | Draws the bars after (to the right of) the content. | `menu_visualizer` (bool, default `false`) draws the bars as the background of -the currently playing card in the media menu. +the currently playing card in the media menu. Because those bars are only +visible while the menu is up, `cava` runs only while the media menu is open — +unless `indicator_visualizer` is also set, which keeps it running whenever a +player is playing. + +`visualizer_framerate` (integer, default `30`, clamped to `1`–`144`) sets how +many frames per second CAVA produces. Every frame redraws the bar on every +monitor, so raising this measurably increases CPU use; lower it if the bar +feels sluggish while the visualizer is active. ```toml [media_player] indicator_visualizer = "Background" menu_visualizer = true +visualizer_framerate = 30 ``` The bars are coloured with a gradient built from the active theme palette @@ -69,6 +78,8 @@ CAVA visualizes the system audio output, not the individual stream of the active player (MPRIS carries only metadata and playback controls, not audio samples). The visualizer is therefore shown only while a player is playing, and `cava` is started on demand and stopped again while playback is paused. +Frames that are visually identical to the previous one are discarded rather +than redrawn, so silence costs nothing. > **Requires** `cava` to be installed and available on your `$PATH`. If `cava` > is missing the visualizer stays hidden. @@ -89,4 +100,5 @@ indicator_format = "Text" indicator_fields = ["Title"] indicator_visualizer = "Background" menu_visualizer = true +visualizer_framerate = 30 ```