use serde_json::Value;
use std::ffi::OsStr;
use tokio::process::Command;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Stream,
Sink,
Source,
}
impl Kind {
fn noun(self) -> &'static str {
match self {
Kind::Stream => "sink-input",
Kind::Sink => "sink",
Kind::Source => "source",
}
}
}
#[derive(Debug, Clone)]
pub struct Entry {
pub index: u32,
pub name: String,
pub label: String,
pub detail: String,
pub volume: u32,
pub mute: bool,
pub default: bool,
}
#[derive(Debug, Clone, Default)]
pub struct Snapshot {
pub streams: Vec<Entry>,
pub sinks: Vec<Entry>,
pub sources: Vec<Entry>,
}
#[derive(Debug, Clone)]
pub struct Pactl {
pub server: &'static str,
}
impl Pactl {
pub async fn new() -> anyhow::Result<Self> {
let info = run(["info"]).await?;
let server = if info.contains("PipeWire") {
"PipeWire"
} else {
"PulseAudio"
};
Ok(Self { server })
}
pub async fn snapshot(&self) -> anyhow::Result<Snapshot> {
let (streams, sinks, sources, default_sink, default_source) = tokio::try_join!(
run(["--format=json", "list", "sink-inputs"]),
run(["--format=json", "list", "sinks"]),
run(["--format=json", "list", "sources"]),
run(["get-default-sink"]),
run(["get-default-source"]),
)?;
Ok(Snapshot {
streams: parse(&streams, Kind::Stream, "")?,
sinks: parse(&sinks, Kind::Sink, default_sink.trim())?,
sources: parse(&sources, Kind::Source, default_source.trim())?,
})
}
pub async fn set_volume(&self, kind: Kind, index: u32, percent: u32) -> anyhow::Result<()> {
run([
format!("set-{}-volume", kind.noun()),
index.to_string(),
format!("{percent}%"),
])
.await?;
Ok(())
}
pub async fn set_mute(&self, kind: Kind, index: u32, mute: bool) -> anyhow::Result<()> {
run([
format!("set-{}-mute", kind.noun()),
index.to_string(),
if mute { "1" } else { "0" }.to_string(),
])
.await?;
Ok(())
}
pub async fn set_default(&self, kind: Kind, name: &str) -> anyhow::Result<()> {
run([format!("set-default-{}", kind.noun()), name.to_string()]).await?;
Ok(())
}
}
async fn run<I, S>(args: I) -> anyhow::Result<String>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let output = Command::new("pactl")
.args(args)
.env("LC_ALL", "C")
.output()
.await?;
if !output.status.success() {
anyhow::bail!("{}", String::from_utf8_lossy(&output.stderr).trim());
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn parse(json: &str, kind: Kind, default: &str) -> anyhow::Result<Vec<Entry>> {
let items: Vec<Value> = serde_json::from_str(json)?;
Ok(items
.iter()
.filter_map(|item| {
let name = item["name"].as_str().unwrap_or_default().to_string();
if kind == Kind::Source && name.ends_with(".monitor") {
return None;
}
let properties = &item["properties"];
let (label, detail) = match kind {
Kind::Stream => (
properties["application.name"]
.as_str()
.or(properties["application.process.binary"].as_str())
.unwrap_or("Unknown")
.to_string(),
properties["media.name"]
.as_str()
.unwrap_or_default()
.to_string(),
),
_ => (
item["description"].as_str().unwrap_or(&name).to_string(),
String::new(),
),
};
Some(Entry {
index: item["index"].as_u64()? as u32,
default: kind != Kind::Stream && name == default,
name,
label,
detail,
volume: volume_percent(item),
mute: item["mute"].as_bool().unwrap_or(false),
})
})
.collect())
}
fn volume_percent(item: &Value) -> u32 {
let values = item["volume"]
.as_object()
.map(|channels| {
channels
.values()
.filter_map(|channel| channel["value"].as_u64())
.collect::<Vec<_>>()
})
.unwrap_or_default();
if values.is_empty() {
return 0;
}
let average = values.iter().sum::<u64>() as f64 / values.len() as f64;
(average * 100.0 / 65536.0).round() as u32
}