318b2a8443
The tile or block grows into its menu; the panel fades its content and takes the menu's size underneath. Wi-Fi lists networks (join saved/open, disconnect, stop iwd), Bluetooth lists devices, VPN has a switch per openvpn service, Sound has every output with its own slider and a default picker. A second Sound slider for the output named by extra_output in shell.conf (NR515). Screen and keyboard light are written to sysfs directly: logind refuses a shell without a seat.
526 lines
16 KiB
Rust
526 lines
16 KiB
Rust
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::path::PathBuf;
|
|
use std::process::{Command, Stdio};
|
|
use std::time::{Duration, Instant};
|
|
use std::{fs, thread};
|
|
|
|
use amane::{Argument, Bus, Media, Service};
|
|
|
|
use crate::art;
|
|
use crate::parse::{self, EQ_PREFIX, Sink, Stream};
|
|
use crate::theme;
|
|
|
|
// how far a band can lift or cut, in dB
|
|
pub const MAX_GAIN: f32 = 12.0;
|
|
|
|
// gains for BANDS (31 Hz .. 16 kHz)
|
|
pub const EQ_PRESETS: [(&str, [f32; 10]); 8] = [
|
|
("Flat", [0.0; 10]),
|
|
("Bass", [6.0, 5.0, 4.0, 2.5, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
|
|
("Treble", [-2.0, -1.5, -1.0, 0.0, 1.0, 2.0, 3.0, 4.5, 5.5, 6.0]),
|
|
("Vocal", [-3.0, -2.0, -1.0, 1.0, 3.0, 4.0, 3.5, 2.0, 0.0, -1.0]),
|
|
("Pop", [-1.0, 1.0, 2.5, 3.5, 2.5, 0.5, -0.5, -0.5, 1.0, 2.0]),
|
|
("Rock", [5.0, 4.0, 2.5, -0.5, -1.5, -0.5, 2.0, 3.5, 4.5, 5.0]),
|
|
("Jazz", [3.0, 2.0, 1.0, 2.0, -1.0, -1.0, 0.0, 1.5, 2.5, 3.0]),
|
|
("Classic", [4.0, 3.0, 2.0, 1.0, -1.0, -1.0, 0.0, 2.0, 3.0, 4.0]),
|
|
];
|
|
|
|
#[derive(Clone, PartialEq)]
|
|
pub struct Eq {
|
|
// a preset's name, or "Custom" once a band is dragged
|
|
pub preset: String,
|
|
pub gains: [f32; 10],
|
|
}
|
|
|
|
impl Default for Eq {
|
|
fn default() -> Self {
|
|
Self { preset: String::from("Flat"), gains: [0.0; 10] }
|
|
}
|
|
}
|
|
|
|
// one player's sound
|
|
#[derive(Clone, PartialEq)]
|
|
pub struct Channel {
|
|
// the mpris bus name, and the same made safe for node names
|
|
pub player: String,
|
|
pub key: String,
|
|
|
|
// the player's own sink-inputs, before any equalizer
|
|
pub streams: Vec<u32>,
|
|
|
|
pub volume: u8,
|
|
pub muted: bool,
|
|
|
|
// the device the sound finally reaches
|
|
pub output: Option<Sink>,
|
|
|
|
// the output stream of the player's equalizer, once it has one
|
|
pub eq_output: Option<u32>,
|
|
}
|
|
|
|
/*
|
|
* per-player sound through pactl and pipewire: volume, output device, and a
|
|
* 10-band equalizer. a player gets its own filter-chain sink the first time
|
|
* its equalizer or output is changed; its streams are then moved into it, and
|
|
* the equalizer's output stream is what moves between devices
|
|
*/
|
|
#[derive(Default)]
|
|
pub struct Sound {
|
|
outputs: Vec<Sink>,
|
|
channels: Vec<Channel>,
|
|
|
|
// the sink new sound goes to (`pactl get-default-sink`)
|
|
default_output: String,
|
|
|
|
// by player identity, so "Cider" keeps its curve across restarts
|
|
eqs: BTreeMap<String, Eq>,
|
|
unsaved: bool,
|
|
|
|
// a dragged control wins over polls until pactl has caught up
|
|
held_until: Option<Instant>,
|
|
|
|
// equalizers launched but not yet seen as sinks, so they aren't launched twice
|
|
starting: BTreeMap<String, Instant>,
|
|
}
|
|
|
|
impl Service for Sound {
|
|
fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
// reads pactl outside the lock, so a slow sound server never stalls a frame
|
|
fn listen() {
|
|
let saved = fs::read_to_string(store_file()).unwrap_or_default();
|
|
|
|
Self::write().eqs = parse::eq_store(&saved).into_iter().map(|(identity, preset, gains)| (identity, Eq { preset, gains })).collect();
|
|
|
|
// equalizer sinks this shell has seen, so each gets the current gains once
|
|
let mut known = BTreeSet::new();
|
|
|
|
loop {
|
|
poll(&mut known);
|
|
save();
|
|
|
|
thread::sleep(Duration::from_secs(1));
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Sound {
|
|
pub fn outputs(&self) -> &[Sink] {
|
|
&self.outputs
|
|
}
|
|
|
|
pub fn default_output(&self) -> &str {
|
|
&self.default_output
|
|
}
|
|
|
|
// the first output whose pactl name or description contains `wanted`; none for an empty `wanted`
|
|
pub fn output_named(&self, wanted: &str) -> Option<&Sink> {
|
|
let wanted = wanted.to_lowercase();
|
|
|
|
(!wanted.is_empty())
|
|
.then(|| self.outputs.iter().find(|sink| sink.description.to_lowercase().contains(&wanted) || sink.name.to_lowercase().contains(&wanted)))
|
|
.flatten()
|
|
}
|
|
|
|
// a whole output's volume, unmuting it, as the control centre's sliders set it
|
|
pub fn set_output_volume(name: &str, volume: u8) {
|
|
let mut sound = Self::write();
|
|
|
|
sound.hold();
|
|
|
|
let Some(sink) = sound.outputs.iter_mut().find(|sink| sink.name == name) else {
|
|
return;
|
|
};
|
|
|
|
if sink.muted {
|
|
run(&["pactl", "set-sink-mute", name, "0"]);
|
|
}
|
|
|
|
run(&["pactl", "set-sink-volume", name, &format!("{volume}%")]);
|
|
|
|
sink.volume = volume;
|
|
sink.muted = false;
|
|
}
|
|
|
|
pub fn toggle_output_mute(name: &str) {
|
|
let mut sound = Self::write();
|
|
|
|
sound.hold();
|
|
|
|
let Some(sink) = sound.outputs.iter_mut().find(|sink| sink.name == name) else {
|
|
return;
|
|
};
|
|
|
|
sink.muted = !sink.muted;
|
|
|
|
run(&["pactl", "set-sink-mute", name, if sink.muted { "1" } else { "0" }]);
|
|
}
|
|
|
|
// where new sound goes; players without an output of their own follow it
|
|
pub fn set_default_output(name: &str) {
|
|
let mut sound = Self::write();
|
|
|
|
sound.hold();
|
|
sound.default_output = name.to_string();
|
|
|
|
run(&["pactl", "set-default-sink", name]);
|
|
}
|
|
|
|
pub fn channel(&self, player: &str) -> Option<&Channel> {
|
|
self.channels.iter().find(|channel| channel.player == player)
|
|
}
|
|
|
|
pub fn eq(&self, identity: &str) -> Eq {
|
|
self.eqs.get(identity).cloned().unwrap_or_default()
|
|
}
|
|
|
|
pub fn unsaved(&self) -> bool {
|
|
self.unsaved
|
|
}
|
|
|
|
fn held(&self) -> bool {
|
|
self.held_until.is_some_and(|until| Instant::now() < until)
|
|
}
|
|
|
|
fn hold(&mut self) {
|
|
self.held_until = Some(Instant::now() + Duration::from_millis(2500));
|
|
}
|
|
|
|
// from input handlers: sets and unmutes every stream of one player
|
|
pub fn set_volume(player: &str, volume: u8) {
|
|
let mut sound = Self::write();
|
|
|
|
sound.hold();
|
|
|
|
let Some(channel) = sound.channels.iter_mut().find(|channel| channel.player == player) else {
|
|
return;
|
|
};
|
|
|
|
if channel.volume == volume && !channel.muted {
|
|
return;
|
|
}
|
|
|
|
// ponytail: one pactl per step; a long-lived `pactl` pipe if drags ever stutter
|
|
for id in &channel.streams {
|
|
if channel.muted {
|
|
run(&["pactl", "set-sink-input-mute", &id.to_string(), "0"]);
|
|
}
|
|
|
|
run(&["pactl", "set-sink-input-volume", &id.to_string(), &format!("{volume}%")]);
|
|
}
|
|
|
|
channel.volume = volume;
|
|
channel.muted = false;
|
|
}
|
|
|
|
pub fn toggle_mute(player: &str) {
|
|
let mut sound = Self::write();
|
|
|
|
sound.hold();
|
|
|
|
let Some(channel) = sound.channels.iter_mut().find(|channel| channel.player == player) else {
|
|
return;
|
|
};
|
|
|
|
channel.muted = !channel.muted;
|
|
|
|
for id in &channel.streams {
|
|
run(&["pactl", "set-sink-input-mute", &id.to_string(), if channel.muted { "1" } else { "0" }]);
|
|
}
|
|
}
|
|
|
|
// moves one player's sound to another device, through its equalizer so new streams follow
|
|
pub fn set_output(player: &str, identity: &str, device: &str) {
|
|
let (key, eq_output) = {
|
|
let mut sound = Self::write();
|
|
|
|
sound.hold();
|
|
|
|
let chosen = sound.outputs.iter().find(|sink| sink.name == device).cloned();
|
|
|
|
let Some(channel) = sound.channels.iter_mut().find(|channel| channel.player == player) else {
|
|
return;
|
|
};
|
|
|
|
channel.output = chosen;
|
|
|
|
(channel.key.clone(), channel.eq_output)
|
|
};
|
|
|
|
match eq_output {
|
|
Some(stream) => run(&["pactl", "move-sink-input", &stream.to_string(), device]),
|
|
None => {
|
|
let gains = Self::read().eq(identity).gains;
|
|
|
|
start_eq(&key, identity, &gains, device);
|
|
}
|
|
}
|
|
}
|
|
|
|
// from a dragged equalizer band; half-dB steps keep pw-cli calls few
|
|
pub fn set_band(player: &str, identity: &str, band: usize, gain: f32) {
|
|
let gain = ((gain * 2.0).round() / 2.0).clamp(-MAX_GAIN, MAX_GAIN);
|
|
|
|
let gains = {
|
|
let mut sound = Self::write();
|
|
|
|
let eq = sound.eqs.entry(identity.to_string()).or_default();
|
|
|
|
if eq.gains[band] == gain {
|
|
return;
|
|
}
|
|
|
|
eq.gains[band] = gain;
|
|
eq.preset = String::from("Custom");
|
|
|
|
let gains = eq.gains;
|
|
|
|
sound.unsaved = true;
|
|
|
|
gains
|
|
};
|
|
|
|
apply(player, identity, &gains);
|
|
}
|
|
|
|
// false when there is no preset by that name
|
|
pub fn preset(player: &str, identity: &str, name: &str) -> bool {
|
|
let Some((preset, gains)) = EQ_PRESETS.iter().find(|(preset, _)| preset.eq_ignore_ascii_case(name)) else {
|
|
return false;
|
|
};
|
|
|
|
{
|
|
let mut sound = Self::write();
|
|
|
|
sound.eqs.insert(identity.to_string(), Eq { preset: preset.to_string(), gains: *gains });
|
|
sound.unsaved = true;
|
|
}
|
|
|
|
apply(player, identity, gains);
|
|
|
|
true
|
|
}
|
|
}
|
|
|
|
// pushes gains to the player's running equalizer, or starts one with them
|
|
fn apply(player: &str, identity: &str, gains: &[f32; 10]) {
|
|
let (running, target) = {
|
|
let sound = Sound::read();
|
|
|
|
let channel = sound.channel(player);
|
|
|
|
let running = channel.is_some_and(|channel| channel.eq_output.is_some());
|
|
let target = channel.and_then(|channel| channel.output.as_ref()).map_or(String::new(), |sink| sink.name.clone());
|
|
|
|
(running, target)
|
|
};
|
|
|
|
let key = parse::key_of(player);
|
|
|
|
if running {
|
|
run(&["pw-cli", "set-param", &format!("{EQ_PREFIX}{key}"), "Props", &parse::eq_params(gains)]);
|
|
} else {
|
|
start_eq(&key, identity, gains, &target);
|
|
}
|
|
}
|
|
|
|
fn poll(known: &mut BTreeSet<String>) {
|
|
let sinks = parse::sinks(&amane::output("LC_ALL=C pactl list sinks"));
|
|
let streams = parse::sink_inputs(&amane::output("LC_ALL=C pactl list sink-inputs"));
|
|
|
|
// copied out, so Media isn't held while this thread waits on d-bus and /proc
|
|
let players: Vec<(String, String)> =
|
|
Media::read().players().iter().map(|player| (player.name().to_string(), player.identity().to_string())).collect();
|
|
|
|
let eqs = Sound::read().eqs.clone();
|
|
|
|
// the equalizers' own output streams belong to no player
|
|
let (eq_streams, app_streams): (Vec<Stream>, Vec<Stream>) =
|
|
streams.into_iter().partition(|stream| stream.node_name.starts_with(EQ_PREFIX));
|
|
|
|
let mut channels = Vec::new();
|
|
|
|
for (player, identity) in players {
|
|
let key = parse::key_of(&player);
|
|
|
|
let eq_name = format!("{EQ_PREFIX}{key}");
|
|
let eq_sink = sinks.iter().find(|sink| sink.name == eq_name);
|
|
let eq_output = eq_streams.iter().find(|stream| stream.node_name == format!("{eq_name}.out"));
|
|
|
|
let owned = parse::owned(&app_streams, process_of(&player), &parse::app_of(&player), ancestry);
|
|
|
|
let device_of = |stream: &&Stream| sinks.iter().find(|sink| sink.index == stream.sink);
|
|
|
|
match eq_sink {
|
|
Some(eq_sink) => {
|
|
// first sight of this equalizer (just launched, or left by an earlier shell): give it the current curve
|
|
if known.insert(key.clone()) {
|
|
Sound::write().starting.remove(&key);
|
|
|
|
if let Some(eq) = eqs.get(&identity) {
|
|
run(&["pw-cli", "set-param", &eq_name, "Props", &parse::eq_params(&eq.gains)]);
|
|
}
|
|
}
|
|
|
|
// every stream the player opens plays through its equalizer
|
|
for stream in owned.iter().filter(|stream| stream.sink != eq_sink.index) {
|
|
run(&["pactl", "move-sink-input", &stream.id.to_string(), &eq_name]);
|
|
}
|
|
}
|
|
None => {
|
|
known.remove(&key);
|
|
|
|
// a saved curve comes back with its player
|
|
if let Some(eq) = eqs.get(&identity).filter(|eq| eq.gains != [0.0; 10]) {
|
|
let target = owned.first().and_then(device_of).map_or("", |sink| sink.name.as_str());
|
|
|
|
start_eq(&key, &identity, &eq.gains, target);
|
|
}
|
|
}
|
|
}
|
|
|
|
// where the sound ends up: through the equalizer to its output, or straight to a device
|
|
let routed = eq_sink.is_some_and(|eq_sink| owned.iter().any(|stream| stream.sink == eq_sink.index));
|
|
|
|
let output = match eq_output {
|
|
Some(eq_output) if routed => sinks.iter().find(|sink| sink.index == eq_output.sink),
|
|
_ => owned.first().and_then(device_of),
|
|
};
|
|
|
|
channels.push(Channel {
|
|
output: output.cloned(),
|
|
eq_output: eq_output.map(|stream| stream.id),
|
|
streams: owned.iter().map(|stream| stream.id).collect(),
|
|
volume: owned.first().map_or(100, |stream| stream.volume),
|
|
muted: !owned.is_empty() && owned.iter().all(|stream| stream.muted),
|
|
player,
|
|
key,
|
|
});
|
|
}
|
|
|
|
// an equalizer whose player has gone
|
|
for sink in &sinks {
|
|
if let Some(key) = sink.name.strip_prefix(EQ_PREFIX)
|
|
&& !channels.iter().any(|channel| channel.key == key)
|
|
{
|
|
run(&["pkill", "-f", &eq_file(key).display().to_string()]);
|
|
}
|
|
}
|
|
|
|
let outputs: Vec<Sink> = sinks.into_iter().filter(|sink| !sink.name.starts_with(EQ_PREFIX)).collect();
|
|
|
|
let default_output = amane::output("pactl get-default-sink");
|
|
|
|
let stale = {
|
|
let sound = Sound::read();
|
|
|
|
!sound.held() && (sound.channels != channels || sound.outputs != outputs || sound.default_output != default_output)
|
|
};
|
|
|
|
if stale {
|
|
let mut sound = Sound::write();
|
|
|
|
sound.channels = channels;
|
|
sound.outputs = outputs;
|
|
sound.default_output = default_output;
|
|
}
|
|
}
|
|
|
|
/*
|
|
* a standalone filter-chain process in its own session, so the sound keeps
|
|
* flowing while the shell restarts; the next shell finds it by node name
|
|
*/
|
|
fn start_eq(key: &str, identity: &str, gains: &[f32; 10], target: &str) {
|
|
{
|
|
let mut sound = Sound::write();
|
|
|
|
if sound.starting.get(key).is_some_and(|since| since.elapsed() < Duration::from_secs(5)) {
|
|
return;
|
|
}
|
|
|
|
sound.starting.insert(key.to_string(), Instant::now());
|
|
}
|
|
|
|
let config = eq_file(key);
|
|
|
|
if fs::write(&config, parse::eq_config(key, identity, gains, target)).is_ok() {
|
|
run(&["setsid", "-f", "pipewire", "-c", &config.display().to_string()]);
|
|
}
|
|
}
|
|
|
|
fn eq_file(key: &str) -> PathBuf {
|
|
art::folder().join(format!("eq-{key}.conf"))
|
|
}
|
|
|
|
// saved equalizers live with the shell's config, so they outlast logouts
|
|
fn store_file() -> PathBuf {
|
|
theme::config_file("island-eq")
|
|
}
|
|
|
|
fn save() {
|
|
let lines: String = {
|
|
let sound = Sound::read();
|
|
|
|
if !sound.unsaved {
|
|
return;
|
|
}
|
|
|
|
sound.eqs.iter().map(|(identity, eq)| parse::eq_line(identity, &eq.preset, &eq.gains)).collect()
|
|
};
|
|
|
|
// ponytail: a change landing between this read and the write below is saved on the next change
|
|
if fs::write(store_file(), lines).is_ok() {
|
|
Sound::write().unsaved = false;
|
|
}
|
|
}
|
|
|
|
// no shell: device and player names come from other programs and must never run as commands
|
|
fn run(command: &[&str]) {
|
|
let Some((program, arguments)) = command.split_first() else {
|
|
return;
|
|
};
|
|
|
|
let child = Command::new(program).args(arguments).stdout(Stdio::null()).stderr(Stdio::null()).spawn();
|
|
|
|
// a finished child nobody waits for stays behind as a zombie
|
|
if let Ok(mut child) = child {
|
|
thread::spawn(move || child.wait());
|
|
}
|
|
}
|
|
|
|
// the pid behind a bus name, 0 when d-bus can't tell
|
|
fn process_of(bus_name: &str) -> u32 {
|
|
let pid = Bus::session().call(
|
|
"org.freedesktop.DBus",
|
|
"/org/freedesktop/DBus",
|
|
"org.freedesktop.DBus",
|
|
"GetConnectionUnixProcessID",
|
|
&[Argument::from(bus_name)],
|
|
);
|
|
|
|
pid.number() as u32
|
|
}
|
|
|
|
// a process and its parents, up to init
|
|
fn ancestry(mut pid: u32) -> Vec<u32> {
|
|
let mut chain = Vec::new();
|
|
|
|
while pid > 1 && chain.len() < 32 {
|
|
chain.push(pid);
|
|
|
|
let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) else {
|
|
break;
|
|
};
|
|
|
|
let Some(parent) = parse::parent_of(&stat) else {
|
|
break;
|
|
};
|
|
|
|
pid = parent;
|
|
}
|
|
|
|
chain
|
|
}
|