Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/src/reference/config-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
21 changes: 21 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ impl Config {
}
self.system_info.validate();
self.settings.validate();
self.media_player.validate();
}
}

Expand Down Expand Up @@ -750,6 +751,7 @@ pub struct MediaPlayerModuleConfig {
pub max_text_length: u32,
pub indicator_visualizer: Option<MediaPlayerVisualizer>,
pub menu_visualizer: bool,
pub visualizer_framerate: u32,
}

impl Default for MediaPlayerModuleConfig {
Expand All @@ -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;
}
}
}
Expand Down
181 changes: 109 additions & 72 deletions src/modules/media_player.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -153,6 +152,7 @@ pub enum Message {
Event(ServiceEvent<MprisPlayerService>),
ConfigReloaded(MediaPlayerModuleConfig),
Bars(Vec<f32>),
MenuOpened,
}

pub enum Action {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
}
}

Expand Down Expand Up @@ -554,8 +565,8 @@ impl MediaPlayer {
})
}

pub fn subscription(&self) -> Subscription<Message> {
let cava = (self.visualizer_enabled() && self.is_playing())
pub fn subscription(&self, menu_open: bool) -> Subscription<Message> {
let cava = (self.visualizer_enabled(menu_open) && self.is_playing())
.then(|| self.cava_subscription().map(Message::Bars));
Subscription::batch(
[
Expand All @@ -570,74 +581,100 @@ impl MediaPlayer {
fn cava_subscription(&self) -> Subscription<Vec<f32>> {
struct Cava;

Subscription::run_with(TypeId::of::<Cava>(), |_| {
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::<Cava>(), 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<Item = Vec<f32>> {
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<f32> = line
.split(';')
.filter(|s| !s.is_empty())
.filter_map(|s| s.trim().parse::<f32>().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<Vec<u8>> = None;

while let Ok(Some(line)) = lines.next_line().await {
let bars: Vec<f32> = line
.split(';')
.filter(|s| !s.is_empty())
.filter_map(|s| s.trim().parse::<f32>().ok())
.map(|v| v / 1000.0)
.collect();

if bars.is_empty() {
continue;
}

let quantized: Vec<u8> = 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());
}
})
}
8 changes: 5 additions & 3 deletions src/modules/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions src/outputs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion website/docs/configuration/full_config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion website/docs/configuration/modules/media_player.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -89,4 +100,5 @@ indicator_format = "Text"
indicator_fields = ["Title"]
indicator_visualizer = "Background"
menu_visualizer = true
visualizer_framerate = 30
```