commit 1b757150ea11c4afe66079847c9bb0b3e993d2df Author: Moritz Ruth Date: Thu Sep 12 22:25:20 2024 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..40d9aca --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +/.idea \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..9857828 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,531 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5fb1d8e4442bd405fdfd1dacb42792696b0cf9cb15882e5d097b742a676d375" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "arc-swap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" + +[[package]] +name = "autocfg" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" + +[[package]] +name = "backtrace" +version = "0.3.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitmaps" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031043d04099746d8db04daf1fa424b2bc8bd69d92b25962dcde24da39ab64a2" +dependencies = [ + "typenum", +] + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "flume" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55ac459de2512911e4b674ce33cf20befaba382d05b62b008afc1c8b57cbf181" +dependencies = [ + "futures-core", + "futures-sink", + "nanorand", + "spin", +] + +[[package]] +name = "futures-core" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" + +[[package]] +name = "futures-sink" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32085ea23f3234fc7846555e85283ba4de91e21016dc0455a16286d87a292d64" + +[[package]] +name = "im" +version = "15.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0acd33ff0285af998aaf9b57342af478078f53492322fafc47450e09397e0e9" +dependencies = [ + "bitmaps", + "rand_core", + "rand_xoshiro", + "sized-chunks", + "typenum", + "version_check", +] + +[[package]] +name = "js-sys" +version = "0.3.70" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1868808506b929d7b0cfa8f75951347aa71bb21144b7791bae35d9bccfcfe37a" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.158" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439" + +[[package]] +name = "libpulse-binding" +version = "2.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3557a2dfc380c8f061189a01c6ae7348354e0c9886038dc6c171219c08eaff" +dependencies = [ + "bitflags", + "libc", + "libpulse-sys", + "num-derive", + "num-traits", + "winapi", +] + +[[package]] +name = "libpulse-sys" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc19e110fbf42c17260d30f6d3dc545f58491c7830d38ecb9aaca96e26067a9b" +dependencies = [ + "libc", + "num-derive", + "num-traits", + "pkg-config", + "winapi", +] + +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "miniz_oxide" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" +dependencies = [ + "adler2", +] + +[[package]] +name = "nanorand" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" +dependencies = [ + "getrandom", +] + +[[package]] +name = "num-derive" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876a53fff98e03a936a674b29568b0e605f06b29372c2489ff4de23f1949743d" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "084f1a5821ac4c651660a94a7153d27ac9d8a53736203f58b31945ded098070a" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "pin-project-lite" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" + +[[package]] +name = "pkg-config" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" + +[[package]] +name = "proc-macro2" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pulseaudio-volume-interface" +version = "1.0.0" +dependencies = [ + "arc-swap", + "flume", + "im", + "libpulse-binding", + "log", + "tokio", +] + +[[package]] +name = "quote" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rand_xoshiro" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sized-chunks" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16d69225bde7a69b235da73377861095455d298f2b970996eec25ddbb42b3d1e" +dependencies = [ + "bitmaps", + "typenum", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f35bcdf61fd8e7be6caf75f429fdca8beb3ed76584befb503b1569faee373ed" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tokio" +version = "1.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998" +dependencies = [ + "backtrace", + "pin-project-lite", +] + +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "unicode-ident" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5" +dependencies = [ + "cfg-if", + "once_cell", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.77", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..1a01966 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "pulseaudio-volume-interface" +version = "1.0.0" +edition = "2021" + +[dependencies] +flume = "0.11.0" +im = "15.1.0" +tokio = { version = "1.38.0", default-features = false, features = ["sync"] } +libpulse-binding = "2.28.1" +log = "0.4.21" +arc-swap = "1.7.1" \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..7a84c0d --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,55 @@ +# Blue Oak Model License + +Version 1.0.0 + +## Purpose + +This license gives everyone as much permission to work with +this software as possible, while protecting contributors +from liability. + +## Acceptance + +In order to receive this license, you must agree to its +rules. The rules of this license are both obligations +under that agreement and conditions to your license. +You must not do anything with this software that triggers +a rule that you cannot or will not follow. + +## Copyright + +Each contributor licenses you to do everything with this +software that would otherwise infringe that contributor's +copyright in it. + +## Notices + +You must ensure that everyone who gets a copy of +any part of this software from you, with or without +changes, also gets the text of this license or a link to +. + +## Excuse + +If anyone notifies you in writing that you have not +complied with [Notices](#notices), you can keep your +license by taking all practical steps to comply within 30 +days after the notice. If you do not do so, your license +ends immediately. + +## Patent + +Each contributor licenses you to do everything with this +software that would otherwise infringe any patent claims +they can license or become able to license. + +## Reliability + +No contributor can revoke this license. + +## No Liability + +***As far as the law allows, this software comes as is, +without any warranty or condition, and no contributor +will be liable to anyone for any damages related to this +software or this license, under any kind of legal claim.*** \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..3e113e8 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# pulseaudio-volume-interface +> Rust library for observing and modifying PulseAudio source, sink, and sink input volumes. Also allows to set the default source and sink. + diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..1cbd329 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1 @@ +max_width = 160 \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..1cbb228 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,208 @@ +mod worker; + +use std::num::NonZeroU32; +use crate::worker::*; +use arc_swap::ArcSwap; +use im::HashMap; +use libpulse_binding::context::introspect::{SinkInfo, SinkInputInfo, SourceInfo}; +use libpulse_binding::volume::{ChannelVolumes, Volume}; +use std::sync::Arc; +use std::time::Instant; +use tokio::sync::broadcast; +use tokio::sync::broadcast::Receiver; + +#[allow(unused_imports)] +use libpulse_binding::mainloop::api::Mainloop as _; + +pub type EntityId = NonZeroU32; + +#[derive(Debug, Eq, PartialEq, Copy, Clone)] +pub enum EntityKind { + Source, + Sink, + SinkInput, +} + +#[derive(Debug, Eq, PartialEq, Clone)] +pub enum EntityMetadata { + Source { + name: String, + description: String, + }, + Sink { + name: String, + description: String, + }, + SinkInput { + description: String, + binary_name: Option, + application_name: Option, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct EntityState { + id: EntityId, + is_muted: bool, + channel_volumes: ChannelVolumes, + metadata: EntityMetadata, +} + +impl EntityState { + pub fn id(&self) -> &EntityId { + &self.id + } + + pub fn is_muted(&self) -> bool { + self.is_muted + } + + pub fn channel_volumes(&self) -> Vec { + self.channel_volumes.get().iter().map(|v| v.0 as f32 / Volume::NORMAL.0 as f32).collect() + } + + pub fn metadata(&self) -> &EntityMetadata { + &self.metadata + } + + pub fn kind(&self) -> EntityKind { + match &self.metadata { + EntityMetadata::Source { .. } => EntityKind::Source, + EntityMetadata::Sink { .. } => EntityKind::Sink, + EntityMetadata::SinkInput { .. } => EntityKind::SinkInput, + } + } +} + +impl From<&SourceInfo<'_>> for EntityState { + fn from(value: &SourceInfo) -> Self { + EntityState { + id: NonZeroU32::new(value.index).unwrap(), + is_muted: value.mute, + channel_volumes: value.volume, + metadata: EntityMetadata::Source { + name: value.name.clone().unwrap_or_default().into_owned(), + description: value.description.clone().unwrap_or_default().into_owned(), + }, + } + } +} + +impl From<&SinkInfo<'_>> for EntityState { + fn from(value: &SinkInfo) -> Self { + EntityState { + id: NonZeroU32::new(value.index).unwrap(), + is_muted: value.mute, + channel_volumes: value.volume, + metadata: EntityMetadata::Sink { + name: value.name.clone().unwrap_or_default().into_owned(), + description: value.description.clone().unwrap_or_default().into_owned(), + }, + } + } +} + +impl From<&SinkInputInfo<'_>> for EntityState { + fn from(value: &SinkInputInfo) -> Self { + EntityState { + id: NonZeroU32::new(value.index).unwrap(), + is_muted: value.mute, + channel_volumes: value.volume, + metadata: EntityMetadata::SinkInput { + description: value.name.clone().unwrap_or_default().into_owned(), + application_name: value + .proplist + .get("application.name") + .map(|v| String::from_utf8_lossy(v).trim_end_matches(char::from(0)).to_owned()), + binary_name: value + .proplist + .get("application.process.binary") + .map(|v| String::from_utf8_lossy(v).trim_end_matches(char::from(0)).to_owned()), + }, + } + } +} + +#[derive(Debug, Clone)] +pub struct State { + timestamp: Instant, + default_sink_id: Option, + default_source_id: Option, + entities_by_id: HashMap>, +} + +impl State { + pub fn timestamp(&self) -> &Instant { + &self.timestamp + } + + pub fn default_sink_id(&self) -> &Option { &self.default_sink_id } + + pub fn default_source_id(&self) -> &Option { &self.default_source_id } + + pub fn entities_by_id(&self) -> &HashMap> { + &self.entities_by_id + } +} + +#[derive(Debug, Clone)] +pub struct PulseAudioVolumeInterface { + #[allow(unused)] + worker: Arc, + current_state: Arc>, + state_tx: broadcast::Sender>, + commands_tx: flume::Sender, +} + +impl PulseAudioVolumeInterface { + pub fn new(client_name: String) -> PulseAudioVolumeInterface { + let (commands_tx, commands_rx) = flume::unbounded(); + let state_tx = broadcast::Sender::new(5); + let current_state = Arc::new(ArcSwap::new(Arc::new(State { + timestamp: Instant::now(), + default_sink_id: None, + default_source_id: None, + entities_by_id: HashMap::new(), + }))); + + PaThread::spawn(client_name, commands_tx.clone(), commands_rx, state_tx.clone(), Arc::clone(¤t_state)); + + let worker = PaWorker { + commands_tx: commands_tx.clone(), + }; + + PulseAudioVolumeInterface { + worker: Arc::new(worker), + current_state, + commands_tx, + state_tx, + } + } + + pub fn subscribe_to_state(&self) -> (Arc, Receiver>) { + let rx = self.state_tx.subscribe(); + let state = self.current_state(); + (state, rx) + } + + pub fn current_state(&self) -> Arc { + Arc::clone(&self.current_state.load()) + } + + pub fn set_is_muted(&self, id: EntityId, value: bool) { + self.commands_tx.send(ThreadMessage::SetIsMuted { id, value }).unwrap() + } + + pub fn set_channel_volumes(&self, id: EntityId, channel_volumes: impl Into>) { + self.commands_tx + .send(ThreadMessage::SetChannelVolumes { + id, + channel_volumes: channel_volumes.into(), + }) + .unwrap() + } + + pub fn set_default_entity(&self, id: EntityId) { + self.commands_tx.send(ThreadMessage::SetDefaultEntity { id }).unwrap() + } +} diff --git a/src/worker.rs b/src/worker.rs new file mode 100644 index 0000000..e8d4154 --- /dev/null +++ b/src/worker.rs @@ -0,0 +1,370 @@ +use crate::{EntityId, EntityKind, EntityMetadata, EntityState, State}; +use arc_swap::ArcSwap; +use libpulse_binding::callbacks::ListResult; +use libpulse_binding::context::introspect::{Introspector, ServerInfo}; +use libpulse_binding::context::subscribe::{Facility, InterestMaskSet}; +use libpulse_binding::context::{subscribe, Context, FlagSet, State as ConnectionState}; +use libpulse_binding::def::Retval; +use libpulse_binding::mainloop::api::Mainloop as _; +use libpulse_binding::mainloop::threaded::Mainloop; +use libpulse_binding::volume::Volume; +use std::num::NonZeroU32; +use std::sync::Arc; +use std::thread; +use std::time::Instant; +use tokio::sync::broadcast; + +#[derive(Debug)] +pub(super) enum ThreadMessage { + // emitted by the library user + SetIsMuted { + id: EntityId, + value: bool, + }, + SetChannelVolumes { + id: EntityId, + channel_volumes: Box<[f32]>, + }, + SetDefaultEntity { + id: EntityId, + }, + Terminate, + + // emitted by libpulse + LoadServerInfo, + LoadSinkInput { + entity_id: EntityId, + }, + LoadSink { + entity_id: EntityId, + }, + LoadSource { + entity_id: EntityId, + }, + + // emitted by handlers for the messages above + HandleServerInfo { + default_source_name: Option>, + default_sink_name: Option>, + }, + UpsertEntity { + entity_state: Arc, + }, + RemoveEntity { + entity_id: EntityId, + }, +} + +pub(super) struct PaThread { + mainloop: Mainloop, + context: Context, + introspector: Introspector, + commands_tx: flume::Sender, + commands_rx: flume::Receiver, + state_tx: broadcast::Sender>, + current_state: Arc>, +} + +impl PaThread { + pub(super) fn spawn( + client_name: String, + commands_tx: flume::Sender, + commands_rx: flume::Receiver, + state_tx: broadcast::Sender>, + current_state: Arc>, + ) { + thread::spawn(move || { + let mut mainloop = Mainloop::new().unwrap(); + let context = Context::new(&mainloop, &client_name).unwrap(); + let introspector = context.introspect(); + + log::debug!("Starting the mainloop thread…"); + mainloop.start().expect("starting the mainloop never fails"); + + let mut t = PaThread { + mainloop, + context, + introspector, + commands_tx, + commands_rx, + state_tx, + current_state, + }; + + t.init(); + t.run(); + }); + } + + fn init(&mut self) { + log::debug!("Initializing…"); + self.mainloop.lock(); + self.context.connect(None, FlagSet::NOFLAGS, None).unwrap(); + + { + let (context_state_change_tx, context_state_change_rx) = flume::bounded(1); + self.context.set_state_callback(Some(Box::new(move || { + context_state_change_tx.send(()).unwrap(); + }))); + + self.mainloop.unlock(); + loop { + context_state_change_rx.recv().unwrap(); + + self.mainloop.lock(); + if self.context.get_state() == ConnectionState::Ready { + break; + } + self.mainloop.unlock(); + } + } + + // Mainloop is still locked + + { + let commands_tx = self.commands_tx.clone(); + self.introspector.get_sink_input_info_list(move |list_result| match list_result { + ListResult::Error => panic!("Introspector.get_sink_input_info_list failed"), + ListResult::End => {} + ListResult::Item(sink_input) => commands_tx + .send(ThreadMessage::UpsertEntity { + entity_state: Arc::new(sink_input.into()), + }) + .unwrap(), + }); + } + + { + let commands_tx = self.commands_tx.clone(); + self.introspector.get_sink_info_list(move |list_result| match list_result { + ListResult::Error => panic!("Introspector.get_sink_info_list failed"), + ListResult::End => {} + ListResult::Item(sink) => commands_tx + .send(ThreadMessage::UpsertEntity { + entity_state: Arc::new(sink.into()), + }) + .unwrap(), + }); + } + + { + let commands_tx = self.commands_tx.clone(); + self.introspector.get_source_info_list(move |list_result| match list_result { + ListResult::Error => panic!("Introspector.get_source_info_list failed"), + ListResult::End => {} + ListResult::Item(source) => commands_tx + .send(ThreadMessage::UpsertEntity { + entity_state: Arc::new(source.into()), + }) + .unwrap(), + }); + } + + { + let commands_tx = self.commands_tx.clone(); + self.context.set_subscribe_callback(Some(Box::new(move |facility, operation, entity_id| { + let entity_id = NonZeroU32::new(entity_id).unwrap(); + let facility = facility.unwrap(); + + if facility == Facility::Server { + commands_tx.send(ThreadMessage::LoadServerInfo).unwrap(); + } else { + match operation.unwrap() { + subscribe::Operation::Removed => { + commands_tx.send(ThreadMessage::RemoveEntity { entity_id }).unwrap(); + } + subscribe::Operation::New | subscribe::Operation::Changed => { + match facility { + Facility::SinkInput => commands_tx.send(ThreadMessage::LoadSinkInput { entity_id }).unwrap(), + Facility::Sink => commands_tx.send(ThreadMessage::LoadSink { entity_id }).unwrap(), + Facility::Source => commands_tx.send(ThreadMessage::LoadSource { entity_id }).unwrap(), + _ => {} + }; + } + }; + } + }))); + } + + self.context + .subscribe(InterestMaskSet::SERVER | InterestMaskSet::SINK | InterestMaskSet::SOURCE | InterestMaskSet::SINK_INPUT, |success| { + if !success { + panic!("Context.subscribe failed") + } + }); + + self.mainloop.unlock(); + } + + fn run(mut self) { + log::debug!("Waiting for commands…"); + + 'outer: loop { + while let Ok(command) = self.commands_rx.recv() { + self.mainloop.lock(); + let commands_tx = self.commands_tx.clone(); + + match command { + ThreadMessage::Terminate => { + break 'outer; + } + ThreadMessage::SetIsMuted { id, value } => { + if let Some(state) = self.current_state.load().entities_by_id.get(&id) { + match state.kind() { + EntityKind::Sink => self.introspector.set_sink_mute_by_index(id.into(), value, None), + EntityKind::Source => self.introspector.set_source_mute_by_index(id.into(), value, None), + EntityKind::SinkInput => self.introspector.set_sink_input_mute(id.into(), value, None), + }; + } + } + ThreadMessage::SetChannelVolumes { id, channel_volumes } => { + if let Some(state) = self.current_state.load().entities_by_id.get(&id) { + let mut value = state.channel_volumes; + for (i, v) in channel_volumes.iter().enumerate() { + value.set(i as u8, Volume((Volume::NORMAL.0 as f32 * v).floor() as u32)); + } + + match state.kind() { + EntityKind::Sink => self.introspector.set_sink_volume_by_index(id.into(), &value, None), + EntityKind::Source => self.introspector.set_source_volume_by_index(id.into(), &value, None), + EntityKind::SinkInput => self.introspector.set_sink_input_volume(id.into(), &value, None), + }; + } + } + ThreadMessage::SetDefaultEntity { id } => { + if let Some(state) = self.current_state.load().entities_by_id.get(&id) { + match &state.metadata { + EntityMetadata::Sink { name, .. } => { + self.context.set_default_sink(name, |_| {}); + } + EntityMetadata::Source { name, .. } => { + self.context.set_default_source(name, |_| {}); + } + _ => { + log::error!("Setting default entity failed. Only sinks and sources can be defaults."); + } + }; + } else { + log::error!("Setting default entity failed. Unknown entity id: {id}"); + } + } + + ThreadMessage::LoadServerInfo => { + self.introspector.get_server_info(move |server_info| { + commands_tx.send(ThreadMessage::HandleServerInfo { + default_sink_name: server_info.default_sink_name.as_ref().map(|n| n.to_string().into_boxed_str()), + default_source_name: server_info.default_source_name.as_ref().map(|n| n.to_string().into_boxed_str()), + }).unwrap(); + }); + } + ThreadMessage::LoadSinkInput { entity_id } => { + self.introspector.get_sink_input_info(entity_id.into(), move |list_result| match list_result { + ListResult::Error => panic!("Introspector.get_sink_input_info failed"), + ListResult::End => {} + ListResult::Item(sink_input) => commands_tx + .send(ThreadMessage::UpsertEntity { + entity_state: Arc::new(sink_input.into()), + }) + .unwrap(), + }); + } + ThreadMessage::LoadSink { entity_id } => { + self.introspector + .get_sink_info_by_index(entity_id.into(), move |list_result| match list_result { + ListResult::Error => panic!("Introspector.get_sink_info_by_index failed"), + ListResult::End => {} + ListResult::Item(sink) => commands_tx + .send(ThreadMessage::UpsertEntity { + entity_state: Arc::new(sink.into()), + }) + .unwrap(), + }); + } + ThreadMessage::LoadSource { entity_id } => { + self.introspector + .get_source_info_by_index(entity_id.into(), move |list_result| match list_result { + ListResult::Error => panic!("Introspector.get_source_info_by_index failed"), + ListResult::End => {} + ListResult::Item(source) => commands_tx + .send(ThreadMessage::UpsertEntity { + entity_state: Arc::new(source.into()), + }) + .unwrap(), + }); + } + + ThreadMessage::HandleServerInfo { default_sink_name, default_source_name} => { + let current_state = self.current_state.load(); + self.set_state(Arc::new(State { + timestamp: Instant::now(), + default_source_id: default_source_name + .map(|search_name| { + current_state + .entities_by_id + .iter() + .find(|(_, e)| match &e.metadata { + EntityMetadata::Source { name, .. } => *name == *search_name, + _ => false, + }) + .map(|(id, _)| (*id).into()) + }) + .flatten(), + default_sink_id: default_sink_name + .map(|search_name| { + current_state + .entities_by_id + .iter() + .find(|(_, e)| match &e.metadata { + EntityMetadata::Sink { name, .. } => *name == *search_name, + _ => false, + }) + .map(|(id, _)| (*id).into()) + }) + .flatten(), + entities_by_id: current_state.entities_by_id.clone(), + })); + } + ThreadMessage::UpsertEntity { entity_state } => { + let current_state = self.current_state.load(); + self.set_state(Arc::new(State { + timestamp: Instant::now(), + entities_by_id: current_state.entities_by_id.update(entity_state.id, entity_state), + ..**current_state + })); + } + ThreadMessage::RemoveEntity { entity_id } => { + let current_state = self.current_state.load(); + self.set_state(Arc::new(State { + timestamp: Instant::now(), + entities_by_id: current_state.entities_by_id.without(&entity_id), + ..**current_state + })); + } + } + + self.mainloop.unlock(); + } + } + + self.mainloop.quit(Retval(0)); + self.mainloop.unlock(); + } + + fn set_state(&self, value: Arc) { + self.current_state.store(Arc::clone(&value)); + + // If there are no subscribers, that’s ok. + _ = self.state_tx.send(value); + } +} + +#[derive(Debug)] +pub(super) struct PaWorker { + pub(super) commands_tx: flume::Sender, +} + +impl Drop for PaWorker { + fn drop(&mut self) { + self.commands_tx.send(ThreadMessage::Terminate).ok(); + } +}