diff --git a/Cargo.lock b/Cargo.lock index 236537616..94dfd6e79 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -228,12 +228,12 @@ dependencies = [ [[package]] name = "annotate-snippets" -version = "0.9.2" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccaf7e9dfbb6ab22c82e473cd1a8a7bd313c19a5b7e40970f3d89ef5a5c9e81e" +checksum = "710e8eae58854cdc1790fcb56cca04d712a17be849eeb81da2a724bf4bae2bc4" dependencies = [ - "unicode-width", - "yansi-term", + "anstyle", + "unicode-width 0.2.2", ] [[package]] @@ -743,21 +743,19 @@ dependencies = [ [[package]] name = "bindgen" -version = "0.69.5" +version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ "annotate-snippets", "bitflags 2.10.0", "cexpr", "clang-sys", - "itertools 0.12.1", - "lazy_static", - "lazycell", + "itertools 0.13.0", "proc-macro2", "quote", "regex", - "rustc-hash 1.1.0", + "rustc-hash 2.1.1", "shlex", "syn 2.0.110", ] @@ -1132,7 +1130,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" dependencies = [ "smallvec", - "target-lexicon", + "target-lexicon 0.12.16", +] + +[[package]] +name = "cfg-expr" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9acd0bdbbf4b2612d09f52ba61da432140cb10930354079d0d53fafc12968726" +dependencies = [ + "smallvec", + "target-lexicon 0.13.3", ] [[package]] @@ -1302,7 +1310,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" dependencies = [ "termcolor", - "unicode-width", + "unicode-width 0.1.14", ] [[package]] @@ -1422,9 +1430,9 @@ dependencies = [ [[package]] name = "convert_case" -version = "0.6.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" dependencies = [ "unicode-segmentation", ] @@ -1434,9 +1442,6 @@ name = "cookie-factory" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" -dependencies = [ - "futures", -] [[package]] name = "core-foundation" @@ -1527,7 +1532,7 @@ dependencies = [ [[package]] name = "cosmic-config" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic#2c93a4094fe331726ad0f7b80ae00a28f856543b" +source = "git+https://github.com/pop-os/libcosmic#fc85fcac3e1d1b7a05982e7396a8bc23ff4d0143" dependencies = [ "atomicwrites", "cosmic-config-derive", @@ -1548,7 +1553,7 @@ dependencies = [ [[package]] name = "cosmic-config-derive" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic#2c93a4094fe331726ad0f7b80ae00a28f856543b" +source = "git+https://github.com/pop-os/libcosmic#fc85fcac3e1d1b7a05982e7396a8bc23ff4d0143" dependencies = [ "quote", "syn 2.0.110", @@ -1622,6 +1627,19 @@ dependencies = [ "xdg-shell-wrapper-config", ] +[[package]] +name = "cosmic-pipewire" +version = "1.0.0-beta6" +dependencies = [ + "intmap", + "libspa", + "libspa-sys", + "pipewire", + "serde", + "serde_json", + "tracing", +] + [[package]] name = "cosmic-protocols" version = "0.1.0" @@ -1665,7 +1683,7 @@ dependencies = [ [[package]] name = "cosmic-settings" -version = "0.1.0" +version = "1.0.0-beta6" dependencies = [ "accounts-zbus", "anyhow", @@ -1747,7 +1765,7 @@ dependencies = [ [[package]] name = "cosmic-settings-a11y-manager-subscription" -version = "0.1.0" +version = "1.0.0-beta6" dependencies = [ "cosmic-protocols", "iced_futures", @@ -1760,7 +1778,7 @@ dependencies = [ [[package]] name = "cosmic-settings-accessibility-subscription" -version = "0.1.0" +version = "1.0.0-beta6" dependencies = [ "cosmic-dbus-a11y", "futures", @@ -1772,7 +1790,7 @@ dependencies = [ [[package]] name = "cosmic-settings-airplane-mode-subscription" -version = "0.1.0" +version = "1.0.0-beta6" dependencies = [ "futures", "iced_futures", @@ -1783,7 +1801,7 @@ dependencies = [ [[package]] name = "cosmic-settings-bluetooth-subscription" -version = "0.1.0" +version = "1.0.0-beta6" dependencies = [ "bluez-zbus", "futures", @@ -1825,7 +1843,7 @@ dependencies = [ [[package]] name = "cosmic-settings-daemon-subscription" -version = "0.1.0" +version = "1.0.0-beta6" dependencies = [ "futures", "iced_futures", @@ -1837,7 +1855,7 @@ dependencies = [ [[package]] name = "cosmic-settings-network-manager-subscription" -version = "0.1.0" +version = "1.0.0-beta6" dependencies = [ "cosmic-dbus-networkmanager", "futures", @@ -1852,7 +1870,7 @@ dependencies = [ [[package]] name = "cosmic-settings-page" -version = "0.1.0" +version = "1.0.0-beta6" dependencies = [ "derive_setters", "downcast-rs 2.0.2", @@ -1865,15 +1883,15 @@ dependencies = [ [[package]] name = "cosmic-settings-sound-subscription" -version = "1.0.0-beta1" +version = "1.0.0-beta6" dependencies = [ - "async-fn-stream", + "cosmic-pipewire", + "crossbeam-queue", "futures", - "indexmap 2.12.0", + "intmap", "libcosmic", - "libpulse-binding", "log", - "pipewire", + "numtoa", "rustix 1.1.2", "tokio", "tracing", @@ -1881,7 +1899,7 @@ dependencies = [ [[package]] name = "cosmic-settings-system" -version = "0.1.0" +version = "1.0.0-beta6" dependencies = [ "bumpalo", "byte-unit", @@ -1893,7 +1911,7 @@ dependencies = [ [[package]] name = "cosmic-settings-upower-subscription" -version = "0.1.0" +version = "1.0.0-beta6" dependencies = [ "futures", "iced_futures", @@ -1906,7 +1924,7 @@ dependencies = [ [[package]] name = "cosmic-settings-wallpaper" -version = "0.1.0" +version = "1.0.0-beta6" dependencies = [ "cosmic-bg-config", "cosmic-randr-shell", @@ -1949,7 +1967,7 @@ dependencies = [ [[package]] name = "cosmic-theme" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic#2c93a4094fe331726ad0f7b80ae00a28f856543b" +source = "git+https://github.com/pop-os/libcosmic#fc85fcac3e1d1b7a05982e7396a8bc23ff4d0143" dependencies = [ "almost", "cosmic-config", @@ -2008,6 +2026,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -3385,7 +3412,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.61.2", ] [[package]] @@ -3400,7 +3427,7 @@ dependencies = [ [[package]] name = "iced" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic#2c93a4094fe331726ad0f7b80ae00a28f856543b" +source = "git+https://github.com/pop-os/libcosmic#fc85fcac3e1d1b7a05982e7396a8bc23ff4d0143" dependencies = [ "dnd", "iced_accessibility", @@ -3418,7 +3445,7 @@ dependencies = [ [[package]] name = "iced_accessibility" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic#2c93a4094fe331726ad0f7b80ae00a28f856543b" +source = "git+https://github.com/pop-os/libcosmic#fc85fcac3e1d1b7a05982e7396a8bc23ff4d0143" dependencies = [ "accesskit", "accesskit_winit", @@ -3427,7 +3454,7 @@ dependencies = [ [[package]] name = "iced_core" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic#2c93a4094fe331726ad0f7b80ae00a28f856543b" +source = "git+https://github.com/pop-os/libcosmic#fc85fcac3e1d1b7a05982e7396a8bc23ff4d0143" dependencies = [ "bitflags 2.10.0", "bytes", @@ -3452,7 +3479,7 @@ dependencies = [ [[package]] name = "iced_futures" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic#2c93a4094fe331726ad0f7b80ae00a28f856543b" +source = "git+https://github.com/pop-os/libcosmic#fc85fcac3e1d1b7a05982e7396a8bc23ff4d0143" dependencies = [ "futures", "iced_core", @@ -3478,7 +3505,7 @@ dependencies = [ [[package]] name = "iced_graphics" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic#2c93a4094fe331726ad0f7b80ae00a28f856543b" +source = "git+https://github.com/pop-os/libcosmic#fc85fcac3e1d1b7a05982e7396a8bc23ff4d0143" dependencies = [ "bitflags 2.10.0", "bytemuck", @@ -3500,7 +3527,7 @@ dependencies = [ [[package]] name = "iced_renderer" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic#2c93a4094fe331726ad0f7b80ae00a28f856543b" +source = "git+https://github.com/pop-os/libcosmic#fc85fcac3e1d1b7a05982e7396a8bc23ff4d0143" dependencies = [ "iced_graphics", "iced_tiny_skia", @@ -3512,7 +3539,7 @@ dependencies = [ [[package]] name = "iced_runtime" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic#2c93a4094fe331726ad0f7b80ae00a28f856543b" +source = "git+https://github.com/pop-os/libcosmic#fc85fcac3e1d1b7a05982e7396a8bc23ff4d0143" dependencies = [ "bytes", "cosmic-client-toolkit", @@ -3528,7 +3555,7 @@ dependencies = [ [[package]] name = "iced_tiny_skia" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic#2c93a4094fe331726ad0f7b80ae00a28f856543b" +source = "git+https://github.com/pop-os/libcosmic#fc85fcac3e1d1b7a05982e7396a8bc23ff4d0143" dependencies = [ "bytemuck", "cosmic-text", @@ -3544,7 +3571,7 @@ dependencies = [ [[package]] name = "iced_wgpu" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic#2c93a4094fe331726ad0f7b80ae00a28f856543b" +source = "git+https://github.com/pop-os/libcosmic#fc85fcac3e1d1b7a05982e7396a8bc23ff4d0143" dependencies = [ "as-raw-xcb-connection", "bitflags 2.10.0", @@ -3575,7 +3602,7 @@ dependencies = [ [[package]] name = "iced_widget" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic#2c93a4094fe331726ad0f7b80ae00a28f856543b" +source = "git+https://github.com/pop-os/libcosmic#fc85fcac3e1d1b7a05982e7396a8bc23ff4d0143" dependencies = [ "cosmic-client-toolkit", "dnd", @@ -3596,7 +3623,7 @@ dependencies = [ [[package]] name = "iced_winit" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic#2c93a4094fe331726ad0f7b80ae00a28f856543b" +source = "git+https://github.com/pop-os/libcosmic#fc85fcac3e1d1b7a05982e7396a8bc23ff4d0143" dependencies = [ "cosmic-client-toolkit", "dnd", @@ -4236,6 +4263,12 @@ dependencies = [ "unic-langid", ] +[[package]] +name = "intmap" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2e611826a1868311677fdcdfbec9e8621d104c732d080f546a854530232f0ee" + [[package]] name = "io-lifetimes" version = "1.0.11" @@ -4262,6 +4295,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -4604,12 +4646,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "lazycell" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" - [[package]] name = "lebe" version = "0.5.3" @@ -4625,7 +4661,7 @@ checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" [[package]] name = "libcosmic" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic#2c93a4094fe331726ad0f7b80ae00a28f856543b" +source = "git+https://github.com/pop-os/libcosmic#fc85fcac3e1d1b7a05982e7396a8bc23ff4d0143" dependencies = [ "apply", "ashpd 0.12.0", @@ -4694,33 +4730,6 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" -[[package]] -name = "libpulse-binding" -version = "2.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "909eb3049e16e373680fe65afe6e2a722ace06b671250cc4849557bc57d6a397" -dependencies = [ - "bitflags 2.10.0", - "libc", - "libpulse-sys", - "num-derive", - "num-traits", - "winapi", -] - -[[package]] -name = "libpulse-sys" -version = "1.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d74371848b22e989f829cc1621d2ebd74960711557d8b45cfe740f60d0a05e61" -dependencies = [ - "libc", - "num-derive", - "num-traits", - "pkg-config", - "winapi", -] - [[package]] name = "libredox" version = "0.1.10" @@ -4734,9 +4743,9 @@ dependencies = [ [[package]] name = "libspa" -version = "0.8.0" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65f3a4b81b2a2d8c7f300643676202debd1b7c929dbf5c9bb89402ea11d19810" +checksum = "b6b8cfa2a7656627b4c92c6b9ef929433acd673d5ab3708cda1b18478ac00df4" dependencies = [ "bitflags 2.10.0", "cc", @@ -4744,20 +4753,20 @@ dependencies = [ "cookie-factory", "libc", "libspa-sys", - "nix 0.27.1", - "nom 7.1.3", - "system-deps", + "nix 0.30.1", + "nom 8.0.0", + "system-deps 7.0.7", ] [[package]] name = "libspa-sys" -version = "0.8.0" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf0d9716420364790e85cbb9d3ac2c950bde16a7dd36f3209b7dfdfc4a24d01f" +checksum = "901049455d2eb6decf9058235d745237952f4804bc584c5fcb41412e6adcc6e0" dependencies = [ "bindgen", "cc", - "system-deps", + "system-deps 7.0.7", ] [[package]] @@ -5042,7 +5051,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" dependencies = [ "cfg-if", - "unicode-width", + "unicode-width 0.1.14", ] [[package]] @@ -5193,17 +5202,6 @@ dependencies = [ "memoffset 0.7.1", ] -[[package]] -name = "nix" -version = "0.27.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" -dependencies = [ - "bitflags 2.10.0", - "cfg-if", - "libc", -] - [[package]] name = "nix" version = "0.30.1" @@ -5426,6 +5424,12 @@ dependencies = [ "syn 2.0.110", ] +[[package]] +name = "numtoa" +version = "1.0.0-alpha1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3f98606e662e333dada0fa9fb6723a3c363fb4a66b51e47ce964cfaf58833d2" + [[package]] name = "objc" version = "0.2.7" @@ -5986,30 +5990,30 @@ dependencies = [ [[package]] name = "pipewire" -version = "0.8.0" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08e645ba5c45109106d56610b3ee60eb13a6f2beb8b74f8dc8186cf261788dda" +checksum = "9688b89abf11d756499f7c6190711d6dbe5a3acdb30c8fbf001d6596d06a8d44" dependencies = [ "anyhow", "bitflags 2.10.0", "libc", "libspa", "libspa-sys", - "nix 0.27.1", + "nix 0.30.1", "once_cell", "pipewire-sys", - "thiserror 1.0.69", + "thiserror 2.0.17", ] [[package]] name = "pipewire-sys" -version = "0.8.0" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "849e188f90b1dda88fe2bfe1ad31fe5f158af2c98f80fb5d13726c44f3f01112" +checksum = "cb028afee0d6ca17020b090e3b8fa2d7de23305aef975c7e5192a5050246ea36" dependencies = [ "bindgen", "libspa-sys", - "system-deps", + "system-deps 7.0.7", ] [[package]] @@ -6419,7 +6423,7 @@ dependencies = [ "rand 0.8.5", "rand_chacha 0.3.1", "simd_helpers", - "system-deps", + "system-deps 6.2.2", "thiserror 1.0.69", "v_frame", "wasm-bindgen", @@ -6959,6 +6963,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_with" version = "3.15.1" @@ -7422,13 +7435,26 @@ version = "6.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" dependencies = [ - "cfg-expr", + "cfg-expr 0.15.8", "heck 0.5.0", "pkg-config", "toml 0.8.23", "version-compare", ] +[[package]] +name = "system-deps" +version = "7.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c8f33736f986f16d69b6cb8b03f55ddcad5c41acc4ccc39dd88e84aa805e7f" +dependencies = [ + "cfg-expr 0.20.4", + "heck 0.5.0", + "pkg-config", + "toml 0.9.8", + "version-compare", +] + [[package]] name = "tachyonix" version = "0.3.1" @@ -7466,6 +7492,12 @@ version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" +[[package]] +name = "target-lexicon" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" + [[package]] name = "temp-dir" version = "0.1.16" @@ -7716,11 +7748,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", - "serde_spanned", + "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_edit 0.22.27", ] +[[package]] +name = "toml" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" +dependencies = [ + "indexmap 2.12.0", + "serde_core", + "serde_spanned 1.0.3", + "toml_datetime 0.7.3", + "toml_parser", + "toml_writer", + "winnow 0.7.13", +] + [[package]] name = "toml_datetime" version = "0.6.11" @@ -7758,7 +7805,7 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap 2.12.0", "serde", - "serde_spanned", + "serde_spanned 0.6.9", "toml_datetime 0.6.11", "winnow 0.7.13", ] @@ -7784,6 +7831,12 @@ dependencies = [ "winnow 0.7.13", ] +[[package]] +name = "toml_writer" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" + [[package]] name = "tracing" version = "0.1.41" @@ -7994,6 +8047,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -8639,20 +8698,7 @@ dependencies = [ "windows-interface 0.59.3", "windows-link 0.1.3", "windows-result 0.3.4", - "windows-strings 0.4.2", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", + "windows-strings", ] [[package]] @@ -8750,15 +8796,6 @@ dependencies = [ "windows-link 0.1.3", ] -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link 0.2.1", -] - [[package]] name = "windows-strings" version = "0.4.2" @@ -8768,15 +8805,6 @@ dependencies = [ "windows-link 0.1.3", ] -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link 0.2.1", -] - [[package]] name = "windows-sys" version = "0.45.0" @@ -9338,15 +9366,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" -[[package]] -name = "yansi-term" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe5c30ade05e61656247b2e334a031dfd0cc466fadef865bdcdea8d537951bf1" -dependencies = [ - "winapi", -] - [[package]] name = "yazi" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index cb2ed8c45..1d0d1a858 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["cosmic-settings", "page", "pages/*", "subscriptions/*"] +members = ["cosmic-settings", "crates/*", "page", "pages/*", "subscriptions/*"] default-members = ["cosmic-settings"] resolver = "3" diff --git a/cosmic-settings/Cargo.toml b/cosmic-settings/Cargo.toml index aa88e9177..85962a0a8 100644 --- a/cosmic-settings/Cargo.toml +++ b/cosmic-settings/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cosmic-settings" -version = "0.1.0" +version = "1.0.0-beta6" edition = "2024" license = "GPL-3.0-only" publish = false @@ -72,7 +72,7 @@ tachyonix = "0.3.1" timedate-zbus = { git = "https://github.com/pop-os/dbus-settings-bindings", optional = true } tokio = { workspace = true, features = ["fs", "io-util", "sync"] } tracing = "0.1.41" -tracing-subscriber = "0.3.20" +tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } udev = { version = "0.9.3", optional = true } upower_dbus = { git = "https://github.com/pop-os/dbus-settings-bindings", optional = true } bluez-zbus = { git = "https://github.com/pop-os/dbus-settings-bindings", optional = true } diff --git a/cosmic-settings/src/app.rs b/cosmic-settings/src/app.rs index 4e15a3b68..e6dd5dce7 100644 --- a/cosmic-settings/src/app.rs +++ b/cosmic-settings/src/app.rs @@ -550,6 +550,13 @@ impl cosmic::Application for SettingsApp { } } + #[cfg(feature = "page-sound")] + crate::pages::Message::SoundDeviceProfiles(message) => { + if let Some(page) = self.pages.page_mut::() { + return page.update(message).map(Into::into); + } + } + crate::pages::Message::StartupApps(message) => { if let Some(page) = self.pages.page_mut::() { return page.update(message).map(Into::into); diff --git a/cosmic-settings/src/main.rs b/cosmic-settings/src/main.rs index 05d58c89d..ae8e03eb8 100644 --- a/cosmic-settings/src/main.rs +++ b/cosmic-settings/src/main.rs @@ -201,11 +201,6 @@ fn init_localizer() { } fn init_logger() { - let log_level = std::env::var("RUST_LOG") - .ok() - .and_then(|level| level.parse::().ok()) - .unwrap_or(tracing::Level::INFO); - let log_format = tracing_subscriber::fmt::format() .pretty() .without_time() @@ -214,17 +209,14 @@ fn init_logger() { .with_target(false) .with_thread_names(true); - let log_filter = tracing_subscriber::fmt::Layer::default() + let log_layer = tracing_subscriber::fmt::Layer::default() .with_writer(std::io::stderr) - .event_format(log_format) - .with_filter(tracing_subscriber::filter::filter_fn(move |metadata| { - let target = metadata.target(); - metadata.level() == &tracing::Level::ERROR - || ((target.starts_with("cosmic_settings") || target.starts_with("cosmic_bg")) - && metadata.level() <= &log_level) - })); - - tracing_subscriber::registry().with(log_filter).init(); + .event_format(log_format); + + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::from_env("RUST_LOG")) + .with(log_layer) + .init(); } #[macro_export] diff --git a/cosmic-settings/src/pages/mod.rs b/cosmic-settings/src/pages/mod.rs index a2eab2ed9..fb2762170 100644 --- a/cosmic-settings/src/pages/mod.rs +++ b/cosmic-settings/src/pages/mod.rs @@ -84,6 +84,8 @@ pub enum Message { Region(time::region::Message), #[cfg(feature = "page-sound")] Sound(sound::Message), + #[cfg(feature = "page-sound")] + SoundDeviceProfiles(sound::device_profiles::Message), StartupApps(applications::startup_apps::Message), #[cfg(feature = "page-users")] User(system::users::Message), diff --git a/cosmic-settings/src/pages/sound/device_profiles.rs b/cosmic-settings/src/pages/sound/device_profiles.rs new file mode 100644 index 000000000..4e4e62774 --- /dev/null +++ b/cosmic-settings/src/pages/sound/device_profiles.rs @@ -0,0 +1,96 @@ +// Copyright 2025 System76 +// SPDX-License-Identifier: GPL-3.0-only + +use cosmic::{Apply, widget}; +use cosmic_settings_page::{self as page, Section, section}; +use cosmic_settings_sound_subscription::{self as subscription}; +use slotmap::SlotMap; + +#[derive(Clone, Debug)] +pub enum Message {} + +impl From for crate::pages::Message { + fn from(message: Message) -> Self { + crate::pages::Message::SoundDeviceProfiles(message) + } +} + +impl From for crate::Message { + fn from(message: Message) -> Self { + crate::Message::PageMessage(message.into()) + } +} + +#[derive(Default)] +pub struct Page { + entity: page::Entity, +} + +impl page::AutoBind for Page {} + +impl page::Page for Page { + fn info(&self) -> page::Info { + page::Info::new("sound-device-profiles", "preferences-sound-symbolic") + .title(fl!("sound-device-profiles")) + } + + fn content( + &self, + sections: &mut SlotMap>, + ) -> Option { + Some(vec![sections.insert(view())]) + } + + fn on_leave(&mut self) -> cosmic::Task { + cosmic::Task::done(crate::pages::Message::Sound(super::Message::Reload)) + } + + fn set_id(&mut self, entity: cosmic_settings_page::Entity) { + self.entity = entity; + } + + fn subscription( + &self, + _core: &cosmic::Core, + ) -> cosmic::iced::Subscription { + cosmic::iced::Subscription::run(subscription::watch) + .map(|message| super::Message::Subscription(message).into()) + } +} + +impl Page { + pub fn update(&mut self, _message: Message) -> cosmic::Task { + cosmic::Task::none() + } +} + +pub fn view() -> Section { + Section::default().view::(move |binder, _page, _section| { + let sound_page_id = binder.find_page_by_id("sound").unwrap().0; + let sound_page = binder.page[sound_page_id] + .downcast_ref::() + .unwrap(); + + let devices = sound_page + .model + .device_profile_dropdowns + .iter() + .cloned() + .map(|(device_id, name, active_profile, indexes, descriptions)| { + let dropdown = widget::dropdown::popup_dropdown( + descriptions, + active_profile, + move |id| super::Message::SetProfile(device_id, indexes[id]), + cosmic::iced::window::Id::RESERVED, + super::Message::Surface, + crate::Message::from, + ) + .apply(cosmic::Element::from) + .map(crate::pages::Message::from); + + widget::settings::item::builder(name).control(dropdown) + }); + + widget::settings::section().extend(devices).into() + }) +} diff --git a/cosmic-settings/src/pages/sound.rs b/cosmic-settings/src/pages/sound/mod.rs similarity index 75% rename from cosmic-settings/src/pages/sound.rs rename to cosmic-settings/src/pages/sound/mod.rs index 3d66222d6..e6df57b80 100644 --- a/cosmic-settings/src/pages/sound.rs +++ b/cosmic-settings/src/pages/sound/mod.rs @@ -1,6 +1,8 @@ // Copyright 2023 System76 // SPDX-License-Identifier: GPL-3.0-only +pub mod device_profiles; + use cosmic::{ Apply, Element, Task, iced::{Alignment, Length, window}, @@ -9,43 +11,42 @@ use cosmic::{ }; use cosmic_config::{Config, ConfigGet, ConfigSet}; use cosmic_settings_page::{self as page, Section, section}; +use cosmic_settings_sound_subscription as subscription; use slab::Slab; use slotmap::SlotMap; -use cosmic_settings_sound_subscription as subscription; - const AUDIO_CONFIG: &str = "com.system76.CosmicAudio"; const AMPLIFICATION_SINK: &str = "amplification_sink"; const AMPLIFICATION_SOURCE: &str = "amplification_source"; #[derive(Clone, Debug)] pub enum Message { - /// Change the balance of the active sink. - SinkBalanceChanged(u32), + /// Reload the model + Reload, /// Change the default output. - SinkChanged(usize), - /// Toggle the mute status of the output. - SinkMuteToggle, - /// Change the active profile for an output. - SinkProfileChanged(usize), - /// Request to change the default output volume. - SinkVolumeChanged(u32), - /// Toggle amplification for sink - ToggleOverAmplificationSink(bool), + SetDefaultSink(usize), /// Change the default input output. - SourceChanged(usize), - /// Toggle the mute status of the input output. - SourceMuteToggle, - /// Change the active profile for an output. - SourceProfileChanged(usize), + SetDefaultSource(usize), + /// Set the profile of a sound device. + SetProfile(u32, u32), + /// Change the balance of the active sink. + SetSinkBalance(u32), + /// Request to change the default output volume. + SetSinkVolume(u32), /// Request to change the input volume. - SourceVolumeChanged(u32), - /// Toggle amplification for sink - ToggleOverAmplificationSource(bool), + SetSourceVolume(u32), /// Messages handled by the sound module in cosmic-settings-subscriptions Subscription(subscription::Message), /// Surface Action Surface(surface::Action), + /// Toggle the mute status of the output. + ToggleSinkMute, + /// Toggle the mute status of the input output. + ToggleSourceMute, + /// Toggle amplification for sink + ToggleOverAmplificationSink(bool), + /// Toggle amplification for sink + ToggleOverAmplificationSource(bool), } impl From for crate::pages::Message { @@ -66,15 +67,32 @@ impl From for Message { } } -#[derive(Default)] pub struct Page { entity: page::Entity, - model: subscription::Model, + device_profiles: page::Entity, + pub(self) model: subscription::Model, sound_config: Option, amplification_sink: bool, amplification_source: bool, } +impl Default for Page { + fn default() -> Self { + let mut model = subscription::Model::default(); + model.unplugged_text = fl!("sound-device-port-unplugged"); + model.hd_audio_text = fl!("sound-hd-audio"); + model.usb_audio_text = fl!("sound-usb-audio"); + Self { + entity: page::Entity::default(), + device_profiles: page::Entity::default(), + model, + sound_config: None, + amplification_sink: false, + amplification_source: false, + } + } +} + impl page::Page for Page { fn on_enter(&mut self) -> cosmic::Task { match Config::new(AUDIO_CONFIG, 1) { @@ -97,7 +115,11 @@ impl page::Page for Page { &self, sections: &mut SlotMap>, ) -> Option { - Some(vec![sections.insert(output()), sections.insert(input())]) + Some(vec![ + sections.insert(output()), + sections.insert(input()), + sections.insert(device_profiles()), + ]) } fn info(&self) -> page::Info { @@ -106,6 +128,10 @@ impl page::Page for Page { .description(fl!("sound", "desc")) } + fn set_id(&mut self, entity: page::Entity) { + self.entity = entity; + } + fn subscription( &self, _core: &cosmic::Core, @@ -115,10 +141,9 @@ impl page::Page for Page { } fn on_leave(&mut self) -> Task { - self.model.clear(); - *self = Page { entity: self.entity, + device_profiles: self.device_profiles, ..Page::default() }; @@ -126,71 +151,76 @@ impl page::Page for Page { } } -impl page::AutoBind for Page {} +impl page::AutoBind for Page { + fn sub_pages( + mut page: page::Insert, + ) -> page::Insert { + let id = page.sub_page_with_id::(); + let model = page.model.page_mut::().unwrap(); + model.device_profiles = id; + page + } +} impl Page { pub fn update(&mut self, message: Message) -> Task { match message { - Message::SinkBalanceChanged(balance) => { + Message::Surface(a) => return cosmic::task::message(crate::app::Message::Surface(a)), + + Message::Subscription(message) => { return self .model - .sink_balance_changed(balance) + .update(message) .map(|message| Message::Subscription(message).into()); } - Message::SinkChanged(pos) => { + + Message::SetSinkBalance(balance) => { return self .model - .sink_changed(pos) + .set_sink_balance(balance) .map(|message| Message::Subscription(message).into()); } - Message::SinkMuteToggle => self.model.sink_mute_toggle(), - - Message::SinkProfileChanged(profile) => { + Message::SetDefaultSink(pos) => { return self .model - .sink_profile_changed(profile) + .set_default_sink(pos) .map(|message| Message::Subscription(message).into()); } - Message::SinkVolumeChanged(volume) => { + Message::SetDefaultSource(pos) => { return self .model - .sink_volume_changed(volume) + .set_default_source(pos) .map(|message| Message::Subscription(message).into()); } - Message::ToggleOverAmplificationSink(enabled) => { - self.amplification_sink = enabled; + Message::ToggleSinkMute => self.model.toggle_sink_mute(), - if let Some(config) = &self.sound_config { - if let Err(why) = config.set(AMPLIFICATION_SINK, enabled) { - tracing::error!(?why, "Failed to save over amplification setting"); - } - } - } + Message::ToggleSourceMute => self.model.toggle_source_mute(), - Message::SourceChanged(pos) => { + Message::SetSinkVolume(volume) => { return self .model - .source_changed(pos) + .set_sink_volume(volume) .map(|message| Message::Subscription(message).into()); } - Message::SourceMuteToggle => self.model.source_mute_toggle(), - - Message::SourceProfileChanged(profile) => { + Message::SetSourceVolume(volume) => { return self .model - .source_profile_changed(profile) + .set_source_volume(volume) .map(|message| Message::Subscription(message).into()); } - Message::SourceVolumeChanged(volume) => { - return self - .model - .source_volume_changed(volume) - .map(|message| Message::Subscription(message).into()); + Message::ToggleOverAmplificationSink(enabled) => { + self.amplification_sink = enabled; + + if let Some(config) = &self.sound_config { + if let Err(why) = config.set(AMPLIFICATION_SINK, enabled) { + tracing::error!(?why, "Failed to save over amplification setting"); + } + } } Message::ToggleOverAmplificationSource(enabled) => { @@ -203,14 +233,17 @@ impl Page { } } - Message::Subscription(message) => { - return self - .model - .update(message) - .map(|message| Message::Subscription(message).into()); + Message::SetProfile(object_id, index) => { + self.model.set_profile(object_id, index, true); } - Message::Surface(a) => return cosmic::task::message(crate::app::Message::Surface(a)), + Message::Reload => { + let mut model = subscription::Model::default(); + model.hd_audio_text = std::mem::take(&mut self.model.hd_audio_text); + model.unplugged_text = std::mem::take(&mut self.model.unplugged_text); + model.usb_audio_text = std::mem::take(&mut self.model.usb_audio_text); + self.model = model; + } } Task::none() @@ -223,7 +256,6 @@ fn input() -> Section { let volume = descriptions.insert(fl!("sound-input", "volume")); let device = descriptions.insert(fl!("sound-input", "device")); let _level = descriptions.insert(fl!("sound-input", "level")); - let profile = descriptions.insert(fl!("profile")); let amplification = descriptions.insert(fl!("amplification")); let amplification_desc = descriptions.insert(fl!("amplification", "desc")); @@ -237,12 +269,12 @@ fn input() -> Section { let slider = if page.amplification_source { widget::slider(0..=150, page.model.source_volume, |change| { - Message::SourceVolumeChanged(change).into() + Message::SetSourceVolume(change).into() }) .breakpoints(&[100]) } else { widget::slider(0..=100, page.model.source_volume, |change| { - Message::SourceVolumeChanged(change).into() + Message::SetSourceVolume(change).into() }) }; @@ -254,7 +286,7 @@ fn input() -> Section { } else { "audio-input-microphone-symbolic" })) - .on_press(Message::SourceMuteToggle.into()), + .on_press(Message::ToggleSourceMute.into()), ) .push( widget::text::body(&page.model.source_volume_text) @@ -266,7 +298,7 @@ fn input() -> Section { let devices = widget::dropdown::popup_dropdown( page.model.sources(), Some(page.model.active_source().unwrap_or(0)), - Message::SourceChanged, + Message::SetDefaultSource, window::Id::RESERVED, Message::Surface, crate::Message::from, @@ -282,21 +314,6 @@ fn input() -> Section { )) .add(settings::item(&*section.descriptions[device], devices)); - if !page.model.source_profiles().is_empty() { - let dropdown = widget::dropdown::popup_dropdown( - page.model.source_profiles(), - page.model.active_source_profile(), - Message::SourceProfileChanged, - window::Id::RESERVED, - Message::Surface, - crate::Message::from, - ) - .apply(Element::from) - .map(crate::pages::Message::from); - - controls = controls.add(settings::item(&*section.descriptions[profile], dropdown)); - } - controls = controls.add( settings::item::builder(&*section.descriptions[amplification]) .description(&*section.descriptions[amplification_desc]) @@ -316,7 +333,6 @@ fn output() -> Section { let volume = descriptions.insert(fl!("sound-output", "volume")); let device = descriptions.insert(fl!("sound-output", "device")); let _level = descriptions.insert(fl!("sound-output", "level")); - let profile = descriptions.insert(fl!("profile")); let balance = descriptions.insert(fl!("sound-output", "balance")); let left = descriptions.insert(fl!("sound-output", "left")); let right = descriptions.insert(fl!("sound-output", "right")); @@ -330,12 +346,12 @@ fn output() -> Section { .view::(move |_binder, page, section| { let slider = if page.amplification_sink { widget::slider(0..=150, page.model.sink_volume, |change| { - Message::SinkVolumeChanged(change).into() + Message::SetSinkVolume(change).into() }) .breakpoints(&[100]) } else { widget::slider(0..=100, page.model.sink_volume, |change| { - Message::SinkVolumeChanged(change).into() + Message::SetSinkVolume(change).into() }) }; @@ -347,7 +363,7 @@ fn output() -> Section { } else { widget::icon::from_name("audio-volume-high-symbolic") }) - .on_press(Message::SinkMuteToggle.into()), + .on_press(Message::ToggleSinkMute.into()), ) .push( widget::text::body(&page.model.sink_volume_text) @@ -360,7 +376,7 @@ fn output() -> Section { let devices = widget::dropdown::popup_dropdown( page.model.sinks(), Some(page.model.active_sink().unwrap_or(0)), - Message::SinkChanged, + Message::SetDefaultSink, window::Id::RESERVED, Message::Surface, crate::Message::from, @@ -374,24 +390,8 @@ fn output() -> Section { &*section.descriptions[volume], volume_control, )) - .add(settings::item(&*section.descriptions[device], devices)); - - if !page.model.sink_profiles().is_empty() { - let dropdown = widget::dropdown::popup_dropdown( - page.model.sink_profiles(), - page.model.active_sink_profile(), - Message::SinkProfileChanged, - window::Id::RESERVED, - Message::Surface, - crate::Message::from, - ) - .apply(Element::from) - .map(crate::pages::Message::from); - - controls = controls.add(settings::item(&*section.descriptions[profile], dropdown)); - } - if let Some(sink_balance) = page.model.sink_balance { - controls = controls.add(settings::item( + .add(settings::item(&*section.descriptions[device], devices)) + .add(settings::item( &*section.descriptions[balance], widget::row::with_capacity(4) .align_y(Alignment::Center) @@ -404,8 +404,9 @@ fn output() -> Section { .push( widget::slider( 0..=200, - ((sink_balance + 1.).max(0.) * 100.).round() as u32, - |change| Message::SinkBalanceChanged(change).into(), + (page.model.sink_balance.unwrap_or(1.0).max(0.) * 100.).round() + as u32, + |change| Message::SetSinkBalance(change).into(), ) .breakpoints(&[100]), ) @@ -416,7 +417,6 @@ fn output() -> Section { .align_x(Alignment::Center), ), )); - } controls = controls.add( settings::item::builder(&*section.descriptions[amplification]) @@ -431,6 +431,34 @@ fn output() -> Section { }) } +/// A section for opening the device profiles sub-page. +fn device_profiles() -> Section { + crate::slab!(descriptions { + button_txt = fl!("sound-device-profiles"); + }); + + Section::default() + .descriptions(descriptions) + .view::(move |_binder, page, section| { + let descriptions = §ion.descriptions; + let button = widget::row::with_children(vec![ + widget::horizontal_space().into(), + widget::icon::from_name("go-next-symbolic").size(16).into(), + ]); + + let device_profiles = settings::item::builder(&*descriptions[button_txt]) + .control(button) + .spacing(16) + .apply(widget::container) + .class(cosmic::theme::Container::List) + .apply(widget::button::custom) + .class(cosmic::theme::Button::Transparent) + .on_press(crate::pages::Message::Page(page.device_profiles)); + + settings::section().add(device_profiles).into() + }) +} + // fn alerts() -> Section { // let mut descriptions = Slab::new(); // let volume = descriptions.insert(fl!("sound-alerts", "volume")); diff --git a/crates/cosmic-pipewire/Cargo.toml b/crates/cosmic-pipewire/Cargo.toml new file mode 100644 index 000000000..877e98dce --- /dev/null +++ b/crates/cosmic-pipewire/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "cosmic-pipewire" +version = "1.0.0-beta6" +edition = "2024" +rust-version.workspace = true +license = "MPL-2.0" +publish = true + +[dependencies] +intmap = "3.1.2" +libspa = "0.9.2" +libspa-sys = "0.9.2" +pipewire = "0.9" +serde = { version = "1.0.228", features = ["derive"]} +serde_json = "1.0.145" +tracing = "0.1.41" diff --git a/crates/cosmic-pipewire/LICENSE.md b/crates/cosmic-pipewire/LICENSE.md new file mode 100644 index 000000000..d141fe4c9 --- /dev/null +++ b/crates/cosmic-pipewire/LICENSE.md @@ -0,0 +1,358 @@ +Mozilla Public License Version 2.0 +================================== + +## 1. Definitions + +### 1.1. "Contributor" +means each individual or legal entity that creates, contributes to +the creation of, or owns Covered Software. + +### 1.2. "Contributor Version" +means the combination of the Contributions of others (if any) used +by a Contributor and that particular Contributor's Contribution. + +### 1.3. "Contribution" +means Covered Software of a particular Contributor. + +### 1.4. "Covered Software" +means Source Code Form to which the initial Contributor has attached +the notice in Exhibit A, the Executable Form of such Source Code +Form, and Modifications of such Source Code Form, in each case +including portions thereof. + +### 1.5. "Incompatible With Secondary Licenses" +means + ++ (a) that the initial Contributor has attached the notice described +in Exhibit B to the Covered Software; or + ++ (b) that the Covered Software was made available under the terms of +version 1.1 or earlier of the License, but not also under the +terms of a Secondary License. + +### 1.6. "Executable Form" +means any form of the work other than Source Code Form. + +### 1.7. "Larger Work" +means a work that combines Covered Software with other material, in +a separate file or files, that is not Covered Software. + +### 1.8. "License" +means this document. + +### 1.9. "Licensable" +means having the right to grant, to the maximum extent possible, +whether at the time of the initial grant or subsequently, any and +all of the rights conveyed by this License. + +### 1.10. "Modifications" +means any of the following: + ++ (a) any file in Source Code Form that results from an addition to, +deletion from, or modification of the contents of Covered +Software; or + ++ (b) any new file in Source Code Form that contains any Covered +Software. + +### 1.11. "Patent Claims" of a Contributor +means any patent claim(s), including without limitation, method, +process, and apparatus claims, in any patent Licensable by such +Contributor that would be infringed, but for the grant of the +License, by the making, using, selling, offering for sale, having +made, import, or transfer of either its Contributions or its +Contributor Version. + +### 1.12. "Secondary License" +means either the GNU General Public License, Version 2.0, the GNU +Lesser General Public License, Version 2.1, the GNU Affero General +Public License, Version 3.0, or any later versions of those +licenses. + +### 1.13. "Source Code Form" +means the form of the work preferred for making modifications. + +### 1.14. "You" (or "Your") +means an individual or a legal entity exercising rights under this +License. For legal entities, "You" includes any entity that +controls, is controlled by, or is under common control with You. For +purposes of this definition, "control" means (a) the power, direct +or indirect, to cause the direction or management of such entity, +whether by contract or otherwise, or (b) ownership of more than +fifty percent (50%) of the outstanding shares or beneficial +ownership of such entity. + +## 2. License Grants and Conditions + +### 2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + ++ (a) under intellectual property rights (other than patent or trademark) +Licensable by such Contributor to use, reproduce, make available, +modify, display, perform, distribute, and otherwise exploit its +Contributions, either on an unmodified basis, with Modifications, or +as part of a Larger Work; and + ++ (b) under Patent Claims of such Contributor to make, use, sell, offer +for sale, have made, import, and otherwise transfer either its +Contributions or its Contributor Version. + +### 2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +### 2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + ++ (a) for any code that a Contributor has removed from Covered Software; +or + ++ (b) for infringements caused by: (i) Your and any other third party's +modifications of Covered Software, or (ii) the combination of its +Contributions with other software (except as part of its Contributor +Version); or + ++ (c) under Patent Claims infringed by Covered Software in the absence of +its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +### 2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +### 2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +### 2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +### 2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +## 3. Responsibilities + +### 3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +### 3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + ++ (a) such Covered Software must also be made available in Source Code +Form, as described in Section 3.1, and You must inform recipients of +the Executable Form how they can obtain a copy of such Source Code +Form by reasonable means in a timely manner, at a charge no more +than the cost of distribution to the recipient; and + ++ (b) You may distribute such Executable Form under the terms of this +License, or sublicense it under different terms, provided that the +license for the Executable Form does not attempt to limit or alter +the recipients' rights in the Source Code Form under this License. + +### 3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +### 3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +### 3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +## 4. Inability to Comply Due to Statute or Regulation + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +## 5. Termination + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + + +## 6. Disclaimer of Warranty + +**Covered Software is provided under this License on an "as is" +basis, without warranty of any kind, either expressed, implied, or +statutory, including, without limitation, warranties that the +Covered Software is free of defects, merchantable, fit for a +particular purpose or non-infringing. The entire risk as to the +quality and performance of the Covered Software is with You. +Should any Covered Software prove defective in any respect, You +(not any Contributor) assume the cost of any necessary servicing, +repair, or correction. This disclaimer of warranty constitutes an +essential part of this License. No use of any Covered Software is +authorized under this License except under this disclaimer.** + + +#7. Limitation of Liability + +**Under no circumstances and under no legal theory, whether tort +(including negligence), contract, or otherwise, shall any +Contributor, or anyone who distributes Covered Software as +permitted above, be liable to You for any direct, indirect, +special, incidental, or consequential damages of any character +including, without limitation, damages for lost profits, loss of +goodwill, work stoppage, computer failure or malfunction, or any +and all other commercial damages or losses, even if such party +shall have been informed of the possibility of such damages. This +limitation of liability shall not apply to liability for death or +personal injury resulting from such party's negligence to the +extent applicable law prohibits such limitation. Some +jurisdictions do not allow the exclusion or limitation of +incidental or consequential damages, so this exclusion and +limitation may not apply to You.** + + +## 8. Litigation + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +## 9. Miscellaneous + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +## 10. Versions of the License + +### 10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +### 10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +### 10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +### 10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +## Exhibit A - Source Code Form License Notice + + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +## Exhibit B - "Incompatible With Secondary Licenses" Notice + + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/crates/cosmic-pipewire/src/device.rs b/crates/cosmic-pipewire/src/device.rs new file mode 100644 index 000000000..a456fd511 --- /dev/null +++ b/crates/cosmic-pipewire/src/device.rs @@ -0,0 +1,27 @@ +// Copyright 2025 System76 +// SPDX-License-Identifier: MPL-2.0 + +use pipewire::device::DeviceInfoRef; + +/// Device information +#[must_use] +#[derive(Clone, Debug)] +pub struct Device { + pub id: u32, + pub name: String, +} + +impl Device { + /// Attains process info from a pipewire info node. + #[must_use] + pub fn from_device(info: &DeviceInfoRef) -> Option { + let props = info.props()?; + + let device = Device { + id: props.get("object.id")?.parse::().ok()?, + name: props.get("device.description")?.to_owned(), + }; + + Some(device) + } +} diff --git a/crates/cosmic-pipewire/src/lib.rs b/crates/cosmic-pipewire/src/lib.rs new file mode 100644 index 000000000..522fbb9d0 --- /dev/null +++ b/crates/cosmic-pipewire/src/lib.rs @@ -0,0 +1,1011 @@ +// Copyright 2024 System76 +// SPDX-License-Identifier: MPL-2.0 + +// #![deny(missing_docs)] + +pub mod device; +pub use device::Device; + +pub mod node; +use intmap::IntMap; +pub use node::{MediaClass, Node, NodeProps}; + +mod profile; +pub use profile::Profile; + +mod route; +pub use route::{Route, RouteProps}; + +mod spa_utils; +pub use spa_utils::Channel; + +use libspa::{ + param::{ParamType, format::FormatProperties}, + pod::{self, Pod, serialize::PodSerializer}, + utils::SpaTypes, +}; +use pipewire::{ + device::{DeviceChangeMask, DeviceListener}, + main_loop::MainLoopWeak, + metadata::MetadataListener, + node::NodeListener, + proxy::{ProxyListener, ProxyT}, + types::ObjectType, +}; +use std::{cell::RefCell, rc::Rc, u32}; + +pub type NodeId = u32; +pub type RouteId = u32; +pub type DeviceId = u32; +pub type ProfileId = i32; +pub type PipewireId = u32; + +pub fn run(on_event: impl FnMut(Event) + Send + 'static) -> Sender { + let (request_tx, request_rx) = pipewire::channel::channel(); + + std::thread::spawn(move || { + if let Err(why) = run_service(request_rx, on_event) { + tracing::error!(?why, "failed to run pipewire thread"); + } + }); + + Sender(request_tx) +} + +/// Monitor pipewire activity and +fn run_service( + rx: pipewire::channel::Receiver, + on_event: impl FnMut(Event) + Send + 'static, +) -> Result<(), pipewire::Error> { + let main_loop = pipewire::main_loop::MainLoopRc::new(None)?; + let context = pipewire::context::ContextRc::new(&main_loop, None)?; + let core = context.connect_rc(None)?; + let registry = core.get_registry_rc()?; + + let state = Rc::new(RefCell::new(State { + nodes: IntMap::new(), + proxies: Proxies { + devices: IntMap::new(), + metadata: IntMap::new(), + nodes: IntMap::new(), + }, + routes: IntMap::new(), + node_devices: IntMap::new(), + node_card_profile_device: IntMap::new(), + node_props: IntMap::new(), + main_loop: main_loop.downgrade(), + on_event: Box::new(on_event), + })); + + let _request_handler = rx.attach(main_loop.loop_(), { + let state = Rc::downgrade(&state); + move |request| match request { + Request::EnumerateDevice(id) => { + if let Some(state) = state.upgrade() { + state.borrow_mut().enumerate_device(id); + } + } + + Request::SetNodeVolume(id, volume, balance) => { + if let Some(state) = state.upgrade() { + state.borrow_mut().set_node_volume(id, volume, balance); + } + } + + Request::SetNodeMute(id, mute) => { + if let Some(state) = state.upgrade() { + state.borrow_mut().set_mute_node(id, mute); + } + } + + Request::SetProfile(id, index, save) => { + if let Some(state) = state.upgrade() { + state.borrow_mut().set_profile(id, index, save); + } + } + + Request::Quit => { + if let Some(state) = state.upgrade() { + state.borrow_mut().quit(); + } + } + } + }); + + let registry_weak = registry.downgrade(); + + let _registry_listener = registry + .add_listener_local() + .global(move |obj| { + let Some(registry) = registry_weak.upgrade() else { + return; + }; + + match obj.type_ { + ObjectType::Device => { + let Ok(device) = registry.bind::(obj) else { + return; + }; + + device.subscribe_params(&[ + ParamType::EnumProfile, + ParamType::EnumRoute, + ParamType::Profile, + ParamType::Route, + ]); + + let pw_id = device.upcast_ref().id(); + + let listener = device + .add_listener_local() + .info({ + let state = Rc::downgrade(&state); + move |info| { + let change_mask = info.change_mask(); + if change_mask == DeviceChangeMask::PARAMS { + if let Some(state) = state.upgrade() { + let state = state.borrow(); + let Some((_device_id, device, ..)) = + state.proxies.devices.get(pw_id) + else { + return; + }; + + device.enum_params( + 0, + Some(ParamType::EnumProfile), + 0, + u32::MAX, + ); + device.enum_params( + 0, + Some(ParamType::Profile), + 0, + u32::MAX, + ); + device.enum_params(1, Some(ParamType::Route), 0, u32::MAX); + } + + return; + } + + if let Some(device) = Device::from_device(info) { + if let Some(state) = state.upgrade() { + state.borrow_mut().add_device(pw_id, device); + } + } + } + }) + .param({ + let state = Rc::downgrade(&state); + move |_seq, param_type, index, _next, param| { + let Some(pod) = param else { + return; + }; + + let Some(state) = state.upgrade() else { + return; + }; + + let Some(&(device_id, ..)) = + state.borrow().proxies.devices.get(pw_id) + else { + return; + }; + + match param_type { + ParamType::EnumProfile => { + if let Some(profile) = Profile::from_pod(pod) { + state.borrow_mut().add_profile(device_id, profile); + } + } + + ParamType::EnumRoute => { + if let Some(route) = Route::from_pod(pod) { + state.borrow_mut().add_route(device_id, index, route); + } + } + + ParamType::Profile => { + if let Some(profile) = Profile::from_pod(pod) { + state.borrow_mut().active_profile(device_id, profile); + } + } + + ParamType::Route => { + if let Some(route) = Route::from_pod(pod) { + state + .borrow_mut() + .active_route(device_id, index, route); + } + } + + _ => (), + } + } + }) + .register(); + + let proxy = device.upcast_ref(); + + let remove_listener = proxy + .add_listener_local() + .removed({ + let state = Rc::downgrade(&state); + move || { + if let Some(state) = state.upgrade() { + let Some((id, ..)) = + state.borrow_mut().proxies.devices.remove(pw_id) + else { + return; + }; + + state.borrow_mut().remove_device(id); + } + } + }) + .register(); + + state + .borrow_mut() + .proxies + .devices + .insert(pw_id, (0, device, listener, remove_listener)); + } + + ObjectType::Node => { + let Ok(node) = registry.bind::(obj) else { + return; + }; + + node.subscribe_params(&[ParamType::Props]); + + let id = node.upcast_ref().id(); + + let listener = node + .add_listener_local() + .info({ + let state = Rc::downgrade(&state); + move |info| { + if let Some(node) = Node::from_node(info) { + if let Some(state) = state.upgrade() { + state.borrow_mut().add_node(id, node); + } + } + } + }) + .param({ + let state = Rc::downgrade(&state); + move |_seq, param_type, _index, _next, param| { + let Some(pod) = param else { + return; + }; + + let Some(state) = state.upgrade() else { + return; + }; + + let Some(&(node_id, ..)) = state.borrow().proxies.nodes.get(id) + else { + return; + }; + + match param_type { + ParamType::Props => { + if let Some(props) = NodeProps::from_pod(pod) { + state.borrow_mut().set_node_props(node_id, props); + } + } + + _ => (), + } + } + }) + .register(); + + let remove_listener = node + .upcast_ref() + .add_listener_local() + .removed({ + let state = Rc::downgrade(&state); + move || { + if let Some(state) = state.upgrade() { + state.borrow_mut().remove_node(id); + } + } + }) + .register(); + + state + .borrow_mut() + .proxies + .nodes + .insert(id, (0, node, listener, remove_listener)); + } + + ObjectType::Metadata => { + let Ok(metadata) = registry.bind::(obj) else { + return; + }; + + let id = metadata.upcast_ref().id(); + + let listener = metadata + .add_listener_local() + .property({ + let state = Rc::downgrade(&state); + move |_subject, key, _type, value| { + let Some((key, value)) = key.zip(value) else { + return 0; + }; + + match key { + "default.audio.sink" => { + if let Ok(value) = + serde_json::de::from_str::(value) + { + if let Some(state) = state.upgrade() { + state + .borrow_mut() + .default_sink(value.name.to_owned()) + } + } + } + + "default.audio.source" => { + if let Ok(value) = + serde_json::de::from_str::(value) + { + if let Some(state) = state.upgrade() { + state + .borrow_mut() + .default_source(value.name.to_owned()) + } + } + } + + _ => (), + } + + 0 + } + }) + .register(); + + let remove_listener = metadata + .upcast_ref() + .add_listener_local() + .removed({ + let state = Rc::downgrade(&state); + move || { + if let Some(state) = state.upgrade() { + state.borrow_mut().remove_metadata(id); + } + } + }) + .register(); + + state + .borrow_mut() + .proxies + .metadata + .insert(id, (metadata, listener, remove_listener)); + } + _ => {} + }; + }) + .register(); + + main_loop.run(); + Ok(()) +} + +/// Response from pipewire +#[derive(Clone, Debug)] +pub enum Event { + /// Set the active profile for a device + ActiveProfile(DeviceId, Profile), + /// Set the active route for a device + ActiveRoute(DeviceId, u32, Route), + /// A new device was detected. + AddDevice(Device), + /// A new node was detected. + AddNode(Node), + /// A profile was enumerated + AddProfile(DeviceId, Profile), + /// A route was enumerated + AddRoute(DeviceId, u32, Route), + /// The default sink was changed. + DefaultSink(String), + /// The default source was changed. + DefaultSource(String), + /// Emitted when the properties of a node has changed. + NodeProperties(NodeId, NodeProps), + /// A device with the given device_id was removed. + RemoveDevice(DeviceId), + /// A node with the given object_id was removed. + RemoveNode(NodeId), +} + +#[derive(Clone, Debug)] +pub enum Request { + /// Request a device's routes, profiles, active routes, and active profile. + EnumerateDevice(DeviceId), + /// Mute a node ID + SetNodeMute(NodeId, bool), + /// Set a device profile by profile index. + SetProfile(DeviceId, u32, bool), + /// Set a new volume + SetNodeVolume(DeviceId, f32, Option), + /// Stop the main loop and exit the thread. + Quit, +} + +#[derive(Copy, Clone, Debug, Default, Hash, Eq, PartialEq)] +pub enum Availability { + #[default] + Unknown, + No, + Yes, +} + +#[derive(Copy, Clone, Debug, Default, Hash, Eq, PartialEq)] +pub enum Direction { + Input, + #[default] + Output, +} + +#[derive(serde::Deserialize)] +pub struct DefaultAudio<'a> { + name: &'a str, +} + +struct Proxies { + devices: IntMap< + PipewireId, + ( + DeviceId, + pipewire::device::Device, + DeviceListener, + ProxyListener, + ), + >, + nodes: IntMap, + metadata: IntMap< + PipewireId, + ( + pipewire::metadata::Metadata, + MetadataListener, + ProxyListener, + ), + >, +} + +struct State { + nodes: IntMap)>, + pub(self) proxies: Proxies, + routes: IntMap>, + node_devices: IntMap, + node_props: IntMap, + node_card_profile_device: IntMap, + main_loop: MainLoopWeak, + /// Handle events and exit the loop when `true` is returned. + on_event: Box, +} + +impl State { + fn active_profile(&mut self, id: DeviceId, profile: Profile) { + self.on_event(Event::ActiveProfile(id, profile)); + } + + fn active_route(&mut self, id: DeviceId, index: u32, route: Route) { + self.on_event(Event::ActiveRoute(id, index, route)); + } + + fn add_device(&mut self, id: PipewireId, device: Device) { + // Map the device's pipewire ID to its device ID + if let Some(entry) = self.proxies.devices.get_mut(id) { + entry.0 = device.id; + }; + + let device_id = device.id; + self.on_event(Event::AddDevice(device)); + + // Request the device's profiles and properties now that we've registered it. + self.enumerate_device(device_id); + } + + fn add_node(&mut self, id: PipewireId, node: Node) { + // Map the device's pipewire ID to its device ID + if let Some(entry) = self.proxies.nodes.get_mut(id) { + entry.0 = node.object_id; + // Request properties for this node now that we've registered it. + entry.1.enum_params(0, Some(ParamType::Props), 0, u32::MAX); + }; + + // Track the node's node ID and device ID by its pipewire ID. + self.nodes.insert(id, (node.object_id, node.device_id)); + + // And the associated route device that the node is derived from. + if let Some(card_profile_device) = node.card_profile_device { + self.node_card_profile_device + .insert(node.object_id, card_profile_device); + } + + // Track the node's device ID by its node ID. + if let Some(device_id) = node.device_id { + self.node_devices.insert(node.object_id, device_id); + } + + self.on_event(Event::AddNode(node)); + } + + fn add_profile(&mut self, id: DeviceId, profile: Profile) { + self.on_event(Event::AddProfile(id, profile)); + } + + fn add_route(&mut self, id: DeviceId, index: u32, route: Route) { + // Keep a record of routes attached to a device for setting properties. + // This will overwrite routes on updates to + let routes = self.routes.entry(id).or_default(); + if routes.len() < index as usize + 1 { + let additional = (index as usize + 1) - routes.capacity(); + routes.reserve_exact(additional); + routes.extend(std::iter::repeat(Route::default()).take(additional)); + } + routes[index as usize] = route.clone(); + + self.on_event(Event::AddRoute(id, index, route)); + } + + /// Request a device's profiles and routes. + fn enumerate_device(&mut self, id: DeviceId) { + let Some(device) = self.device(id) else { + return; + }; + + device.enum_params(0, Some(ParamType::EnumProfile), 0, u32::MAX); + device.enum_params(1, Some(ParamType::EnumRoute), 0, u32::MAX); + device.enum_params(2, Some(ParamType::Profile), 0, u32::MAX); + device.enum_params(3, Some(ParamType::Route), 0, u32::MAX); + } + + fn default_sink(&mut self, name: String) { + self.on_event(Event::DefaultSink(name)); + } + + fn default_source(&mut self, name: String) { + self.on_event(Event::DefaultSource(name)); + } + + fn on_event(&mut self, event: Event) { + (self.on_event)(event); + } + + fn quit(&mut self) { + if let Some(main_loop) = self.main_loop.upgrade() { + main_loop.quit(); + } + } + + fn remove_device(&mut self, id: PipewireId) { + if let Some((device_id, ..)) = self.proxies.devices.remove(id) { + self.routes.remove(device_id); + self.on_event(Event::RemoveDevice(device_id)); + } + } + + fn remove_metadata(&mut self, id: PipewireId) { + self.proxies.metadata.remove(id); + } + + fn remove_node(&mut self, id: PipewireId) { + if let Some((node_id, _)) = self.nodes.remove(id) { + self.node_card_profile_device.remove(node_id); + self.node_devices.remove(node_id); + self.node_props.remove(node_id); + self.on_event(Event::RemoveNode(node_id)); + } + + self.proxies.nodes.remove(id); + } + + fn set_mute(&self, id: DeviceId, route_device: i32, route: &Route, mute: bool) { + let Some(device) = self.device(id) else { + return; + }; + + let route_props = pod::object!( + SpaTypes::ObjectParamProps, + ParamType::Props, + pod::property!(FormatProperties(libspa_sys::SPA_PROP_mute), Bool, mute), + ); + + let buffer = std::io::Cursor::new(Vec::new()); + let Ok(serialized) = PodSerializer::serialize( + buffer, + &pod::Value::Object(pod::object!( + SpaTypes::ObjectParamRoute, + ParamType::Route, + pod::property!( + FormatProperties(libspa_sys::SPA_PARAM_ROUTE_index), + Int, + route.index + ), + pod::property!( + FormatProperties(libspa_sys::SPA_PARAM_ROUTE_device), + Int, + route_device + ), + pod::property!( + FormatProperties(libspa_sys::SPA_PARAM_ROUTE_props), + Object, + route_props + ), + pod::property!( + FormatProperties(libspa_sys::SPA_PARAM_ROUTE_save), + Bool, + true + ) + )), + ) + .map(|(cursor, _)| cursor.into_inner()) else { + return; + }; + + if let Some(param) = Pod::from_bytes(&serialized) { + device.set_param(ParamType::Route, 0, param); + } + } + + fn set_mute_node(&self, id: NodeId, mute: bool) { + // Prefer to mute the device instead of the node. + // Muting a node will not emit a notification. + if let Some((&device_id, &route_device)) = self + .node_devices + .get(id) + .zip(self.node_card_profile_device.get(id)) + { + let route_device = route_device as i32; + if let Some(route) = self.node_route(device_id, route_device) { + self.set_mute(device_id, route_device, route, mute); + return; + }; + } + + let Some(node) = self.node(id) else { + return; + }; + + let buffer = std::io::Cursor::new(Vec::new()); + let Ok(serialized) = PodSerializer::serialize( + buffer, + &pod::Value::Object(pod::object!( + SpaTypes::ObjectParamProps, + ParamType::Props, + pod::property!(FormatProperties(libspa_sys::SPA_PROP_mute), Bool, mute), + )), + ) + .map(|(cursor, _)| cursor.into_inner()) else { + return; + }; + + if let Some(param) = Pod::from_bytes(&serialized) { + node.set_param(ParamType::Props, 0, param); + } + } + + fn node_route(&self, device_id: DeviceId, route_device: i32) -> Option<&Route> { + self.routes + .get(device_id)? + .iter() + .find(|r| r.devices.contains(&route_device)) + } + + fn set_node_props(&mut self, id: NodeId, props: NodeProps) { + self.on_event(Event::NodeProperties(id, props.clone())); + *self.node_props.entry(id).or_default() = props; + } + + fn set_node_volume(&self, id: NodeId, volume: f32, balance: Option) { + let Some(props) = self.node_props.get(id) else { + return; + }; + + // Prefer to change the volume of the device instead of the node. + if let Some((&device_id, &route_device)) = self + .node_devices + .get(id) + .zip(self.node_card_profile_device.get(id)) + { + let route_device = route_device as i32; + if let Some(route) = self.node_route(device_id, route_device) { + self.set_volume(device_id, props, route_device, route, volume, balance); + return; + }; + } + + let Some(node) = self.node(id) else { + return; + }; + + let buffer = std::io::Cursor::new(Vec::new()); + let Ok(serialized) = PodSerializer::serialize( + buffer, + &pod::Value::Object(pod::object!( + SpaTypes::ObjectParamProps, + ParamType::Props, + pod::property!(FormatProperties(libspa_sys::SPA_PROP_mute), Bool, false), + pod::property!( + FormatProperties(libspa_sys::SPA_PROP_channelVolumes), + ValueArray, + pod::ValueArray::Float(volume::to_channel_volumes( + &props.channel_map.as_deref().unwrap_or_default(), + volume, + balance, + )) + ) + )), + ) + .map(|(cursor, _)| cursor.into_inner()) else { + return; + }; + + if let Some(param) = Pod::from_bytes(&serialized) { + node.set_param(ParamType::Props, 0, param); + } + } + + fn set_profile(&mut self, id: DeviceId, index: u32, save: bool) { + let Some(device) = self.device(id) else { + return; + }; + + let buffer = std::io::Cursor::new(Vec::new()); + let Ok(serialized) = PodSerializer::serialize( + buffer, + &pod::Value::Object(pod::object!( + SpaTypes::ObjectParamProfile, + ParamType::Profile, + pod::property!( + FormatProperties(libspa_sys::SPA_PARAM_PROFILE_index), + Int, + index as i32 + ), + pod::property!( + FormatProperties(libspa_sys::SPA_PARAM_PROFILE_save), + Bool, + save + ) + )), + ) + .map(|(cursor, _)| cursor.into_inner()) else { + return; + }; + + if let Some(param) = Pod::from_bytes(&serialized) { + device.set_param(ParamType::Profile, 0, param); + } + } + + fn set_volume( + &self, + id: DeviceId, + props: &NodeProps, + route_device: i32, + route: &Route, + volume: f32, + balance: Option, + ) { + let Some(device) = self.device(id) else { + return; + }; + + let route_props = pod::object!( + SpaTypes::ObjectParamProps, + ParamType::Props, + pod::property!(FormatProperties(libspa_sys::SPA_PROP_mute), Bool, false), + pod::property!( + FormatProperties(libspa_sys::SPA_PROP_channelVolumes), + ValueArray, + pod::ValueArray::Float(if matches!(route.direction, Direction::Output) { + volume::to_channel_volumes( + &props.channel_map.as_deref().unwrap_or_default(), + volume, + balance, + ) + } else { + vec![volume * volume * volume] + }) + ) + ); + + let buffer = std::io::Cursor::new(Vec::new()); + let Ok(serialized) = PodSerializer::serialize( + buffer, + &pod::Value::Object(pod::object!( + SpaTypes::ObjectParamRoute, + ParamType::Route, + pod::property!( + FormatProperties(libspa_sys::SPA_PARAM_ROUTE_index), + Int, + route.index + ), + pod::property!( + FormatProperties(libspa_sys::SPA_PARAM_ROUTE_device), + Int, + route_device + ), + pod::property!( + FormatProperties(libspa_sys::SPA_PARAM_ROUTE_props), + Object, + route_props + ), + pod::property!( + FormatProperties(libspa_sys::SPA_PARAM_ROUTE_save), + Bool, + true + ) + )), + ) + .map(|(cursor, _)| cursor.into_inner()) else { + return; + }; + + if let Some(param) = Pod::from_bytes(&serialized) { + device.set_param(ParamType::Route, 0, param); + } + } + + fn device(&self, id: DeviceId) -> Option<&pipewire::device::Device> { + self.proxies + .devices + .values() + .find(|(device_id, ..)| id == *device_id) + .map(|(_, device, ..)| device) + } + + fn node(&self, id: NodeId) -> Option<&pipewire::node::Node> { + self.proxies + .nodes + .values() + .find(|(node_id, ..)| id == *node_id) + .map(|(_, node, ..)| node) + } +} + +pub struct Sender(pipewire::channel::Sender); + +impl Sender { + pub fn send(&self, request: Request) -> Result<(), Request> { + self.0.send(request) + } +} + +impl Drop for Sender { + fn drop(&mut self) { + _ = self.0.send(Request::Quit); + } +} + +pub mod volume { + use crate::Channel; + + /// Get the configured volume and balance based on a provided channel volumes array. + pub fn from_channel_volumes(channels: &[f32]) -> (f32, Option) { + let left_volume = channels.first().cloned().unwrap_or_default(); + let right_volume = channels.last().cloned().unwrap_or_default(); + + if (left_volume - right_volume).abs() < f32::EPSILON { + return (left_volume.powf(1.0 / 3.0), None); + } + + let (volume, balance) = if left_volume >= right_volume { + (left_volume, right_volume / left_volume) + } else { + (right_volume, (2.0 - (left_volume / right_volume))) + }; + + (volume.powf(1.0 / 3.0), Some(balance)) + } + + /// Create a channel volumes array based on the provided volume, balance, and channel positions. + pub fn to_channel_volumes( + channel_map: &[Channel], + volume: f32, + balance: Option, + ) -> Vec { + let volume = volume * volume * volume; + if let Some(balance) = balance { + let (left_volume, right_volume) = if balance >= 1.0 { + ((volume * (balance - 2.0).abs()), volume) + } else { + (volume, volume * balance) + }; + + let center_volume = (left_volume + right_volume) / 2.0; + let mut channel_volumes = Vec::with_capacity(channel_map.len()); + + // Use channel identifiers to apply volume balance + for channel in channel_map { + channel_volumes.push(match channel { + // Left channels + Channel::FL + | Channel::SL + | Channel::FLC + | Channel::RL + | Channel::TFL + | Channel::TFC + | Channel::TRL + | Channel::RLC + | Channel::FLW + | Channel::FLH + | Channel::TFLC + | Channel::TSL + | Channel::LLFE + | Channel::BLC => left_volume, + // Right channels + Channel::FR + | Channel::SR + | Channel::FRC + | Channel::RR + | Channel::TFR + | Channel::TRC + | Channel::TRR + | Channel::RRC + | Channel::FRW + | Channel::FRH + | Channel::TFRC + | Channel::TSR + | Channel::RLFE + | Channel::BRC => right_volume, + // Center/neutral channels + _ => center_volume, + }); + } + + channel_volumes + } else { + vec![volume; channel_map.len()] + } + } + + #[cfg(test)] + mod test { + use crate::Channel; + + #[test] + fn volume_balance_to_channel_volumes() { + // Test conversions to and from a channel + let channel_map = &[Channel::FL, Channel::FR]; + let inputs = vec![ + ((0.77, Some(0.32)), &[0.45653298, 0.14609055]), + ((0.77, Some(0.57)), &[0.45653298, 0.2602238]), + ((0.77, Some(0.68)), &[0.45653298, 0.31044245]), + ((0.77, Some(0.74)), &[0.45653298, 0.33783442]), + ((0.77, Some(1.00)), &[0.45653298, 0.45653298]), + ((0.77, Some(1.32)), &[0.31044242, 0.45653298]), + ((0.77, Some(1.57)), &[0.19630916, 0.45653298]), + ((0.77, Some(1.68)), &[0.14609058, 0.45653298]), + ((0.77, Some(1.74)), &[0.118698575, 0.45653298]), + ]; + + for ((volume, balance), channel_volumes) in inputs { + let out = super::to_channel_volumes(channel_map, volume, balance); + assert_eq!(&out, channel_volumes); + let res = super::from_channel_volumes(&out); + assert!((volume - res.0).abs() < 0.01, "{} != {}", volume, res.0); + assert!( + balance.map_or_else( + || res.1 == Some(1.0), + |b| res.1.map_or_else(|| b == 1.0, |r| (b - r).abs() < 0.01) + ), + "{:?} != {:?}", + balance, + res.1 + ); + } + } + } +} diff --git a/crates/cosmic-pipewire/src/node.rs b/crates/cosmic-pipewire/src/node.rs new file mode 100644 index 000000000..5536f30d0 --- /dev/null +++ b/crates/cosmic-pipewire/src/node.rs @@ -0,0 +1,190 @@ +// Copyright 2025 System76 +// SPDX-License-Identifier: MPL-2.0 + +use crate::{Channel, spa_utils::array_from_pod}; +use libspa::{pod::Pod, utils::Id}; +use pipewire::node::{NodeInfoRef, NodeState}; +use std::ffi::c_float; + +/// Node information +#[must_use] +#[derive(Clone, Debug)] +pub struct Node { + pub object_id: u32, + pub audio_channels: u32, + pub audio_position: String, + pub card_profile_device: Option, + pub description: String, + pub device_id: Option, + pub device_profile_description: String, + pub device_profile_pro: bool, + pub icon_name: String, + pub media_class: MediaClass, + pub node_name: String, + pub state: State, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum State { + Idle, + Running, + Creating, + Suspended, + Error(String), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum MediaClass { + Source, + Sink, +} + +impl Node { + /// Attains process info from a pipewire info node. + #[must_use] + pub fn from_node(info: &NodeInfoRef) -> Option { + let props = info.props()?; + + let mut audio_channels = 1; + let mut audio_position = String::new(); + let mut card_profile_device = None; + let mut device_id = None; + let mut device_profile_description: &str = ""; + let mut device_profile_pro = false; + let mut icon_name = String::new(); + let mut media_class = None; + let mut node_description: &str = ""; + let mut node_name = String::new(); + let mut object_id = None; + + for (entry, value) in props.iter() { + match entry { + "device.id" => device_id = value.parse::().ok(), + "object.id" => object_id = Some(value.parse::().ok()?), + + // 2 + "audio.channels" => audio_channels = value.parse::().unwrap_or(1), + + // FL,FR + "audio.position" => audio_position = value.to_owned(), + + // 0 + "card.profile.device" => card_profile_device = Some(value.parse::().ok()?), + + // Analog Stereo (ALSA only) + "device.profile.description" => { + device_profile_description = value; + } + + // false + "device.profile.pro" => device_profile_pro = value == "true", + + // audio-card-analog + "device.icon-name" => icon_name = value.to_owned(), + + "media.class" => { + media_class = Some(match value { + "Audio/Sink" => MediaClass::Sink, + "Audio/Source" => MediaClass::Source, + _ => return None, + }) + } + + // alsa_input.pci-0000_66_00.6.analog-stereo + "node.name" => node_name = value.to_owned(), + + // Family 17h/19h HD Audio Controller Analog Stereo + "node.description" => node_description = value, + + _ => (), + } + } + + let device = Node { + object_id: object_id?, + device_id, + card_profile_device, + media_class: media_class?, + description: if device_profile_description.is_empty() { + node_description.to_owned() + } else { + let device_name = node_description + .strip_suffix(device_profile_description) + .unwrap_or(node_description) + .trim_ascii_end(); + device_name.to_owned() + }, + device_profile_description: device_profile_description.to_owned(), + device_profile_pro, + icon_name, + audio_channels, + audio_position, + node_name, + state: match info.state() { + NodeState::Idle => State::Idle, + NodeState::Running => State::Running, + NodeState::Creating => State::Creating, + NodeState::Suspended => State::Suspended, + NodeState::Error(why) => State::Error(why.to_owned()), + }, + }; + + Some(device) + } +} + +#[derive(Clone, Debug, Default)] +pub struct NodeProps { + pub mute: Option, + pub monitor_mute: Option, + pub channel_map: Option>, + pub channel_volumes: Option>, +} + +impl NodeProps { + pub fn from_pod(pod: &Pod) -> Option { + let props = pod.as_object().ok()?; + let props = NodeProps { + mute: props + .find_prop(Id(libspa_sys::SPA_PROP_mute)) + .and_then(|prop| prop.value().get_bool().ok()), + monitor_mute: props + .find_prop(Id(libspa_sys::SPA_PROP_monitorMute)) + .and_then(|prop| prop.value().get_bool().ok()), + channel_map: props + .find_prop(Id(libspa_sys::SPA_PROP_channelMap)) + .and_then(|prop| unsafe { array_from_pod::(prop.value()) }), + channel_volumes: props + .find_prop(Id(libspa_sys::SPA_PROP_channelVolumes)) + .and_then(|prop| unsafe { array_from_pod::(prop.value()) }), + }; + + if props.mute.is_none() + && props.monitor_mute.is_none() + && props.channel_map.is_none() + && props.channel_volumes.is_none() + { + None + } else { + Some(props) + } + } + + pub fn merge(&mut self, other: NodeProps) { + if other.mute.is_some() { + self.mute = other.mute + } + + if other.monitor_mute.is_some() { + self.monitor_mute = other.monitor_mute; + } + + if other.channel_map.is_some() { + self.channel_map = other.channel_map; + } + + if other.channel_volumes.is_some() { + self.channel_volumes = other.channel_volumes; + } + } +} diff --git a/crates/cosmic-pipewire/src/port.rs b/crates/cosmic-pipewire/src/port.rs new file mode 100644 index 000000000..e2fc9577d --- /dev/null +++ b/crates/cosmic-pipewire/src/port.rs @@ -0,0 +1,98 @@ +// Copyright 2025 System76 +// SPDX-License-Identifier: MPL-2.0 + +//! Currently unusued + +use crate::pipewire::Direction; +use pipewire::port::PortInfoRef; + +#[must_use] +#[derive(Clone, Debug)] +pub struct Port { + pub node_id: u32, + pub object_id: u32, + pub port_id: u32, + pub audio_channel: String, + pub format_dsp: String, + pub object_path: String, + pub port_direction: Direction, + pub port_group: String, + pub port_name: String, + pub port_alias: String, + pub port_physical: bool, + pub port_terminal: bool, + pub port_monitor: bool, +} + +impl Port { + /// Attains process info from a pipewire info port. + #[must_use] + pub fn from_port(info: &PortInfoRef) -> Option { + let props = info.props()?; + let object_id = info.id(); + let port_direction = match info.direction() { + libspa::utils::Direction::Input => Direction::Input, + libspa::utils::Direction::Output => Direction::Output, + _ => return None, + }; + + let mut node_id = 0; + let mut port_id = 0; + let mut port_monitor = false; + let mut port_physical = false; + let mut port_terminal = false; + + let mut audio_channel = String::new(); + let mut format_dsp = String::new(); + let mut object_path = String::new(); + let mut port_alias = String::new(); + let mut port_group = String::new(); + let mut port_name = String::new(); + + for (entry, value) in props.iter() { + match entry { + // 32 bit float mono audio + "format.dsp" => format_dsp = value.to_owned(), + // FR + "audio.channel" => audio_channel = value.to_owned(), + // playback + "port.group" => port_group = value.to_owned(), + // 1 + "port.id" => port_id = value.parse::().ok()?, + // false + "port.monitor" => port_monitor = value == "true", + // true + "port.physical" => port_physical = value == "true", + // true + "port.terminal" => port_terminal = value == "true", + // alsa:acp:Device:3:playback:playback_1 + "object.path" => object_path = value.to_owned(), + // playback_FR + "port.name" => port_name = value.to_owned(), + // MosArt USB Audio Device:playback_FR + "port.alias" => port_alias = value.to_owned(), + // 59 + "node.id" => node_id = value.parse::().ok()?, + _ => (), + } + } + + let port = Port { + format_dsp, + audio_channel, + port_id, + port_direction, + object_path, + port_name, + port_alias, + port_group, + port_monitor, + port_physical, + port_terminal, + node_id, + object_id, + }; + + Some(port) + } +} diff --git a/crates/cosmic-pipewire/src/profile.rs b/crates/cosmic-pipewire/src/profile.rs new file mode 100644 index 000000000..d2b01d0e7 --- /dev/null +++ b/crates/cosmic-pipewire/src/profile.rs @@ -0,0 +1,53 @@ +// Copyright 2025 System76 +// SPDX-License-Identifier: MPL-2.0 + +use crate::{Availability, spa_utils::string_from_pod}; +use libspa::pod::Pod; + +#[derive(Clone, Debug)] +pub struct Profile { + pub index: i32, + pub priority: i32, + pub available: Availability, + pub name: String, + pub description: String, +} + +impl Profile { + pub fn from_pod(pod: &Pod) -> Option { + let mut index = 0; + let mut priority = 0; + let mut available = Availability::Unknown; + let mut name = String::new(); + let mut description = String::new(); + + let profile = pod.as_object().ok()?; + + for prop in profile.props() { + match prop.key().0 { + libspa_sys::SPA_PARAM_PROFILE_index => index = prop.value().get_int().ok()?, + libspa_sys::SPA_PARAM_PROFILE_priority => priority = prop.value().get_int().ok()?, + libspa_sys::SPA_PARAM_PROFILE_available => { + available = match prop.value().get_id().unwrap().0 { + libspa_sys::SPA_PARAM_AVAILABILITY_no => Availability::No, + libspa_sys::SPA_PARAM_AVAILABILITY_yes => Availability::Yes, + _ => Availability::Unknown, + }; + } + libspa_sys::SPA_PARAM_PROFILE_name => name = string_from_pod(prop.value())?, + libspa_sys::SPA_PARAM_PROFILE_description => { + description = string_from_pod(prop.value())?; + } + _ => (), + } + } + + Some(Self { + index, + priority, + available, + name, + description, + }) + } +} diff --git a/crates/cosmic-pipewire/src/route.rs b/crates/cosmic-pipewire/src/route.rs new file mode 100644 index 000000000..eebbd151d --- /dev/null +++ b/crates/cosmic-pipewire/src/route.rs @@ -0,0 +1,92 @@ +// Copyright 2025 System76 +// SPDX-License-Identifier: MPL-2.0 + +use std::ffi::{c_float, c_int}; + +use crate::{ + Availability, Channel, Direction, + spa_utils::{array_from_pod, string_from_pod}, +}; +use libspa::{pod::Pod, utils::Id}; + +#[derive(Clone, Debug, Default)] +pub struct Route { + pub index: i32, + pub priority: i32, + pub device: i32, + pub available: Availability, + pub direction: Direction, + pub name: String, + pub description: String, + pub devices: Vec, + pub props: Option, +} + +#[derive(Clone, Debug, Default)] +pub struct RouteProps { + pub mute: Option, + pub monitor_mute: Option, + pub channel_map: Option>, + pub channel_volumes: Option>, +} + +impl Route { + pub fn from_pod(pod: &Pod) -> Option { + let mut this = Route::default(); + + let route = pod.as_object().ok()?; + + for prop in route.props() { + match prop.key().0 { + libspa_sys::SPA_PARAM_ROUTE_index => this.index = prop.value().get_int().ok()?, + libspa_sys::SPA_PARAM_ROUTE_priority => { + this.priority = prop.value().get_int().ok()? + } + libspa_sys::SPA_PARAM_ROUTE_device => this.device = prop.value().get_int().ok()?, + libspa_sys::SPA_PARAM_ROUTE_available => { + this.available = match prop.value().get_id().unwrap().0 { + libspa_sys::SPA_PARAM_AVAILABILITY_no => Availability::No, + libspa_sys::SPA_PARAM_AVAILABILITY_yes => Availability::Yes, + _ => Availability::Unknown, + }; + } + libspa_sys::SPA_PARAM_ROUTE_name => this.name = string_from_pod(prop.value())?, + libspa_sys::SPA_PARAM_ROUTE_description => { + this.description = string_from_pod(prop.value())?; + } + libspa_sys::SPA_PARAM_ROUTE_direction => { + this.direction = match prop.value().get_id().unwrap().0 { + libspa_sys::SPA_DIRECTION_OUTPUT => Direction::Output, + _ => Direction::Input, + } + } + libspa_sys::SPA_PARAM_ROUTE_devices => { + if let Some(data) = unsafe { array_from_pod::(prop.value()) } { + this.devices = data; + } + } + libspa_sys::SPA_PARAM_ROUTE_props => { + let props = prop.value().as_object().ok()?; + + this.props = Some(RouteProps { + mute: props + .find_prop(Id(libspa_sys::SPA_PROP_mute)) + .and_then(|prop| prop.value().get_bool().ok()), + monitor_mute: props + .find_prop(Id(libspa_sys::SPA_PROP_monitorMute)) + .and_then(|prop| prop.value().get_bool().ok()), + channel_map: props + .find_prop(Id(libspa_sys::SPA_PROP_channelMap)) + .and_then(|prop| unsafe { array_from_pod::(prop.value()) }), + channel_volumes: props + .find_prop(Id(libspa_sys::SPA_PROP_channelVolumes)) + .and_then(|prop| unsafe { array_from_pod::(prop.value()) }), + }) + } + _ => (), + } + } + + Some(this) + } +} diff --git a/crates/cosmic-pipewire/src/spa_utils.rs b/crates/cosmic-pipewire/src/spa_utils.rs new file mode 100644 index 000000000..7e3e859d1 --- /dev/null +++ b/crates/cosmic-pipewire/src/spa_utils.rs @@ -0,0 +1,152 @@ +// Copyright 2025 System76 +// SPDX-License-Identifier: MPL-2.0 + +use libspa::pod::Pod; +use std::ffi::CStr; + +/// Read a `Pod`'s string if it contains a string. +pub fn string_from_pod(pod: &Pod) -> Option { + if !pod.is_string() { + return None; + } + + let mut cstr = std::ptr::null(); + + unsafe { + // SAFETY: Pod is checked to be a string beforehand + if libspa_sys::spa_pod_get_string(pod.as_raw_ptr(), &mut cstr) == 0 { + if !cstr.is_null() { + return Some(String::from_utf8_lossy(CStr::from_ptr(cstr).to_bytes()).into_owned()); + } + } + } + + None +} + +/// SAFETY: Must be absolutely certain that the array is a compatible array. +pub unsafe fn array_from_pod(pod: &Pod) -> Option> { + if !pod.is_array() { + return None; + } + + let mut len = 0; + + unsafe { + let array: *mut CType = libspa_sys::spa_pod_get_array(pod.as_raw_ptr(), &mut len).cast(); + + if array.is_null() { + return None; + } + + Some(std::slice::from_raw_parts(array, len as usize).to_vec()) + } +} + +#[repr(u32)] +#[derive(Copy, Clone, Debug, Default, Hash, Eq, PartialEq)] +pub enum Channel { + #[default] + UNKNOWN = 0, // unspecified + NA, // N/A, silent + MONO, // mono stream + FL, // front left + FR, // front right + FC, // front center + LFE, // LFE + SL, // side left + SR, // side right + FLC, // front left center + FRC, // front right center + RC, // rear center + RL, // rear left + RR, // rear right + TC, // top center + TFL, // top front left + TFC, // top front center + TFR, // top front right + TRL, // top rear left + TRC, // top rear center + TRR, // top rear right + RLC, // rear left center + RRC, // rear right center + FLW, // front left wide + FRW, // front right wide + LFE2, // LFE 2 + FLH, // front left high + FCH, // front center high + FRH, // front right high + TFLC, // top front left center + TFRC, // top front right center + TSL, // top side left + TSR, // top side right + LLFE, // left LFE + RLFE, // right LFE + BC, // bottom center + BLC, // bottom left center + BRC = 37, // bottom right center + AUX0 = 4096, // aux channels + AUX1, + AUX2, + AUX3, + AUX4, + AUX5, + AUX6, + AUX7, + AUX8, + AUX9, + AUX10, + AUX11, + AUX12, + AUX13, + AUX14, + AUX15, + AUX16, + AUX17, + AUX18, + AUX19, + AUX20, + AUX21, + AUX22, + AUX23, + AUX24, + AUX25, + AUX26, + AUX27, + AUX28, + AUX29, + AUX30, + AUX31, + AUX32, + AUX33, + AUX34, + AUX35, + AUX36, + AUX37, + AUX38, + AUX39, + AUX40, + AUX41, + AUX42, + AUX43, + AUX44, + AUX45, + AUX46, + AUX47, + AUX48, + AUX49, + AUX50, + AUX51, + AUX52, + AUX53, + AUX54, + AUX55, + AUX56, + AUX57, + AUX58, + AUX59, + AUX60, + AUX61, + AUX62, + AUX63 = 4159, +} diff --git a/debian/control b/debian/control index ed5295808..c04ab7863 100644 --- a/debian/control +++ b/debian/control @@ -13,7 +13,6 @@ Build-Depends: libfreetype-dev, libinput-dev, libpipewire-0.3-dev, - libpulse-dev, libudev-dev, libwayland-dev, libxkbcommon-dev, diff --git a/i18n/en/cosmic_settings.ftl b/i18n/en/cosmic_settings.ftl index f3c6d4035..b85abf2cd 100644 --- a/i18n/en/cosmic_settings.ftl +++ b/i18n/en/cosmic_settings.ftl @@ -487,10 +487,15 @@ sound-alerts = Alerts sound-applications = Applications .desc = Application volumes and settings -profile = Profile +# No speaker, headphones, or microphone plugged into sound card port +sound-device-port-unplugged = Unplugged +sound-hd-audio = HD Audio +sound-usb-audio = USB Audio -## Power +# Profiles for sound card devices +sound-device-profiles = Device profiles +# Power & Battery settings page power = Power & battery .desc = Manage power settings diff --git a/page/Cargo.toml b/page/Cargo.toml index d0591da9b..c4b64a1f7 100644 --- a/page/Cargo.toml +++ b/page/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cosmic-settings-page" -version = "0.1.0" +version = "1.0.0-beta6" edition = "2024" [dependencies] diff --git a/pages/system/Cargo.toml b/pages/system/Cargo.toml index d4e81171d..281dd7a72 100644 --- a/pages/system/Cargo.toml +++ b/pages/system/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cosmic-settings-system" -version = "0.1.0" +version = "1.0.0-beta6" edition = "2024" license = "GPL-3.0-only" rust-version.workspace = true diff --git a/pages/wallpapers/Cargo.toml b/pages/wallpapers/Cargo.toml index 3ede7a3c6..8381c5e22 100644 --- a/pages/wallpapers/Cargo.toml +++ b/pages/wallpapers/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cosmic-settings-wallpaper" -version = "0.1.0" +version = "1.0.0-beta6" edition = "2024" rust-version.workspace = true diff --git a/resources/applications/com.system76.CosmicSettings.About.desktop b/resources/applications/com.system76.CosmicSettings.About.desktop index d6bf2af5b..dfe3f2477 100644 --- a/resources/applications/com.system76.CosmicSettings.About.desktop +++ b/resources/applications/com.system76.CosmicSettings.About.desktop @@ -23,7 +23,7 @@ Comment[af]=Toestelnaam, hardeware-inligtings, standaardinstellings van die bedr Comment[sk]=Názov zariadenia, hardvérové informácie, predvolené nastavenia systému. Comment[sv]=Enhetsnamn, hårdvaruinformation, standardinställningar för operativsystem. Comment[es]=Nombre del dispositivo, información de hardware y valores del sistema operativo. -Type=Settings +Type=Application Exec=cosmic-settings about Terminal=false Categories=COSMIC diff --git a/resources/applications/com.system76.CosmicSettings.Accessibility.desktop b/resources/applications/com.system76.CosmicSettings.Accessibility.desktop index a5f14c67c..3c6465b69 100644 --- a/resources/applications/com.system76.CosmicSettings.Accessibility.desktop +++ b/resources/applications/com.system76.CosmicSettings.Accessibility.desktop @@ -21,7 +21,7 @@ Comment[af]=Toeganklikheidsinstellings. Comment[sk]=Nastavenia prístupnosti. Comment[sv]=Tillgänglighetsinställningar. Comment[es]=Configuración de accesibilidad. -Type=Settings +Type=Application Exec=cosmic-settings accessibility Terminal=false Categories=COSMIC diff --git a/resources/applications/com.system76.CosmicSettings.Appearance.desktop b/resources/applications/com.system76.CosmicSettings.Appearance.desktop index d8dc1083e..f15d17b2f 100644 --- a/resources/applications/com.system76.CosmicSettings.Appearance.desktop +++ b/resources/applications/com.system76.CosmicSettings.Appearance.desktop @@ -23,7 +23,7 @@ Comment[af]=Aksentkleure en temas. Comment[sk]=Akcentové farby a témy. Comment[sv]=Accentfärger och teman. Comment[es]=Colores de énfasis y temas. -Type=Settings +Type=Application Exec=cosmic-settings appearance Terminal=false Categories=COSMIC diff --git a/resources/applications/com.system76.CosmicSettings.Applications.desktop b/resources/applications/com.system76.CosmicSettings.Applications.desktop index 24d5aab8b..251bce5c8 100644 --- a/resources/applications/com.system76.CosmicSettings.Applications.desktop +++ b/resources/applications/com.system76.CosmicSettings.Applications.desktop @@ -21,7 +21,7 @@ Comment[af]=Bestuur toepassingsinstellings. Comment[sk]=Spravovať nastavenia aplikácií. Comment[sv]=Hantera programinställningar. Comment[es]=Gestionar configuración de aplicaciones. -Type=Settings +Type=Application Exec=cosmic-settings applications Terminal=false Categories=COSMIC diff --git a/resources/applications/com.system76.CosmicSettings.Bluetooth.desktop b/resources/applications/com.system76.CosmicSettings.Bluetooth.desktop index ccc0074b5..f5821b8ed 100644 --- a/resources/applications/com.system76.CosmicSettings.Bluetooth.desktop +++ b/resources/applications/com.system76.CosmicSettings.Bluetooth.desktop @@ -21,7 +21,7 @@ Comment[sv]=Hantera Bluetooth-enheter Comment[nl]=Bluetooth-apparaten beheren Comment[af]=Bestuur Bluetooth-toestelle Comment[es]=Gestionar dispositivos Bluetooth -Type=Settings +Type=Application Exec=cosmic-settings bluetooth Terminal=false Categories=COSMIC diff --git a/resources/applications/com.system76.CosmicSettings.DateTime.desktop b/resources/applications/com.system76.CosmicSettings.DateTime.desktop index fa9e8a795..4a766d9a7 100644 --- a/resources/applications/com.system76.CosmicSettings.DateTime.desktop +++ b/resources/applications/com.system76.CosmicSettings.DateTime.desktop @@ -23,7 +23,7 @@ Comment[af]=Tydsone, outomatiese klokinstellings en tydformatering. Comment[sk]=Časové pásmo, automatické nastavenie hodín a formátovanie času. Comment[sv]=Tidzon, automatiska klockinställningar, och tidsformat. Comment[es]=Zona horaria, configuración automática del reloj y formatos de hora. -Type=Settings +Type=Application Exec=cosmic-settings date-time Terminal=false Categories=COSMIC diff --git a/resources/applications/com.system76.CosmicSettings.DefaultApps.desktop b/resources/applications/com.system76.CosmicSettings.DefaultApps.desktop index 54ca45f39..ff46a7cd5 100644 --- a/resources/applications/com.system76.CosmicSettings.DefaultApps.desktop +++ b/resources/applications/com.system76.CosmicSettings.DefaultApps.desktop @@ -23,7 +23,7 @@ Comment[af]=Standaard webblaaier, e-poskliënt, lêerblaaier en ander toepassing Comment[sk]=Predvolený webový prehliadač, e-mailový klient, správca súborov a ďalšie aplikácie. Comment[sv]=Standardwebläsare, e-postklient, filbläddrare, och andra program. Comment[es]=Navegador web predeterminado, cliente de correo, explorador de archivos y otras aplicaciones. -Type=Settings +Type=Application Exec=cosmic-settings default-apps Terminal=false Categories=COSMIC diff --git a/resources/applications/com.system76.CosmicSettings.Desktop.desktop b/resources/applications/com.system76.CosmicSettings.Desktop.desktop index 24b1ab563..77ffb443b 100644 --- a/resources/applications/com.system76.CosmicSettings.Desktop.desktop +++ b/resources/applications/com.system76.CosmicSettings.Desktop.desktop @@ -13,7 +13,7 @@ Name[es]=Escritorio Comment= Comment[cs]=Nastavení pracovní plochy, vzhledu a chování oken. Comment[sk]=Nastavenia pracovnej plochy, vzhľadu a správania okien -Type=Settings +Type=Application Exec=cosmic-settings desktop Terminal=false Categories=COSMIC diff --git a/resources/applications/com.system76.CosmicSettings.Displays.desktop b/resources/applications/com.system76.CosmicSettings.Displays.desktop index 846389db5..264f551b4 100644 --- a/resources/applications/com.system76.CosmicSettings.Displays.desktop +++ b/resources/applications/com.system76.CosmicSettings.Displays.desktop @@ -23,7 +23,7 @@ Comment[af]=Vertoonopsies, grafiese modusse en naglig. Comment[sk]=Možnosti displeja, grafické režimy a nočné svetlo. Comment[sv]=Skärmalternativ, grafiklägen, och nattljus. Comment[es]=Opciones de pantalla, modos gráficos y luz nocturna. -Type=Settings +Type=Application Exec=cosmic-settings displays Terminal=false Categories=COSMIC diff --git a/resources/applications/com.system76.CosmicSettings.Dock.desktop b/resources/applications/com.system76.CosmicSettings.Dock.desktop index 811de3460..85e2a0bc4 100644 --- a/resources/applications/com.system76.CosmicSettings.Dock.desktop +++ b/resources/applications/com.system76.CosmicSettings.Dock.desktop @@ -21,7 +21,7 @@ Comment[sv]=En valfri list för program och applets. Comment[nl]=Een optionele balk voor apps en applets. Comment[af]='n Opsionele balk vir programme en applets. Comment[es]=Panel opcional para aplicaciones y otros applets. -Type=Settings +Type=Application Exec=cosmic-settings dock Terminal=false Categories=COSMIC diff --git a/resources/applications/com.system76.CosmicSettings.Firmware.desktop b/resources/applications/com.system76.CosmicSettings.Firmware.desktop index 3b24de613..c6ff54efd 100644 --- a/resources/applications/com.system76.CosmicSettings.Firmware.desktop +++ b/resources/applications/com.system76.CosmicSettings.Firmware.desktop @@ -21,7 +21,7 @@ Comment[af]=Bekyk en werk firmware op. Comment[sk]=Zobraziť a aktualizovať firmware. Comment[sv]=Visa och updatera fast programvara. Comment[es]=Ver y actualizar firmware. -Type=Settings +Type=Application Exec=cosmic-settings firmware Terminal=false Categories=COSMIC diff --git a/resources/applications/com.system76.CosmicSettings.Input.desktop b/resources/applications/com.system76.CosmicSettings.Input.desktop index 1a5655707..565fe5b6c 100644 --- a/resources/applications/com.system76.CosmicSettings.Input.desktop +++ b/resources/applications/com.system76.CosmicSettings.Input.desktop @@ -21,7 +21,7 @@ Comment[hu]=Billentyűzet, mutató, stb. Comment[sk]=Klávesnica, kurzor a ďalšie. Comment[sv]=Tangentbord, markör, etc. Comment[es]=Teclado, ratón, etc. -Type=Settings +Type=Application Exec=cosmic-settings input Terminal=false Categories=COSMIC diff --git a/resources/applications/com.system76.CosmicSettings.Keyboard.desktop b/resources/applications/com.system76.CosmicSettings.Keyboard.desktop index 3b035dab5..8dd4931bd 100644 --- a/resources/applications/com.system76.CosmicSettings.Keyboard.desktop +++ b/resources/applications/com.system76.CosmicSettings.Keyboard.desktop @@ -21,7 +21,7 @@ Comment[nl]=Invoermethodes, speciale tekens, en sneltoetsen. Comment[sk]=Vstupné zdroje, prepínanie, zadávanie špeciálnych znakov, skratky. Comment[sv]=Inmatningskällor, växling, specialtecken, genvägar. Comment[es]=Entrada de teclado, conmutación, carácteres especiales, atajos. -Type=Settings +Type=Application Exec=cosmic-settings keyboard Terminal=false Categories=COSMIC diff --git a/resources/applications/com.system76.CosmicSettings.LegacyApplications.desktop b/resources/applications/com.system76.CosmicSettings.LegacyApplications.desktop index 57dd9cadd..7c7fac091 100644 --- a/resources/applications/com.system76.CosmicSettings.LegacyApplications.desktop +++ b/resources/applications/com.system76.CosmicSettings.LegacyApplications.desktop @@ -19,7 +19,7 @@ Comment[nl]=X11-toepassingsvensters schalen, en globale sneltoetsen. Comment[sk]=Škálovanie X11 aplikácií a globálne skratky. Comment[sv]=Applikationsskalning för X11 fönstersystem och globala genvägar. Comment[es]=Escalado de aplicaciones del sistema de ventanas X11 y atajos globales. -Type=Settings +Type=Application Exec=cosmic-settings legacy-applications Terminal=false Categories=COSMIC diff --git a/resources/applications/com.system76.CosmicSettings.Mouse.desktop b/resources/applications/com.system76.CosmicSettings.Mouse.desktop index 626b58b6a..fe41f28b4 100644 --- a/resources/applications/com.system76.CosmicSettings.Mouse.desktop +++ b/resources/applications/com.system76.CosmicSettings.Mouse.desktop @@ -21,7 +21,7 @@ Comment[nl]=Muissnelheid en -versnelling, en 'natuurlijk' scrollen. Comment[sk]=Rýchlosť myši, akcelerácia a prirodzené rolovanie. Comment[sv]=Mushastighet, acceleration, och naturlig rullning. Comment[es]=Velocidad del ratón, aceleración y desplazamiento natural. -Type=Settings +Type=Application Exec=cosmic-settings mouse Terminal=false Categories=COSMIC diff --git a/resources/applications/com.system76.CosmicSettings.Network.desktop b/resources/applications/com.system76.CosmicSettings.Network.desktop index 1e2a87df4..5492dcbdf 100644 --- a/resources/applications/com.system76.CosmicSettings.Network.desktop +++ b/resources/applications/com.system76.CosmicSettings.Network.desktop @@ -1,6 +1,6 @@ [Desktop Entry] Name=Network & Wireless -Name[ar]=الشبكة والاتصالات اللاسلكية +Name[ar]=الشبكة والاتصالات اللاسلكية Name[cs]=Síť a Wi-Fi Name[zh_CN]=网络和无线 Name[pl]=Sieć i połączenia bezprzewodowe @@ -21,7 +21,7 @@ Comment[nl]=Netwerkverbindingen beheren Comment[sk]=Spravovať sieťové pripojenia Comment[sv]=Hantera nätverksanslutningar Comment[es]=Gestionar conexiones de red -Type=Settings +Type=Application Exec=cosmic-settings network Terminal=false Categories=COSMIC diff --git a/resources/applications/com.system76.CosmicSettings.Notifications.desktop b/resources/applications/com.system76.CosmicSettings.Notifications.desktop index cfff2e0d9..c374a0cf0 100644 --- a/resources/applications/com.system76.CosmicSettings.Notifications.desktop +++ b/resources/applications/com.system76.CosmicSettings.Notifications.desktop @@ -21,7 +21,7 @@ Comment[nl]="Niet storen", meldingen op het vergrendelingsscherm en meldingsinst Comment[sk]=Nerušiť, oznámenia na uzamknutej obrazovke a nastavenia pre aplikácie. Comment[sv]=Stör ej, aviseringar på låsskärm, och inställningar per program. Comment[es]=No molestar, notificaciones de pantalla de bloqueo y ajustes de aplicación. -Type=Settings +Type=Application Exec=cosmic-settings notifications Terminal=false Categories=COSMIC diff --git a/resources/applications/com.system76.CosmicSettings.Panel.desktop b/resources/applications/com.system76.CosmicSettings.Panel.desktop index 9c7d55f0c..58cc5bf32 100644 --- a/resources/applications/com.system76.CosmicSettings.Panel.desktop +++ b/resources/applications/com.system76.CosmicSettings.Panel.desktop @@ -19,7 +19,7 @@ Comment[nl]=De standaard systeembalk voor menu's en applets. Comment[sk]=Hlavný systémový panel pre menu a applety. Comment[sv]=Primär systemfält för menyer och applets. Comment[es]=Barra principal del sistema para menús y applets. -Type=Settings +Type=Application Exec=cosmic-settings panel Terminal=false Categories=COSMIC diff --git a/resources/applications/com.system76.CosmicSettings.Power.desktop b/resources/applications/com.system76.CosmicSettings.Power.desktop index 2c25081b1..9e2503130 100644 --- a/resources/applications/com.system76.CosmicSettings.Power.desktop +++ b/resources/applications/com.system76.CosmicSettings.Power.desktop @@ -21,7 +21,7 @@ Comment[nl]=Energieverbruik en -besparingsopties. Comment[sk]=Režimy napájania a možnosti úspory energie. Comment[sv]=Strömalternativ och energisparalternativ. Comment[es]=Modos de energía y opciones de ahorro de energía. -Type=Settings +Type=Application Exec=cosmic-settings power Terminal=false Categories=COSMIC diff --git a/resources/applications/com.system76.CosmicSettings.RegionLanguage.desktop b/resources/applications/com.system76.CosmicSettings.RegionLanguage.desktop index 99b09ae84..ef721e4d5 100644 --- a/resources/applications/com.system76.CosmicSettings.RegionLanguage.desktop +++ b/resources/applications/com.system76.CosmicSettings.RegionLanguage.desktop @@ -1,6 +1,6 @@ [Desktop Entry] Name=Region & Language -Name[ar]=اللغة والمنطقة +Name[ar]=اللغة والمنطقة Name[cs]=Region a jazyk Name[zh_CN]=区域和语言 Name[pl]=Region i język @@ -21,7 +21,7 @@ Comment[nl]=Regionale datum-, tijd- en getalweergave. Comment[sk]=Formátovanie dátumov, časov a čísel podľa vášho regiónu. Comment[sv]=Formatera datum, tider och siffror baserat på din region. Comment[es]=Formato de fechas, horas y números según su región. -Type=Settings +Type=Application Exec=cosmic-settings region-language Terminal=false Categories=COSMIC diff --git a/resources/applications/com.system76.CosmicSettings.Sound.desktop b/resources/applications/com.system76.CosmicSettings.Sound.desktop index 4389c528d..8f48c2d86 100644 --- a/resources/applications/com.system76.CosmicSettings.Sound.desktop +++ b/resources/applications/com.system76.CosmicSettings.Sound.desktop @@ -21,7 +21,7 @@ Comment[nl]=Geluidsinstellingen voor apparaten, alarmen en programma's. Comment[sk]=Zvukové nastavenia pre zariadenia, upozornenia a aplikácie. Comment[sv]=Ljudinställningar för enhter, larm och program. Comment[es]=Configuraciones de audio para dispositivos, alertas y aplicaciones. -Type=Settings +Type=Application Exec=cosmic-settings sound Terminal=false Categories=COSMIC diff --git a/resources/applications/com.system76.CosmicSettings.StartupApps.desktop b/resources/applications/com.system76.CosmicSettings.StartupApps.desktop index 9bf21c908..4744c2739 100644 --- a/resources/applications/com.system76.CosmicSettings.StartupApps.desktop +++ b/resources/applications/com.system76.CosmicSettings.StartupApps.desktop @@ -9,7 +9,7 @@ Comment[ar]=اضبط التطبيقات التي تعمل عند الولوج. Comment[cs]=Nastavte aplikace, které se spustí při přihlášení. Comment[sv]=Konfigurera program som körs vid inloggning. Comment[hu]=Azoknak az alkalmazásoknak a beállítása, amelyek bejelentkezéskor elindulnak. -Type=Settings +Type=Application Exec=cosmic-settings startup-apps Terminal=false Categories=COSMIC diff --git a/resources/applications/com.system76.CosmicSettings.System.desktop b/resources/applications/com.system76.CosmicSettings.System.desktop index 7541e0040..fb4b2a434 100644 --- a/resources/applications/com.system76.CosmicSettings.System.desktop +++ b/resources/applications/com.system76.CosmicSettings.System.desktop @@ -15,7 +15,7 @@ Comment[cs]=Systémové informace, uživatelé a firmware Comment[nl]=Systeeminformatie, gebruikers en firmware Comment[sk]=Systémové informácie, používatelia a firmware Comment[es]=Información del sistema, cuentas y firmware -Type=Settings +Type=Application Exec=cosmic-settings system Terminal=false Categories=COSMIC diff --git a/resources/applications/com.system76.CosmicSettings.Time.desktop b/resources/applications/com.system76.CosmicSettings.Time.desktop index 13dc7b898..5b0c0e088 100644 --- a/resources/applications/com.system76.CosmicSettings.Time.desktop +++ b/resources/applications/com.system76.CosmicSettings.Time.desktop @@ -12,7 +12,7 @@ Name[sv]=Tid & språk Name[es]=Hora e Idioma Comment= Comment[sk]=Nastavenia času a jazyka -Type=Settings +Type=Application Exec=cosmic-settings time Terminal=false Categories=COSMIC diff --git a/resources/applications/com.system76.CosmicSettings.Touchpad.desktop b/resources/applications/com.system76.CosmicSettings.Touchpad.desktop index b18f26e73..64fa67216 100644 --- a/resources/applications/com.system76.CosmicSettings.Touchpad.desktop +++ b/resources/applications/com.system76.CosmicSettings.Touchpad.desktop @@ -1,6 +1,6 @@ [Desktop Entry] Name=Touchpad -Name[ar]=لوحة اللمس +Name[ar]=لوحة اللمس Name[cs]=Touchpad Name[zh_CN]=触摸板 Name[pl]=Gładzik @@ -20,7 +20,7 @@ Comment[sk]=Rýchlosť touchpadu, možnosti kliknutia, gestá. Comment[sv]=Pekplattans hastighet, klickalternativ, gester. Comment[nl]=Touchpad muisversnelling, klikeigenschappen en veeggebaren. Comment[es]=Velocidad del panel táctil, opciones de clic, gestos. -Type=Settings +Type=Application Exec=cosmic-settings touchpad Terminal=false Categories=COSMIC diff --git a/resources/applications/com.system76.CosmicSettings.Users.desktop b/resources/applications/com.system76.CosmicSettings.Users.desktop index 24ee1bf81..d3e3a9307 100644 --- a/resources/applications/com.system76.CosmicSettings.Users.desktop +++ b/resources/applications/com.system76.CosmicSettings.Users.desktop @@ -21,7 +21,7 @@ Comment[nl]=Authenticatie en gebruikersinstellingen. Comment[sk]=Autentifikácia a používateľské účty. Comment[sv]=Autentisering och användarkonton. Comment[es]=Autenticación y cuentas de usuario. -Type=Settings +Type=Application Exec=cosmic-settings users Terminal=false Categories=COSMIC diff --git a/resources/applications/com.system76.CosmicSettings.Vpn.desktop b/resources/applications/com.system76.CosmicSettings.Vpn.desktop index f5cebaa6d..ac84d742e 100644 --- a/resources/applications/com.system76.CosmicSettings.Vpn.desktop +++ b/resources/applications/com.system76.CosmicSettings.Vpn.desktop @@ -11,7 +11,7 @@ Comment[hu]=VPN-kapcsolatok és kapcsolódási profilok. Comment[pt]=Conexões VPN e perfis de conexão. Comment[nl]=VPN-verbindingen en VPN-profielen. Comment[es]=Conexiones VPN y perfiles de conexión. -Type=Settings +Type=Application Exec=cosmic-settings vpn Terminal=false Categories=COSMIC diff --git a/resources/applications/com.system76.CosmicSettings.Wallpaper.desktop b/resources/applications/com.system76.CosmicSettings.Wallpaper.desktop index 8a8cf5c7f..17f12a577 100644 --- a/resources/applications/com.system76.CosmicSettings.Wallpaper.desktop +++ b/resources/applications/com.system76.CosmicSettings.Wallpaper.desktop @@ -17,11 +17,11 @@ Comment[zh_CN]=壁纸图片、颜色和幻灯片选项 Comment[pl]=Obraz tła, kolory i opcje pokazu slajdów. Comment[hu]=Háttérképek, színek és diavetítési beállítások. Comment[pt]=Imagens de plano de fundo, cores, e opções de exibição em slide. -Comment[nl]=Schermachtergrond: Afbeeldingen, kleuren en diavoorstellingen. +Comment[nl]=Schermachtergrond: Afbeeldingen, kleuren en diavoorstellingen. Comment[sk]=Obrázky tapiet, farby a možnosti prezentácie. Comment[sv]=Bakgrundsbilder, färger, och bildspelsalternativ. Comment[es]=Imágenes de fondo, colores y opciones de carrusel de imágenes. -Type=Settings +Type=Application Exec=cosmic-settings wallpaper Terminal=false Categories=COSMIC diff --git a/resources/applications/com.system76.CosmicSettings.WindowManagement.desktop b/resources/applications/com.system76.CosmicSettings.WindowManagement.desktop index d7799445d..1bf76df6f 100644 --- a/resources/applications/com.system76.CosmicSettings.WindowManagement.desktop +++ b/resources/applications/com.system76.CosmicSettings.WindowManagement.desktop @@ -21,7 +21,7 @@ Comment[nl]=Opties voor de Supertoets, vensterbeheer en aanvullende opties voor Comment[sk]=Akcia klávesu Super, možnosti ovládania okien a ďalšie možnosti dlaždicovania okien. Comment[sv]=Åtgärd för Super-tangent, fönsterkontroll alternativ, och ytterligare fönsterplacerings alternativ. Comment[es]=Acción de la tecla Super, opciones de control de ventana y opciones adicionales de mosaico de ventana. -Type=Settings +Type=Application Exec=cosmic-settings window-management Terminal=false Categories=COSMIC diff --git a/resources/applications/com.system76.CosmicSettings.Wired.desktop b/resources/applications/com.system76.CosmicSettings.Wired.desktop index 93f285d35..ad0e8680c 100644 --- a/resources/applications/com.system76.CosmicSettings.Wired.desktop +++ b/resources/applications/com.system76.CosmicSettings.Wired.desktop @@ -21,7 +21,7 @@ Comment[nl]=Kabelverbinding en verbindingsprofielen. Comment[sk]=Káblové pripojenia a profily pripojení. Comment[sv]=Trådbundna anslutningar och anslutningsprofiler. Comment[es]=Conexiones cableadas y perfiles de conexión. -Type=Settings +Type=Application Exec=cosmic-settings wired Terminal=false Categories=COSMIC diff --git a/resources/applications/com.system76.CosmicSettings.Wireless.desktop b/resources/applications/com.system76.CosmicSettings.Wireless.desktop index df482a7bf..45d49209f 100644 --- a/resources/applications/com.system76.CosmicSettings.Wireless.desktop +++ b/resources/applications/com.system76.CosmicSettings.Wireless.desktop @@ -21,7 +21,7 @@ Comment[nl]=Wifiverbinding en verbindingsprofielen. Comment[sk]=Wi-Fi pripojenia a profily pripojení. Comment[sv]=Wi-Fi-anslutningar och anslutningsprofiler. Comment[es]=Conexiones Wi-Fi y perfiles de conexión. -Type=Settings +Type=Application Exec=cosmic-settings wireless Terminal=false Categories=COSMIC diff --git a/resources/applications/com.system76.CosmicSettings.Workspaces.desktop b/resources/applications/com.system76.CosmicSettings.Workspaces.desktop index e34ed3642..32c016011 100644 --- a/resources/applications/com.system76.CosmicSettings.Workspaces.desktop +++ b/resources/applications/com.system76.CosmicSettings.Workspaces.desktop @@ -19,7 +19,7 @@ Comment[pt]=Orientação e comportamento da área de trabalho. Comment[sk]=Orientácia a správanie pracovných priestorov. Comment[sv]=Arbetsytors orientering och beteende. Comment[es]=Orientación de los espacios de trabajo y comportamiento. -Type=Settings +Type=Application Exec=cosmic-settings workspaces Terminal=false Categories=COSMIC diff --git a/subscriptions/a11y-manager/Cargo.toml b/subscriptions/a11y-manager/Cargo.toml index 65ccc44dc..c70252575 100644 --- a/subscriptions/a11y-manager/Cargo.toml +++ b/subscriptions/a11y-manager/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cosmic-settings-a11y-manager-subscription" -version = "0.1.0" +version = "1.0.0-beta6" edition = "2024" license = "MPL-2.0" rust-version.workspace = true diff --git a/subscriptions/accessibility/Cargo.toml b/subscriptions/accessibility/Cargo.toml index dc15514ac..a49dd2fcc 100644 --- a/subscriptions/accessibility/Cargo.toml +++ b/subscriptions/accessibility/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cosmic-settings-accessibility-subscription" -version = "0.1.0" +version = "1.0.0-beta6" edition = "2024" license = "MPL-2.0" rust-version.workspace = true diff --git a/subscriptions/airplane-mode/Cargo.toml b/subscriptions/airplane-mode/Cargo.toml index b05e4086b..0cd4fb86d 100644 --- a/subscriptions/airplane-mode/Cargo.toml +++ b/subscriptions/airplane-mode/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cosmic-settings-airplane-mode-subscription" -version = "0.1.0" +version = "1.0.0-beta6" edition = "2024" license = "MPL-2.0" rust-version.workspace = true diff --git a/subscriptions/bluetooth/Cargo.toml b/subscriptions/bluetooth/Cargo.toml index c882a21f3..141eb3cec 100644 --- a/subscriptions/bluetooth/Cargo.toml +++ b/subscriptions/bluetooth/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cosmic-settings-bluetooth-subscription" -version = "0.1.0" +version = "1.0.0-beta6" edition = "2024" license = "MPL-2.0" rust-version.workspace = true diff --git a/subscriptions/network-manager/Cargo.toml b/subscriptions/network-manager/Cargo.toml index b34f45719..c7ef34831 100644 --- a/subscriptions/network-manager/Cargo.toml +++ b/subscriptions/network-manager/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cosmic-settings-network-manager-subscription" -version = "0.1.0" +version = "1.0.0-beta6" edition = "2024" license = "MPL-2.0" rust-version.workspace = true diff --git a/subscriptions/settings-daemon/Cargo.toml b/subscriptions/settings-daemon/Cargo.toml index e3d9402a2..f6a6e21ec 100644 --- a/subscriptions/settings-daemon/Cargo.toml +++ b/subscriptions/settings-daemon/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cosmic-settings-daemon-subscription" -version = "0.1.0" +version = "1.0.0-beta6" edition = "2024" rust-version.workspace = true publish = true diff --git a/subscriptions/sound/Cargo.toml b/subscriptions/sound/Cargo.toml index 147964c08..1855e1fa7 100644 --- a/subscriptions/sound/Cargo.toml +++ b/subscriptions/sound/Cargo.toml @@ -1,19 +1,19 @@ [package] name = "cosmic-settings-sound-subscription" -version = "1.0.0-beta1" +version = "1.0.0-beta6" edition = "2024" rust-version.workspace = true license = "MPL-2.0" publish = true [dependencies] -async-fn-stream = "0.3.2" +cosmic-pipewire = { path = "../../crates/cosmic-pipewire" } +crossbeam-queue = "0.3.12" futures = "0.3.31" -indexmap = "2.12.0" +intmap = "3.1.2" libcosmic = { git = "https://github.com/pop-os/libcosmic" } -libpulse-binding = "2.30.1" log = "0.4.28" -pipewire = "0.8" -rustix = "1.1.2" -tokio = "1.48.0" +numtoa = "1.0.0-alpha1" +rustix = "1.0.8" +tokio = { version = "1.47.1", features = ["process", "rt", "time"] } tracing = "0.1.41" diff --git a/subscriptions/sound/src/lib.rs b/subscriptions/sound/src/lib.rs index 54b0a4bb7..13fa3bd04 100644 --- a/subscriptions/sound/src/lib.rs +++ b/subscriptions/sound/src/lib.rs @@ -1,95 +1,52 @@ // Copyright 2024 System76 // SPDX-License-Identifier: MPL-2.0 -pub mod pipewire; -pub mod pulse; - use cosmic::Task; use cosmic::iced_futures::MaybeSend; -use futures::{Stream, StreamExt}; -use indexmap::IndexMap; -use std::{collections::BTreeMap, sync::Arc, time::Duration}; +use cosmic_pipewire as pipewire; +use futures::{FutureExt, SinkExt, Stream}; +use intmap::IntMap; +use pipewire::Availability; +use std::{process::Stdio, sync::Arc, time::Duration}; +pub type DeviceId = u32; pub type NodeId = u32; -pub type ProfileId = u32; +pub type ProfileId = i32; +pub type RouteId = u32; pub fn watch() -> impl Stream + MaybeSend + 'static { - async_fn_stream::fn_stream(|emitter| async move { + cosmic::iced_futures::stream::channel(1, |mut emitter| async move { let (cancel_tx, mut cancel_rx) = futures::channel::oneshot::channel::<()>(); + let events = Arc::new(crossbeam_queue::SegQueue::new()); - let (tx, mut pulse_rx) = futures::channel::mpsc::channel(1); - let _pulse_handle = std::thread::spawn(move || { - pulse::thread(tx); - }); - - let (tx, mut pw_rx) = futures::channel::mpsc::channel(1); - let (_pipewire_handle, pipewire_terminate) = pipewire::thread(tx); - - emitter - .emit( + _ = emitter + .send( Message::SubHandle(Arc::new(SubscriptionHandle { cancel_tx, - pipewire: pipewire_terminate, + pipewire: pipewire::run({ + let events = events.clone(); + move |event| { + events.push(event); + } + }), })) .into(), ) .await; - let mut pulse_channels = None; - let mut balance = None; - let mut source_volume = None; - let mut sink_volume = None; - let mut events = Vec::new(); let mut timer = tokio::time::interval(Duration::from_millis(64)); loop { - tokio::select! { - event = pulse_rx.next() => { - let Some(event) = event else { - break; - }; - - match event { - pulse::Event::Channels(channels) => pulse_channels = Some(channels), - pulse::Event::SinkVolume(volume) => sink_volume = Some(volume), - pulse::Event::SourceVolume(volume) => source_volume = Some(volume), - pulse::Event::Balance(value) => balance = Some(value), - _ => { - events.push(Server::Pulse(event)); - timer.reset(); + futures::select! { + _ = timer.tick().fuse() => { + if !events.is_empty() { + let mut batched = Vec::with_capacity(events.len()); + while let Some(event) = events.pop() { + batched.push(event); } - } - } - - event = pw_rx.next() => { - let Some(event) = event else { - break; - }; - - timer.reset(); - events.push(Server::Pipewire(event)); - } - - _ = timer.tick() => { - if let Some(channels) = pulse_channels.take() { - events.push(Server::Pulse(pulse::Event::Channels(channels))); - } - if let Some(volume) = sink_volume.take() { - events.push(Server::Pulse(pulse::Event::SinkVolume(volume))); - } - - if let Some(volume) = source_volume.take() { - events.push(Server::Pulse(pulse::Event::SourceVolume(volume))); - } - - if let Some(balance) = balance.take() { - events.push(Server::Pulse(pulse::Event::Balance(balance))); - } - - if !events.is_empty() { - emitter - .emit(Message::Server(Arc::from(std::mem::take(&mut events)))) + _ = emitter + .send(Message::Server(Arc::from(batched))) .await; } } @@ -98,9 +55,6 @@ pub fn watch() -> impl Stream + MaybeSend + 'static { } } - drop(pulse_rx); - drop(pw_rx); - futures::future::pending::().await; }) } @@ -108,56 +62,58 @@ pub fn watch() -> impl Stream + MaybeSend + 'static { #[derive(Default)] pub struct Model { subscription_handle: Option, - sink_channels: Option, - devices: BTreeMap, - card_names: IndexMap, - card_profiles: IndexMap>, - active_profiles: IndexMap>, + pub device_profile_dropdowns: Vec<(DeviceId, String, Option, Vec, Vec)>, + + // Translated text + pub unplugged_text: String, + pub hd_audio_text: String, + pub usb_audio_text: String, + + device_ids: IntMap, + node_names: IntMap, + card_profile_devices: IntMap, + + device_names: IntMap, + device_profiles: IntMap>, + active_profiles: IntMap, + device_routes: IntMap>, /** Sink devices */ - /// Product names for source sink devices. + /// Description of a sink device and its port sinks: Vec, - /// Pipewire object IDs for sink devices. - sink_pw_ids: Vec, - /// Profile IDs for the actively-selected sink device. - sink_profiles: Vec, - /// Names of profiles for the actively-selected sink device. - sink_profile_names: Vec, - /// Device ID of active sink device. - active_sink_device: Option, + /// Node IDs for sinks + sink_node_ids: Vec, /// Index of active sink device. active_sink: Option, - /// Card profile index of active sink device. - active_sink_profile: Option, + /// Node ID of active sink device. + active_sink_node: Option, + /// Device ID of active sink device. + active_sink_device: Option, + /// Device identifier of the default sink. + active_sink_node_name: String, /** Source devices */ /// Product names for source devices. sources: Vec, - /// Pipewire object IDs for source devices. - source_pw_ids: Vec, - /// Profile IDs for the actively-selected source device. - source_profiles: Vec, - /// Names of profiles for the actively-selected source device. - source_profile_names: Vec, - /// Device ID of active source device. - active_source_device: Option, + /// Node IDs for sources + source_node_ids: Vec, /// Index of active source device. active_source: Option, - /// Card profile index of active source device. - active_source_profile: Option, + /// Node ID of active source device. + active_source_node: Option, + /// Device ID of active source device. + active_source_device: Option, + /// Node identifier of the default source. + active_source_node_name: String, - /// Device identifier of the default sink. - default_sink: String, - /// Device identifier of the default source. - default_source: String, + changing_sink_device: Option, + changing_source_device: Option, pub sink_volume_text: String, pub source_volume_text: String, - - pub sink_balance_text: Option, pub sink_balance: Option, pub sink_volume: u32, @@ -165,12 +121,8 @@ pub struct Model { pub sink_mute: bool, sink_volume_debounce: bool, - sink_balance_debounce: bool, pub source_mute: bool, source_volume_debounce: bool, - - changing_sink_profile: Option, - changing_source_profile: Option, } impl Model { @@ -178,125 +130,159 @@ impl Model { self.active_sink } - pub fn active_sink_profile(&self) -> Option { - self.active_sink_profile - } - pub fn active_source(&self) -> Option { self.active_source } - pub fn active_source_profile(&self) -> Option { - self.active_source_profile - } - pub fn sinks(&self) -> &[String] { &self.sinks } - pub fn sink_profiles(&self) -> &[String] { - &self.sink_profiles - } - pub fn sources(&self) -> &[String] { &self.sources } - pub fn source_profiles(&self) -> &[String] { - &self.source_profiles - } - pub fn clear(&mut self) { if let Some(handle) = self.subscription_handle.take() { _ = handle.cancel_tx.send(()); - _ = handle.pipewire.send(()); + _ = handle.pipewire.send(pipewire::Request::Quit); + } + } + + /// Send a message to the pipewire-rs thread. + pub fn pipewire_send(&self, request: pipewire::Request) { + if let Some(handle) = self.subscription_handle.as_ref() { + _ = handle.pipewire.send(request); + } + } + + /// Sets and applies a profile to a device with wpctl. + /// + /// Requires using the device ID rather than a node ID. + pub fn set_profile(&mut self, device_id: DeviceId, index: u32, save: bool) { + if save { + self.changing_sink_device = self + .device_ids + .iter() + .find(|(node_id, _device)| self.active_sink_node == Some(*node_id)) + .and_then(|(_node_id, &device)| { + if device == device_id { + Some(device_id) + } else { + None + } + }); + + self.changing_source_device = self + .device_ids + .iter() + .find(|(node_id, _device)| self.active_source_node == Some(*node_id)) + .and_then(|(_node_id, &device)| { + if device == device_id { + Some(device_id) + } else { + None + } + }); } - if let Some(channel) = self.sink_channels.take() { - channel.quit(); + let mut update = false; + + if let Some(profiles) = self.device_profiles.get(device_id) { + for profile in profiles { + if profile.index as u32 == index { + self.active_profiles.insert(device_id, profile.clone()); + self.pipewire_send(pipewire::Request::SetProfile(device_id, index, save)); + update = true; + } + } + + if update { + self.update_ui_profiles(); + } + + // Use pw-cli as a fallback in case it wasn't set correctly. + tokio::spawn(async move { + set_profile(device_id, index, save).await; + }); } } - pub fn sink_balance_changed(&mut self, balance: u32) -> Task { - self.sink_balance = Some((balance as f32 - 100.) / 100.); - self.sink_balance_text = Some(format!("{balance:.2}")); - if self.sink_balance_debounce { + /// Change the balance of channel volumes on the sink device. + pub fn set_sink_balance(&mut self, balance: u32) -> Task { + self.sink_balance = (balance != 100).then(|| balance as f32 / 100.); + if self.sink_volume_debounce { return Task::none(); } - if !self - .sink_pw_ids - .get(self.active_sink.unwrap_or(0)) - .is_none() - { - self.sink_balance_debounce = true; + if let Some(id) = self.active_sink_node { + self.sink_volume_debounce = true; return cosmic::Task::future(async move { - tokio::time::sleep(Duration::from_millis(64)).await; - Message::SinkBalanceApply.into() + tokio::time::sleep(Duration::from_millis(128)).await; + Message::SinkVolumeApply(id).into() }); } Task::none() } - pub fn sink_changed(&mut self, pos: usize) -> Task { - if let Some(&node_id) = self.sink_pw_ids.get(pos) { - for card in self.devices.values() { - for (&nid, port) in &card.ports { - if node_id == nid { - self.active_sink = Some(pos); - let identifier = port.identifier.clone(); - return cosmic::Task::future(async move { - wpctl_set_default(nid).await; - Message::SetDefaultSink(identifier).into() - }); - } - } - } + /// Change the default sink device + pub fn set_default_sink(&mut self, pos: usize) -> Task { + if let Some(&node_id) = self.sink_node_ids.get(pos) { + self.set_default_sink_node_id(node_id); } Task::none() } - pub fn sink_mute_toggle(&mut self) { - self.sink_mute = !self.sink_mute; - if let Some(&node_id) = self.sink_pw_ids.get(self.active_sink.unwrap_or(0)) { - wpctl_set_mute(node_id, self.sink_mute); - } + pub fn set_default_sink_node_id(&mut self, node_id: NodeId) { + tracing::debug!(target: "sound", "set default sink node {node_id}"); + self.set_default_sink_id(node_id); + + // Use pactl if the node is not a device node. + let virtual_sink_name: Option = if self.device_ids.contains_key(node_id) { + None + } else if let Some(name) = self.node_names.get(node_id) { + Some(name.clone()) + } else { + None + }; + + tokio::task::spawn(async move { + if let Some(node_name) = virtual_sink_name { + pactl_set_default_sink(&node_name).await + } else { + set_default(node_id).await + } + }); } - pub fn sink_profile_changed(&mut self, profile: usize) -> Task { - self.active_sink_profile = Some(profile); - - if let Some(profile) = self.sink_profile_names.get(profile).cloned() { - if let Some(device_id) = self.active_sink_device.clone() { - if let Some(name) = self.card_names.get(&device_id).cloned() { - self.active_profiles - .insert(device_id.clone(), Some(profile.clone())); - - self.changing_sink_profile = Some(device_id); - return cosmic::Task::future(async move { - pactl_set_card_profile(name, profile).await; - }) - .discard(); - } + /// Toggle the mute property of the sink device. + pub fn toggle_sink_mute(&mut self) { + self.sink_mute = !self.sink_mute; + if let Some(node_id) = self.active_sink_node { + let mute = self.sink_mute; + if let Some(handle) = self.subscription_handle.as_mut() { + _ = handle + .pipewire + .send(pipewire::Request::SetNodeMute(node_id, mute)); } } - - Task::none() } - pub fn sink_volume_changed(&mut self, volume: u32) -> Task { + /// Change the sink device's volume. + pub fn set_sink_volume(&mut self, volume: u32) -> Task { self.sink_volume = volume; - self.sink_volume_text = volume.to_string(); + self.sink_volume_text = numtoa::BaseN::<10>::u32(volume).as_str().to_owned(); if self.sink_volume_debounce { return Task::none(); } - if let Some(&node_id) = self.sink_pw_ids.get(self.active_sink.unwrap_or(0)) { + // Wait for the debounce duration before applying the volume change. + if let Some(node_id) = self.active_sink_node { self.sink_volume_debounce = true; return cosmic::Task::future(async move { - tokio::time::sleep(Duration::from_millis(64)).await; + tokio::time::sleep(Duration::from_millis(128)).await; Message::SinkVolumeApply(node_id).into() }); } @@ -304,63 +290,63 @@ impl Model { Task::none() } - pub fn source_changed(&mut self, pos: usize) -> Task { - if let Some(&node_id) = self.source_pw_ids.get(pos) { - for card in self.devices.values() { - for (&nid, port) in &card.ports { - if node_id == nid { - self.active_source = Some(pos); - let identifier = port.identifier.clone(); - return cosmic::Task::future(async move { - wpctl_set_default(nid).await; - Message::SetDefaultSource(identifier).into() - }); - } - } - } + /// Change the default source device. + pub fn set_default_source(&mut self, pos: usize) -> Task { + if let Some(&node_id) = self.source_node_ids.get(pos) { + self.set_default_source_node_id(node_id); } Task::none() } - pub fn source_mute_toggle(&mut self) { - self.source_mute = !self.source_mute; - if let Some(&node_id) = self.source_pw_ids.get(self.active_source.unwrap_or(0)) { - wpctl_set_mute(node_id, self.source_mute); - } + pub fn set_default_source_node_id(&mut self, node_id: NodeId) { + tracing::debug!(target: "sound", "set default source node {node_id}"); + self.set_default_source_id(node_id); + + // Use pactl if the node is not a device node. + let virtual_source_name: Option = if self.device_ids.contains_key(node_id) { + None + } else if let Some(name) = self.node_names.get(node_id) { + Some(name.clone()) + } else { + None + }; + + tokio::task::spawn(async move { + if let Some(node_name) = virtual_source_name { + pactl_set_default_source(&node_name).await + } else { + set_default(node_id).await + } + }); } - pub fn source_profile_changed(&mut self, profile: usize) -> Task { - self.active_source_profile = Some(profile); - if let Some(profile) = self.source_profile_names.get(profile).cloned() { - if let Some(device_id) = self.active_source_device.clone() { - if let Some(name) = self.card_names.get(&device_id).cloned() { - self.active_profiles - .insert(device_id.clone(), Some(profile.clone())); - - self.changing_source_profile = Some(device_id.clone()); - return cosmic::Task::future(async move { - pactl_set_card_profile(name, profile).await; - }) - .discard(); - } + /// Toggle the mute property of the source device. + pub fn toggle_source_mute(&mut self) { + self.source_mute = !self.source_mute; + if let Some(node_id) = self.active_source_node { + let mute = self.source_mute; + if let Some(handle) = self.subscription_handle.as_mut() { + _ = handle + .pipewire + .send(pipewire::Request::SetNodeMute(node_id, mute)); } } - - Task::none() } - pub fn source_volume_changed(&mut self, volume: u32) -> Task { + /// Change the source device's volume. + pub fn set_source_volume(&mut self, volume: u32) -> Task { self.source_volume = volume; - self.source_volume_text = volume.to_string(); + self.source_volume_text = numtoa::BaseN::<10>::u32(volume).as_str().to_owned(); if self.source_volume_debounce { return Task::none(); } - if let Some(&node_id) = self.source_pw_ids.get(self.active_source.unwrap_or(0)) { + // Wait for the debounce duration before applying the volume change. + if let Some(node_id) = self.active_source_node { self.source_volume_debounce = true; return cosmic::Task::future(async move { - tokio::time::sleep(Duration::from_millis(64)).await; + tokio::time::sleep(Duration::from_millis(128)).await; Message::SourceVolumeApply(node_id).into() }); } @@ -371,404 +357,503 @@ impl Model { pub fn update(&mut self, message: Message) -> Task { match message { Message::Server(events) => { - for event in Arc::into_inner(events).into_iter().flatten() { - match event { - Server::Pulse(event) => match event { - pulse::Event::SourceVolume(volume) => { - if self.sink_volume_debounce { - return Task::none(); - } + Arc::into_inner(events) + .into_iter() + .flatten() + .for_each(|event| self.pipewire_update(event)); + } - self.source_volume = volume; - self.source_volume_text = volume.to_string(); - } + Message::SinkVolumeApply(node_id) => { + self.sink_volume_debounce = false; + self.pipewire_send(pipewire::Request::SetNodeVolume( + node_id, + self.sink_volume as f32 / 100.0, + self.sink_balance, + )); + } - pulse::Event::SinkVolume(volume) => { - if self.sink_volume_debounce { - return Task::none(); - } + Message::SourceVolumeApply(node_id) => { + self.source_volume_debounce = false; + self.pipewire_send(pipewire::Request::SetNodeVolume( + node_id, + self.source_volume as f32 / 100.0, + None, + )); + } - self.sink_volume = volume; - self.sink_volume_text = volume.to_string(); - } + Message::SubHandle(handle) => { + if let Some(handle) = Arc::into_inner(handle) { + self.subscription_handle = Some(handle); + } + } + } - pulse::Event::CardInfo(card) => { - let device_id = match card.variant { - pulse::DeviceVariant::Alsa { alsa_card, .. } => { - DeviceId::Alsa(alsa_card) - } - pulse::DeviceVariant::Bluez5 { address, .. } => { - DeviceId::Bluez5(address) - } - }; - - eprintln!( - "inserting card {:?}: name={}, active_profile={:?}, profiles={:?}", - device_id, - card.name, - card.active_profile.as_ref().map(|p| p.name.as_str()), - card.profiles - ); + Task::none() + } - self.card_names.insert(device_id.clone(), card.name); - self.card_profiles.insert(device_id.clone(), card.profiles); - self.active_profiles - .insert(device_id, card.active_profile.map(|p| p.name)); - } + fn pipewire_update(&mut self, event: pipewire::Event) { + match event { + pipewire::Event::NodeProperties(id, props) => { + if self.active_sink_node == Some(id) { + if self.sink_volume_debounce { + return; + } - pulse::Event::DefaultSink(sink) => { - if !self.changing_sink_profile.is_some() { - self.set_default_sink(sink); - } - } - pulse::Event::DefaultSource(source) => { - if !self.changing_source_profile.is_some() { - self.set_default_source(source); - } - } - pulse::Event::SinkMute(mute) => { - self.sink_mute = mute; - } - pulse::Event::SourceMute(mute) => { - self.source_mute = mute; - } - pulse::Event::Balance(balance) => { - self.sink_balance = balance; - self.sink_balance_text = balance.map(|b| format!("{b:.2}")); - } - pulse::Event::Channels(channels) => { - self.sink_channels = Some(channels); - } - }, + if let Some(mute) = props.mute { + self.sink_mute = mute; + } - Server::Pipewire(event) => match event { - pipewire::DeviceEvent::Add(device) => { - let device_id = match device.variant { - pipewire::DeviceVariant::Alsa { alsa_card, .. } => { - DeviceId::Alsa(alsa_card) - } - pipewire::DeviceVariant::Bluez5 { address, .. } => { - DeviceId::Bluez5(address) - } - pipewire::DeviceVariant::Unknown {} => DeviceId::Unknown {}, - }; - - match device.media_class { - pipewire::MediaClass::Sink => { - self.sinks.push(device.product_name.clone()); - self.sink_pw_ids.push(device.object_id); - - sort_pulse_devices(&mut self.sinks, &mut self.sink_pw_ids); - - if self.default_sink == device.node_name { - self.active_sink_device = Some(device_id.clone()); - self.active_sink = self - .sinks - .iter() - .position(|s| *s == device.product_name); - self.set_sink_profiles(&device_id); - } - } + if let Some(channel_volumes) = props.channel_volumes { + let (volume, balance) = + pipewire::volume::from_channel_volumes(&channel_volumes); - pipewire::MediaClass::Source => { - self.sources.push(device.product_name.clone()); - self.source_pw_ids.push(device.object_id); - - sort_pulse_devices( - &mut self.sources, - &mut self.source_pw_ids, - ); - - if self.default_source == device.node_name { - self.active_source = self - .sources - .iter() - .position(|s| *s == device.product_name); - self.active_source_device = Some(device_id.clone()); - self.set_source_profiles(&device_id); - } - } - } + self.sink_balance = balance; + self.sink_volume = (volume * 100.0) as u32; + self.sink_volume_text = numtoa::BaseN::<10>::u32(self.sink_volume) + .as_str() + .to_owned(); + } + } else if self.active_source_node == Some(id) { + if self.source_volume_debounce { + return; + } - let card = self.devices.entry(device_id).or_insert_with(|| Card { - ports: IndexMap::new(), - }); - - card.ports.insert( - device.object_id, - CardPort { - class: device.media_class, - identifier: device.node_name, - description: device.product_name, - }, - ); + if let Some(mute) = props.mute { + self.source_mute = mute; + } - card.ports.sort_unstable_by(|_, av, _, bv| { - av.description.cmp(&bv.description) - }); - } + if let Some(channel_volumes) = props.channel_volumes { + let (volume, _balance) = + pipewire::volume::from_channel_volumes(&channel_volumes); + self.source_volume = (volume * 100.0) as u32; + self.source_volume_text = numtoa::BaseN::<10>::u32(self.source_volume) + .as_str() + .to_owned(); + } + } + } - pipewire::DeviceEvent::Remove(node_id) => { - let mut remove = None; - for (card_id, card) in &mut self.devices { - if card.ports.shift_remove(&node_id).is_some() { - if card.ports.is_empty() { - remove = Some(card_id.clone()); - } - break; - } - } + pipewire::Event::ActiveProfile(id, profile) => { + tracing::debug!( + target: "sound", + "Device {id} active profile changed to {}: {}", + profile.index, + profile.description + ); + + let index = profile.index as u32; + let prev = self.active_profiles.insert(id, profile.clone()); + self.update_ui_profiles(); + if let Some(prev) = prev { + if prev.index == profile.index { + return; + } - if let Some(card_id) = remove { - _ = self.devices.remove(&card_id); - } + tracing::debug!( + target: "sound", + "Device {id} profile changed from {} to {}: {}", + prev.index, profile.index, profile.description + ); + } else { + // Use pw-cli to reset the profile in case wireplumber has invalid state. + // Profiles set by us do not need to use this. + tracing::debug!( + target: "sound", + "Device {id} initialized with profile {}: {}", index, profile.description + ); + + self.set_profile(id, index, false); + } + } - if let Some(pos) = - self.sink_pw_ids.iter().position(|&id| id == node_id) - { - _ = self.sink_pw_ids.remove(pos); - _ = self.sinks.remove(pos); - if self.active_sink == Some(pos) { - self.active_sink = None; - self.active_sink_device = None; - self.active_sink_profile = None; - } else { - self.active_sink = self.active_sink.map(|active_pos| { - if active_pos > pos { - active_pos - 1 - } else { - active_pos - } - }); - } - } else if let Some(pos) = - self.source_pw_ids.iter().position(|&id| id == node_id) - { - _ = self.source_pw_ids.remove(pos); - _ = self.sources.remove(pos); - if self.active_source == Some(pos) { - self.active_source = None; - self.active_source_device = None; - self.active_source_profile = None; - } - } - } - }, + pipewire::Event::ActiveRoute(id, _index, route) => { + tracing::debug!( + target: "sound", + "Device {id} active route changed to {}: {}", + route.index, + route.description + ); + + self.update_device_route_name(&route, id); + + let (active_device, node_ids, set_default_node): ( + Option, + &[NodeId], + fn(&mut Self, NodeId), + ) = match route.direction { + pipewire::Direction::Output => ( + self.active_sink_device.clone(), + &self.sink_node_ids, + Self::set_default_sink_node_id, + ), + pipewire::Direction::Input => ( + self.active_source_device.clone(), + &self.source_node_ids, + Self::set_default_source_node_id, + ), + }; + + if active_device == Some(id) { + for (node_id, &device) in &self.device_ids { + if device == id && node_ids.contains(&node_id) { + set_default_node(self, node_id); + break; + } } } + } - let mut tasks = Task::none(); - - if let Some(device_id) = self.changing_sink_profile.take() { - tasks = tasks.chain(self.sink_profile_select(device_id)); + pipewire::Event::AddProfile(id, profile) => { + if let Some(p) = self.active_profiles.get_mut(id) { + if p.index == profile.index { + *p = profile.clone(); + } } - if let Some(device_id) = self.changing_source_profile.take() { - tasks = tasks.chain(self.source_profile_select(device_id)); + let profiles = self.device_profiles.entry(id).or_default(); + for p in profiles.iter_mut() { + if p.index == profile.index { + *p = profile; + + self.update_ui_profiles(); + return; + } } - return tasks; + profiles.push(profile); + self.update_ui_profiles(); } - Message::SinkBalanceApply => { - self.sink_balance_debounce = false; - if let Some((balance, channels)) = - self.sink_balance.zip(self.sink_channels.as_mut()) - { - channels.set_balance(balance); - } + pipewire::Event::AddRoute(id, index, route) => self.add_route(id, index, route), + + pipewire::Event::AddDevice(device) => { + tracing::debug!(target: "sound", "Device {} added: {}", device.id, device.name); + self.device_names + .insert(device.id, self.translate_device_name(&device.name)); } - Message::SinkVolumeApply(_) => { - self.sink_volume_debounce = false; - if let Some(channels) = self.sink_channels.as_mut() { - channels.set_volume(self.sink_volume as f32 / 100.); + pipewire::Event::AddNode(node) => { + tracing::debug!(target: "sound", "Node {} added: {}", node.object_id, node.node_name); + // Device nodes will have device and card profile device IDs. + // Virtual sinks/sources do not have these. + if let Some(device_id) = node.device_id { + self.device_ids.insert(node.object_id, device_id); + + // This is the device number of the route. This is used with the + // device ID to set properties for a route. + if let Some(card_profile_device) = node.card_profile_device { + self.card_profile_devices + .insert(node.object_id, card_profile_device); + } } - } - Message::SourceVolumeApply(node_id) => { - self.source_volume_debounce = false; - wpctl_set_volume(node_id, self.source_volume); - } + let description = self.translate_device_name(&node.description); - Message::SetDefaultSink(identifier) => self.set_default_sink(identifier), + // The default sink/source is defined by a node's name. We use this when setting + // virtual sink/source nodes with pactl; and when pipewire notifies us of a new + // default sink/source. + if self + .node_names + .insert(node.object_id, node.node_name.clone()) + .is_none() + { + // Use the device.profile.description as the route name by default for the UI. + let name = if node.device_profile_description.is_empty() { + description + } else { + [&node.device_profile_description, " - ", &description].concat() + }; - Message::SetDefaultSource(identifier) => self.set_default_source(identifier), + // Check if the node is a sink or a source, and append it to the relevant collections. + match node.media_class { + pipewire::MediaClass::Sink => { + self.sinks.push(name); + self.sink_node_ids.push(node.object_id); + + // Set the sink as the default if it matches the server. + if self.active_sink_node_name == node.node_name { + tracing::debug!( + target: "sound", + "Node {} ({}) was the default sink", + node.object_id, + node.node_name + ); + self.set_default_sink_node_id(node.object_id); + } else if let Some(device_id) = self.changing_sink_device { + for (node_id, &device) in &self.device_ids { + if device == device_id && self.sink_node_ids.contains(&node_id) + { + self.changing_sink_device = None; + self.set_default_sink_node_id(node_id); + return; + } + } + } + } - Message::SubHandle(handle) => { - if let Some(handle) = Arc::into_inner(handle) { - self.subscription_handle = Some(handle); + pipewire::MediaClass::Source => { + self.sources.push(name); + self.source_node_ids.push(node.object_id); + + // Set the source as the default if it matches the server. + if self.active_source_node_name == node.node_name { + tracing::debug!( + target: "sound", + "Node {} ({}) was the default source", + node.object_id, + node.node_name + ); + self.set_default_source_node_id(node.object_id); + } else if let Some(device_id) = self.changing_source_device { + for (node_id, &device) in &self.device_ids { + if device == device_id + && self.source_node_ids.contains(&node_id) + { + self.changing_source_device = None; + self.set_default_source_node_id(node_id); + return; + } + } + } + } + } } } - } - - Task::none() - } - fn device_profiles(&self, device_id: &DeviceId) -> (Vec, Vec, Option) { - let (profiles, profile_descriptions): (Vec, Vec) = self - .card_profiles - .get(device_id) - .map_or((Vec::new(), Vec::new()), |profiles| { - profiles - .iter() - .filter(|p| p.available && p.name != "off") - .map(|p| (p.name.clone(), p.description.clone())) - .collect() - }); - - let active_profile = self.active_profiles.get(device_id).and_then(|profile| { - profile - .as_ref() - .and_then(|profile| profiles.iter().position(|p| p == profile)) - }); + pipewire::Event::DefaultSink(node_name) => { + tracing::debug!(target: "sound", "default sink node changed to {node_name}"); + if self.active_sink_node_name == node_name { + return; + } - (profiles, profile_descriptions, active_profile) - } + if let Some(id) = self.node_id_from_name(&node_name) { + self.set_default_sink_id(id); + } - /// Update the state of the default sink and its profiles. - fn set_default_sink(&mut self, sink: String) { - if self.default_sink == sink { - return; - } + self.active_sink_node_name = node_name; + } - self.default_sink = sink; + pipewire::Event::DefaultSource(node_name) => { + tracing::debug!(target: "sound", "default source node changed to {node_name}"); + if self.active_source_node_name == node_name { + return; + } - for (device_id, card) in &self.devices { - for (&node_id, card_port) in &card.ports { - if let pipewire::MediaClass::Sink = card_port.class { - if &card_port.identifier == &self.default_sink { - let device_id = device_id.clone(); - self.set_sink_profiles(&device_id); - self.active_sink = self.sink_pw_ids.iter().position(|&id| id == node_id); - self.active_sink_device = Some(device_id); - return; - } + if let Some(id) = self.node_id_from_name(&node_name) { + self.set_default_source_id(id); } + + self.active_source_node_name = node_name; } + + pipewire::Event::RemoveDevice(id) => self.remove_device(id), + pipewire::Event::RemoveNode(id) => self.remove_node(id), } } - fn set_default_source(&mut self, source: String) { - if self.default_source == source { - return; + fn add_route(&mut self, id: DeviceId, index: u32, route: pipewire::Route) { + self.update_device_route_name(&route, id); + let routes = self.device_routes.entry(id).or_default(); + if routes.len() < index as usize + 1 { + let additional = (index as usize + 1) - routes.capacity(); + routes.reserve_exact(additional); + routes.extend(std::iter::repeat(pipewire::Route::default()).take(additional)); } + routes[index as usize] = route; + } - self.default_source = source; - - for (device_id, card) in &self.devices { - for (&node_id, card_ports) in &card.ports { - if let pipewire::MediaClass::Source = card_ports.class { - if card_ports.identifier == self.default_source { - self.active_source = - self.source_pw_ids.iter().position(|&id| id == node_id); - let device_id = device_id.clone(); - self.set_source_profiles(&device_id); - self.active_source_device = Some(device_id); - return; - } + fn node_id_from_name(&self, name: &str) -> Option { + self.node_names + .iter() + .find(|&(_, n)| *n == name) + .map(|(id, _)| id) + } + + fn remove_device(&mut self, id: DeviceId) { + tracing::debug!(target: "sound", "Device {id} removed"); + _ = self.device_names.remove(id); + _ = self.device_profiles.remove(id); + _ = self.active_profiles.remove(id); + _ = self.device_routes.remove(id); + } + + fn remove_node(&mut self, id: NodeId) { + tracing::debug!(target: "sound", "Node {id} removed"); + if let Some(pos) = self.sink_node_ids.iter().position(|&node_id| node_id == id) { + self.sink_node_ids.remove(pos); + self.sinks.remove(pos); + if let Some(node_id) = self.active_sink_node { + if id == node_id { + self.active_sink = None; + self.active_sink_node = None; + self.active_sink_node_name.clear(); + } + } + } else if let Some(pos) = self + .source_node_ids + .iter() + .position(|&node_id| node_id == id) + { + self.source_node_ids.remove(pos); + self.sources.remove(pos); + if let Some(node_id) = self.active_source_node { + if id == node_id { + self.active_source = None; + self.active_source_node = None; + self.active_source_node_name.clear(); } } } + + _ = self.device_ids.remove(id); + _ = self.node_names.remove(id); + _ = self.card_profile_devices.remove(id); } - fn set_sink_profiles(&mut self, device_id: &DeviceId) { - ( - self.sink_profile_names, - self.sink_profiles, - self.active_sink_profile, - ) = self.device_profiles(device_id); + /// Set the default sink device by its the node ID. + fn set_default_sink_id(&mut self, node_id: NodeId) { + self.active_sink = self.sink_node_ids.iter().position(|&id| id == node_id); + self.active_sink_node = Some(node_id); + self.active_sink_node_name = self.node_names.get(node_id).cloned().unwrap_or_default(); + self.active_sink_device = self + .device_ids + .iter() + .find_map(|(nid, did)| if nid == node_id { Some(*did) } else { None }); } - fn set_source_profiles(&mut self, device_id: &DeviceId) { - ( - self.source_profile_names, - self.source_profiles, - self.active_source_profile, - ) = self.device_profiles(device_id); + /// Set the default source device by its the node ID. + fn set_default_source_id(&mut self, node_id: NodeId) { + self.active_source = self.source_node_ids.iter().position(|&id| id == node_id); + self.active_source_node = Some(node_id); + self.active_source_node_name = self.node_names.get(node_id).cloned().unwrap_or_default(); + self.active_source_device = self + .device_ids + .iter() + .find_map(|(nid, did)| if nid == node_id { Some(*did) } else { None }); } - fn sink_profile_select(&mut self, device_id: DeviceId) -> Task { - let sink_pos = self.active_sink.unwrap_or(0); - if let Some(card) = self.devices.get(&device_id) { - if let Some((&nid, port)) = card.ports.get_index(sink_pos) { - let identifier = port.identifier.clone(); - return cosmic::Task::future(async move { - wpctl_set_default(nid).await; - Message::SetDefaultSink(identifier) - }); - } + fn update_device_route_name(&mut self, route: &pipewire::Route, id: DeviceId) { + if matches!(route.available, Availability::No) { + return; } - Task::none() - } + let (devices, node_ids) = match route.direction { + pipewire::Direction::Output => (&mut self.sinks, &self.sink_node_ids), + pipewire::Direction::Input => (&mut self.sources, &self.source_node_ids), + }; - fn source_profile_select(&mut self, device_id: DeviceId) -> Task { - self.changing_source_profile = None; - let source_pos = self.active_source.unwrap_or(0); + for (pos, &node) in node_ids.iter().enumerate() { + let Some(&device) = self.device_ids.get(node) else { + continue; + }; - if let Some(card) = self.devices.get(&device_id) { - if let Some((&nid, port)) = card.ports.get_index(source_pos) { - let identifier = port.identifier.clone(); - return cosmic::Task::future(async move { - wpctl_set_default(nid).await; - Message::SetDefaultSource(identifier) - }); + if device != id { + continue; } - } - Task::none() - } -} + let Some(profile) = self.active_profiles.get(id) else { + continue; + }; -#[derive(Debug)] -struct Card { - ports: IndexMap, -} + if !profile.name.starts_with("pro-audio") { + let Some(&card_profile_device) = self.card_profile_devices.get(node) else { + continue; + }; -#[derive(Debug)] -struct CardPort { - class: pipewire::MediaClass, - identifier: String, - description: String, -} + if !route.devices.contains(&(card_profile_device as i32)) { + continue; + } + } + + let Some(device_name) = self.device_names.get(id) else { + continue; + }; + + devices[pos] = [&route.description, " - ", device_name].concat(); + + break; + } + } + + // Update the cached profiles for the UI. + fn update_ui_profiles(&mut self) { + self.device_profile_dropdowns = self + .device_profiles + .iter() + .filter_map(|(device_id, profiles)| { + let name = self.device_names.get(device_id)?.as_str(); + let (active_profile, indexes, descriptions) = self + .active_profiles + .get(device_id) + .and_then(|profile| { + let (indexes, descriptions): (Vec<_>, Vec<_>) = profiles + .iter() + .filter(|p| { + p.index == profile.index + || !matches!(p.available, pipewire::Availability::No) + }) + .map(|p| (p.index as u32, p.description.clone())) + .collect(); + + let pos = profiles + .iter() + .filter(|p| { + p.index == profile.index + || !matches!(p.available, pipewire::Availability::No) + }) + .enumerate() + .find(|(_, p)| p.index == profile.index) + .map(|(pos, _)| pos); + + Some((pos, indexes, descriptions)) + }) + .unwrap_or_else(|| { + let (indexes, descriptions): (Vec<_>, Vec<_>) = profiles + .iter() + .filter(|p| !matches!(p.available, pipewire::Availability::No)) + .map(|p| (p.index as u32, p.description.clone())) + .collect(); + + (None, indexes, descriptions) + }); + + Some(( + device_id, + name.to_owned(), + active_profile, + indexes, + descriptions, + )) + }) + .collect::>(); + + self.device_profile_dropdowns.sort_by(|a, b| a.1.cmp(&b.1)); + } -#[derive(Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)] -pub enum DeviceId { - Alsa(u32), - Bluez5(String), - Unknown(), + fn translate_device_name(&self, input: &str) -> String { + input + .replacen(" Controller", "", 1) + .replacen("High Definition Audio", &self.hd_audio_text, 1) + .replacen("HD Audio", &self.hd_audio_text, 1) + .replacen("USB Audio Device", &self.usb_audio_text, 1) + } } #[derive(Clone, Debug)] pub enum Message { /// Handle messages from the sound server. - Server(Arc>), - /// Set the default sink. - SetDefaultSink(String), - /// Set the default source. - SetDefaultSource(String), + Server(Arc>), /// Change the output volume. SinkVolumeApply(NodeId), - /// Change the output balance. - SinkBalanceApply, /// Change the input volume. SourceVolumeApply(NodeId), /// On init of the subscription, channels for closing background threads are given to the app. SubHandle(Arc), } -#[derive(Clone, Debug)] -pub enum Server { - /// Get default sinks/sources and their volumes/mute status. - Pulse(pulse::Event), - /// Get ALSA cards and their profiles. - Pipewire(pipewire::DeviceEvent), -} - pub struct SubscriptionHandle { cancel_tx: futures::channel::oneshot::Sender<()>, - pipewire: pipewire::Sender<()>, + pipewire: pipewire::Sender, } impl std::fmt::Debug for SubscriptionHandle { @@ -777,51 +862,60 @@ impl std::fmt::Debug for SubscriptionHandle { } } -fn sort_pulse_devices(descriptions: &mut Vec, node_ids: &mut Vec) { - let mut tmp: Vec<(String, NodeId)> = std::mem::take(descriptions) - .into_iter() - .zip(std::mem::take(node_ids)) - .collect(); - - tmp.sort_unstable_by(|(ak, _), (bk, _)| ak.cmp(bk)); - - (*descriptions, *node_ids) = tmp.into_iter().collect(); +// TODO: Use pipewire library +pub async fn set_default(id: u32) { + tracing::debug!(target: "sound", "setting default node {id}"); + let id = numtoa::BaseN::<10>::u32(id); + _ = tokio::process::Command::new("wpctl") + .args(["set-default", id.as_str()]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await; } -async fn pactl_set_card_profile(id: String, profile: String) { - tracing::debug!("pactl set-card-profile {id} {profile}"); +/// Use this to set a virtual sink as a default. +/// TODO: We should be able to set this with pipewire-rs somehow. +pub async fn pactl_set_default_sink(node_name: &str) { + tracing::debug!(target: "sound", "setting default virtual node {node_name}"); _ = tokio::process::Command::new("pactl") - .args(["set-card-profile", id.as_str(), profile.as_str()]) + .args(["set-default-sink", node_name]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) .status() - .await + .await; } -async fn wpctl_set_default(id: u32) { - tracing::debug!("wpctl set-default {id}"); - let id = id.to_string(); - _ = tokio::process::Command::new("wpctl") - .args(["set-default", id.as_str()]) +/// Use this to set a virtual sink as a default. +/// TODO: We should be able to set this with pipewire-rs somehow. +pub async fn pactl_set_default_source(node_name: &str) { + _ = tokio::process::Command::new("pactl") + .args(["set-default-source", node_name]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) .status() .await; } -fn wpctl_set_mute(id: u32, mute: bool) { - tokio::task::spawn(async move { - let default = id.to_string(); - _ = tokio::process::Command::new("wpctl") - .args(["set-mute", default.as_str(), if mute { "1" } else { "0" }]) - .status() - .await; - }); -} - -fn wpctl_set_volume(id: u32, volume: u32) { - tokio::task::spawn(async move { - let id = id.to_string(); - let volume = format!("{}.{:02}", volume / 100, volume % 100); - _ = tokio::process::Command::new("wpctl") - .args(["set-volume", id.as_str(), volume.as_str()]) - .status() - .await; - }); +// TODO: Use pipewire library +pub async fn set_profile(id: u32, index: u32, save: bool) { + let id = numtoa::BaseN::<10>::u32(id); + let index = numtoa::BaseN::<10>::u32(index); + let value = [ + "{ index: ", + index.as_str(), + if save { + ", save: true }" + } else { + ", save: false }" + }, + ] + .concat(); + + _ = tokio::process::Command::new("pw-cli") + .args(["s", id.as_str(), "Profile", &value]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await; } diff --git a/subscriptions/sound/src/pipewire.rs b/subscriptions/sound/src/pipewire.rs deleted file mode 100644 index 5daf2c333..000000000 --- a/subscriptions/sound/src/pipewire.rs +++ /dev/null @@ -1,279 +0,0 @@ -// Copyright 2024 System76 -// SPDX-License-Identifier: MPL-2.0 - -// #![deny(missing_docs)] - -pub use pipewire::channel::Sender; - -use cosmic::iced_futures::{self, Subscription, stream}; -use futures::{SinkExt, executor::block_on}; -use pipewire::{ - context::Context as PwContext, - main_loop::MainLoop as PwMainLoop, - node::{Node, NodeInfoRef, NodeState}, - proxy::{Listener, ProxyT}, - types::ObjectType, -}; -use std::{ - cell::RefCell, - collections::{BTreeMap, HashMap}, - rc::Rc, - thread::JoinHandle, -}; - -pub fn subscription() -> iced_futures::Subscription { - Subscription::run_with_id( - "pipewire", - stream::channel(20, |sender| async { - _ = thread(sender); - - futures::future::pending().await - }), - ) -} - -pub fn thread( - on_event: futures::channel::mpsc::Sender, -) -> (JoinHandle<()>, pipewire::channel::Sender<()>) { - let (pw_tx, pw_rx) = pipewire::channel::channel(); - - let handle = std::thread::spawn(move || { - devices_from_socket(pw_rx, on_event); - }); - - (handle, pw_tx) -} - -/// Node event` -#[derive(Debug)] -pub enum NodeEvent<'a> { - /// Node info - NodeInfo(u32, &'a NodeInfoRef), - /// Node removal - Remove(u32), -} - -/// Device event -#[derive(Clone, Debug)] -pub enum DeviceEvent { - /// A new device was detected. - Add(Device), - /// A device with the given object_id was removed. - Remove(u32), -} - -/// Device information -#[must_use] -#[derive(Clone, Debug)] -pub struct Device { - pub object_id: u32, - pub variant: DeviceVariant, - pub media_class: MediaClass, - pub product_name: String, - pub node_name: String, - pub state: DeviceState, -} - -#[derive(Clone, Debug, Hash, Eq, PartialEq)] -pub enum DeviceVariant { - Alsa { alsa_card: u32 }, - Bluez5 { address: String }, - Unknown {}, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum DeviceState { - Idle, - Running, - Creating, - Suspended, - Error(String), -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum MediaClass { - Source, - Sink, -} - -impl Device { - /// Attains process info from a pipewire info node. - #[must_use] - pub fn from_node(info: &NodeInfoRef) -> Option { - let props = info.props()?; - - let (variant, product_name) = if let Some(alsa_card) = - props.get("alsa.card").and_then(|v| v.parse::().ok()) - { - let device_profile_description = props.get("device.profile.description")?.to_owned(); - - let description = props.get("node.description")?; - - let description = description - .strip_suffix(&device_profile_description) - .map(str::trim_end) - .unwrap_or(description) - .replace("High Definition Audio", "HD Audio"); - - (DeviceVariant::Alsa { alsa_card }, description) - } else if let Some(address) = props - .get("api.bluez5.address") - .and_then(|v| v.parse::().ok()) - { - ( - DeviceVariant::Bluez5 { - address: address.to_owned(), - }, - props.get("node.description")?.to_owned(), - ) - } else { - ( - DeviceVariant::Unknown {}, - props.get("node.description")?.to_owned(), - ) - }; - - Some(Device { - object_id: props.get("object.id")?.parse::().ok()?, - variant, - media_class: match props.get("media.class")? { - "Audio/Sink" => MediaClass::Sink, - "Audio/Source" => MediaClass::Source, - _ => return None, - }, - product_name, - node_name: props.get("node.name")?.to_owned(), - state: match info.state() { - NodeState::Idle => DeviceState::Idle, - NodeState::Running => DeviceState::Running, - NodeState::Creating => DeviceState::Creating, - NodeState::Suspended => DeviceState::Suspended, - NodeState::Error(why) => DeviceState::Error(why.to_owned()), - }, - }) - } -} - -/// Monitors the devices from a given ``PipeWire`` socket. -/// -/// ``PipeWire`` sockets are found in `/run/user/{{UID}}/pipewire-0`. -pub fn devices_from_socket( - pw_cancel: pipewire::channel::Receiver<()>, - mut on_event: futures::channel::mpsc::Sender, -) { - let mut managed = BTreeMap::new(); - - let _res = nodes_from_socket(pw_cancel, move |main_loop, event| match event { - NodeEvent::NodeInfo(pw_id, info) => { - if let Some(device) = Device::from_node(info) { - if managed.insert(pw_id, device.object_id).is_none() { - if block_on(on_event.send(DeviceEvent::Add(device))).is_err() { - main_loop.quit(); - } - } - } - } - - NodeEvent::Remove(pw_id) => { - if let Some(object_id) = managed.remove(&pw_id) { - if block_on(on_event.send(DeviceEvent::Remove(object_id))).is_err() { - main_loop.quit(); - } - } - } - }); -} - -/// Listens to information about nodes, passing that info into a callback. -/// -/// # Errors -/// -/// Errors if the pipewire connection fails -pub fn nodes_from_socket( - pw_cancel: pipewire::channel::Receiver<()>, - on_event: impl FnMut(&PwMainLoop, NodeEvent) + 'static, -) -> Result<(), Box> { - let main_loop = PwMainLoop::new(None)?; - let context = PwContext::new(&main_loop)?; - let core = context.connect(None)?; - - // Exit main loop on receivering terminate message. - let _cancel_rx = pw_cancel.attach(main_loop.loop_(), { - let main_loop = main_loop.clone(); - move |_| main_loop.quit() - }); - - let registry = Rc::new(core.get_registry()?); - let registry_weak = Rc::downgrade(®istry); - - let proxies = Rc::new(RefCell::new(HashMap::new())); - let on_event = Rc::new(RefCell::new(on_event)); - - let main_loop_clone = main_loop.clone(); - - let _registry_listener = registry - .add_listener_local() - .global(move |obj| { - let Some(registry) = registry_weak.upgrade() else { - return; - }; - - let attached_proxy: Option<(Box, Box)> = match obj.type_ { - ObjectType::Node => { - let Ok(node): Result = registry.bind(obj) else { - return; - }; - - let on_event_weak = Rc::downgrade(&on_event); - let main_loop = main_loop_clone.clone(); - let id = node.upcast_ref().id(); - - let listener = node - .add_listener_local() - .info(move |info| { - if let Some(on_event) = on_event_weak.upgrade() { - on_event.borrow_mut()(&main_loop, NodeEvent::NodeInfo(id, info)); - } - }) - .register(); - - Some((Box::new(node), Box::new(listener))) - } - - _ => None, - }; - - if let Some((proxy_spe, listener)) = attached_proxy { - let proxy = proxy_spe.upcast_ref(); - let id = proxy.id(); - let (object_type, _object_version) = proxy.get_type(); - - let proxies_weak = Rc::downgrade(&proxies); - let on_event_weak = Rc::downgrade(&on_event); - let main_loop = main_loop_clone.clone(); - - let remove_listener = proxy - .add_listener_local() - .removed(move || { - if object_type == ObjectType::Node { - if let Some(on_event) = on_event_weak.upgrade() { - on_event.borrow_mut()(&main_loop, NodeEvent::Remove(id)); - } - } - - if let Some(proxies) = proxies_weak.upgrade() { - proxies.borrow_mut().remove(&id); - } - }) - .register(); - - proxies - .borrow_mut() - .insert(id, (proxy_spe, listener, remove_listener)); - } - }) - .register(); - - main_loop.run(); - Ok(()) -} diff --git a/subscriptions/sound/src/pulse.rs b/subscriptions/sound/src/pulse.rs deleted file mode 100644 index 5fd0d7ace..000000000 --- a/subscriptions/sound/src/pulse.rs +++ /dev/null @@ -1,752 +0,0 @@ -// Copyright 2024 System76 -// SPDX-License-Identifier: MPL-2.0 - -// Make sure not to fail if pulse not found, and reconnect? -// change to device shouldn't send osd? - -use cosmic::iced_futures::{self, Subscription, stream}; -use futures::{SinkExt, executor::block_on}; -use libpulse_binding::{ - callbacks::ListResult, - channelmap::Map, - context::{ - Context, FlagSet, State, - introspect::{CardInfo, CardProfileInfo, Introspector, ServerInfo, SinkInfo, SourceInfo}, - subscribe::{Facility, InterestMaskSet, Operation}, - }, - def::{PortAvailable, Retval}, - mainloop::{ - api::MainloopApi, - events::io::IoEventInternal, - standard::{IterateResult, Mainloop}, - }, - volume::{ChannelVolumes, Volume}, -}; -use std::{ - borrow::Cow, - cell::{Cell, RefCell}, - convert::Infallible, - io::{Read, Write}, - os::{ - fd::{FromRawFd, IntoRawFd, RawFd}, - raw::c_void, - }, - rc::Rc, - str::FromStr, - sync::mpsc, -}; - -pub fn subscription() -> iced_futures::Subscription { - Subscription::run_with_id( - "pulse", - stream::channel(20, |sender| async { - std::thread::spawn(move || thread(sender)); - futures::future::pending().await - }), - ) -} - -pub fn thread(sender: futures::channel::mpsc::Sender) { - let Some(mut main_loop) = Mainloop::new() else { - log::error!("Failed to create PA main loop"); - return; - }; - - let Some(mut context) = Context::new(&main_loop, "cosmic-osd") else { - log::error!("Failed to create PA context"); - return; - }; - - let data = Rc::new(Data { - main_loop: RefCell::new(Mainloop { - _inner: Rc::clone(&main_loop._inner), - }), - introspector: context.introspect(), - sink_volume: Cell::new(None), - sink_mute: Cell::new(None), - source_volume: Cell::new(None), - source_mute: Cell::new(None), - default_sink_name: RefCell::new(None), - default_source_name: RefCell::new(None), - sender: RefCell::new(sender.clone()), - }); - - let data_clone = data.clone(); - context.set_subscribe_callback(Some(Box::new(move |facility, operation, index| { - data_clone.subscribe_cb(facility.unwrap(), operation, index); - }))); - - let _ = context.connect(None, FlagSet::NOFAIL, None); - - loop { - if sender.is_closed() { - return; - } - - match main_loop.iterate(false) { - IterateResult::Success(_) => {} - IterateResult::Err(_e) => { - return; - } - IterateResult::Quit(_e) => { - return; - } - } - - if context.get_state() == State::Ready { - break; - } - } - - // Inspect all available cards on startup - data.introspector.get_card_info_list({ - let data_weak = Rc::downgrade(&data); - move |card_info_res| { - if let Some(data) = data_weak.upgrade() { - data.card_info_cb(card_info_res) - } - } - }); - - data.get_server_info(); - context.subscribe( - InterestMaskSet::SERVER | InterestMaskSet::SINK | InterestMaskSet::SOURCE, - |_| {}, - ); - - if let Err((err, retval)) = main_loop.run() { - log::error!("PA main loop returned {:?}, error {}", retval, err); - } -} - -#[derive(Clone, Debug)] -pub enum Event { - Balance(Option), - CardInfo(Card), - DefaultSink(String), - DefaultSource(String), - SinkVolume(u32), - Channels(PulseChannels), - SinkMute(bool), - SourceVolume(u32), - SourceMute(bool), -} - -enum Request { - Volume(u32, f32), - Balance(u32, f32), - Quit, -} - -#[derive(Debug)] -pub struct PulseChannels { - tx: mpsc::Sender, - pipe_tx: std::fs::File, - index: u32, -} - -impl Clone for PulseChannels { - fn clone(&self) -> Self { - Self { - tx: self.tx.clone(), - pipe_tx: self - .pipe_tx - .try_clone() - .expect("failed to clone PulseChannels pipe writer"), - index: self.index, - } - } -} - -/// Data used by the [`handle_balance_io_new`] callback. -struct HandleBalanceData( - Context, - ChannelVolumes, - Map, - std::sync::mpsc::Receiver, -); - -/// Callback for creating an IO event source [`MainloopApi::io_new`]. -extern "C" fn handle_balance_io_new( - api: *const MainloopApi, - event: *mut IoEventInternal, - reader_fd: RawFd, - _flags: libpulse_binding::mainloop::events::io::FlagSet, - data: *mut c_void, -) { - // Take ownership of the data and borrow its contents. - let mut data = unsafe { Box::::from_raw(data as _) }; - let HandleBalanceData(ctx, volumes, map, rx) = data.as_mut(); - - // Return early if the context is not ready, and give the data back. - if ctx.get_state() != State::Ready { - let _ = Box::leak(data); - return; - } - - // If the first byte cannot be read, destroy this event source with its reader and data. - let mut buf = [0u8; 1]; - let mut reader = unsafe { std::fs::File::from_raw_fd(reader_fd) }; - if reader.read_exact(&mut buf).is_err() { - (unsafe { &*api }) - .io_free - .as_ref() - .expect("io_free function is missing")(event); - return; - } - - // Give ownership of the reader back. - _ = reader.into_raw_fd(); - - while let Ok(req) = rx.try_recv() { - match req { - Request::Volume(index, volume_scale) => { - let mut intro = ctx.introspect(); - - let new_scale = Volume((volume_scale * Volume::NORMAL.0 as f32).round() as u32); - - if let Some(v) = volumes.scale(new_scale) { - _ = intro.set_sink_volume_by_index( - index, - v, - Some(Box::new(|success| { - if !success { - tracing::error!("Failed to set sink balance"); - } - })), - ); - } - } - Request::Balance(index, new_balance) => { - if map.can_balance() { - if let Some(v) = volumes.set_balance(&map, new_balance) { - let mut intro = ctx.introspect(); - - _ = intro.set_sink_volume_by_index( - index, - v, - Some(Box::new(|success| { - if !success { - tracing::error!("Failed to set sink balance"); - } - })), - ); - } - } - } - Request::Quit => unsafe { &*api } - .quit - .as_ref() - .expect("quit function missing")(api, 0), - } - } - - let _ = Box::leak(data); -} - -impl PulseChannels { - fn new( - volumes: ChannelVolumes, - map: Map, - api: &MainloopApi, - index: u32, - ctx: Context, - ) -> PulseChannels { - let (reader, writer) = rustix::pipe::pipe_with(rustix::pipe::PipeFlags::CLOEXEC) - .expect("failed to crate pipe"); - - let (tx, rx) = mpsc::channel::(); - - // Create IO event source object for handling speaker balance. - let event_source = api.io_new.as_ref().unwrap()( - api as *const _, - reader.into_raw_fd(), - libpulse_binding::mainloop::events::io::FlagSet::INPUT, - Some(handle_balance_io_new), - Box::into_raw(Box::new(HandleBalanceData(ctx, volumes, map, rx))) as *mut c_void, - ); - - if let Some(enable) = api.io_enable.as_ref() { - enable( - event_source, - libpulse_binding::mainloop::events::io::FlagSet::INPUT, - ); - } - - Self { - tx, - pipe_tx: std::fs::File::from(writer), - index, - } - } - - /// Change the active index. - #[inline] - pub fn set_index(&mut self, index: u32) { - self.index = index; - } - - /// Set the speaker balance of the active sink. - pub fn set_balance(&mut self, balance: f32) { - if let Err(err) = self.tx.send(Request::Balance(self.index, balance)) { - tracing::error!(?err, "Failed to send new balance to channel"); - } else { - self.pipe_tx - .write_all(&[1]) - .expect("PulseChannels pipe write failed"); - } - } - - /// Set the volume of the active sink. - pub fn set_volume(&mut self, volume: f32) { - if let Err(err) = self.tx.send(Request::Volume(self.index, volume)) { - tracing::error!(?err, "Failed to send new volume to channel"); - } else { - self.pipe_tx - .write_all(&[1]) - .expect("PulseChannels pipe write failed"); - } - } - - /// Request the pulse thread to quit. - pub fn quit(mut self) { - _ = self.tx.send(Request::Quit); - _ = self.pipe_tx.write_all(&[1]); - } -} - -#[derive(Clone, Debug, Hash, Eq, PartialEq)] -pub struct Card { - pub object_id: u32, - pub name: String, - pub product_name: String, - pub variant: DeviceVariant, - pub ports: Vec, - pub profiles: Vec, - pub active_profile: Option, -} - -#[derive(Clone, Debug, Hash, Eq, PartialEq)] -pub struct CardPort { - pub name: String, - pub description: String, - pub direction: Direction, - pub port_type: PortType, - pub profile_port: u32, - pub priority: u32, - pub profiles: Vec, - pub availability: Availability, -} - -#[derive(Copy, Clone, Debug, Hash, Eq, PartialEq)] -pub enum Availability { - Unknown, - No, - Yes, -} - -impl From for Availability { - fn from(pa: PortAvailable) -> Self { - match pa { - PortAvailable::Unknown => Availability::Unknown, - PortAvailable::No => Availability::No, - PortAvailable::Yes => Availability::Yes, - } - } -} - -#[derive(Clone, Debug, Hash, Eq, PartialEq)] -pub struct CardProfile { - pub name: String, - pub description: String, - pub available: bool, - pub n_sinks: u32, - pub n_sources: u32, - pub priority: u32, -} - -#[derive(Clone, Debug, Hash, Eq, PartialEq)] -pub enum DeviceVariant { - Alsa { alsa_card: u32 }, - Bluez5 { address: String }, -} - -#[derive(Clone, Debug, Hash, Eq, PartialEq)] -pub enum Direction { - Input, - Output, - Both, -} - -#[derive(Default, Clone, Debug, Hash, Eq, PartialEq)] -pub enum PortType { - Mic, - Speaker, - Headphones, - Headset, - Digital, - #[default] - Unknown, -} - -impl FromStr for PortType { - type Err = Infallible; - - fn from_str(s: &str) -> Result { - match s { - "mic" => Ok(PortType::Mic), - "speaker" => Ok(PortType::Speaker), - "headphones" => Ok(PortType::Headphones), - "headset" => Ok(PortType::Headset), - "digital" => Ok(PortType::Digital), - _ => Ok(PortType::Unknown), - } - } -} - -struct Data { - main_loop: RefCell, - default_sink_name: RefCell>, - default_source_name: RefCell>, - sink_volume: Cell>, - sink_mute: Cell>, - source_volume: Cell>, - source_mute: Cell>, - introspector: Introspector, - sender: RefCell>, -} - -impl Data { - fn card_info_cb(self: &Rc, card_info: ListResult<&CardInfo>) { - if let ListResult::Item(card_info) = card_info { - let Some(object_id) = card_info - .proplist - .get_str("object.id") - .and_then(|v| v.parse::().ok()) - else { - return; - }; - - let variant = if let Some(alsa_card) = card_info - .proplist - .get_str("alsa.card") - .and_then(|v| v.parse::().ok()) - { - DeviceVariant::Alsa { alsa_card } - } else if let Some(address) = card_info.proplist.get_str("api.bluez5.address") { - DeviceVariant::Bluez5 { address } - } else { - return; - }; - - let card = Card { - name: card_info - .name - .as_ref() - .map(Cow::to_string) - .unwrap_or_default(), - product_name: card_info - .proplist - .get_str("device.product.name") - .unwrap_or_default(), - object_id, - variant, - ports: card_info - .ports - .iter() - .map(|port| CardPort { - name: port.name.as_ref().map(Cow::to_string).unwrap_or_default(), - description: port - .description - .as_ref() - .map(Cow::to_string) - .unwrap_or_default(), - direction: match port.direction.bits() { - x if x == libpulse_binding::direction::FlagSet::INPUT.bits() => { - Direction::Input - } - x if x == libpulse_binding::direction::FlagSet::OUTPUT.bits() => { - Direction::Output - } - _ => Direction::Both, - }, - port_type: port - .proplist - .get_str("port.type") - .as_deref() - .map(|s| PortType::from_str(s).unwrap()) - .unwrap_or_default(), - profile_port: port - .proplist - .get_str("card.profile.port") - .and_then(|v| v.parse::().ok()) - .unwrap_or(0), - priority: port.priority, - profiles: collect_profiles(&port.profiles), - availability: port.available.into(), - }) - .collect(), - profiles: collect_profiles(&card_info.profiles), - active_profile: card_info.active_profile.as_deref().map(CardProfile::from), - }; - - if block_on(self.sender.borrow_mut().send(Event::CardInfo(card))).is_err() { - self.main_loop.borrow_mut().quit(Retval(0)); - } - } - } - - fn server_info_cb(self: &Rc, server_info: &ServerInfo) { - let new_default_sink_name = server_info - .default_sink_name - .as_ref() - .map(|x| x.clone().into_owned()); - let mut default_sink_name = self.default_sink_name.borrow_mut(); - if new_default_sink_name != *default_sink_name { - if let Some(name) = &new_default_sink_name { - _ = block_on( - self.sender - .borrow_mut() - .send(Event::DefaultSink(name.clone())), - ); - self.get_sink_info_by_name(name); - } - *default_sink_name = new_default_sink_name; - } - - let new_default_source_name = server_info - .default_source_name - .as_ref() - .map(|x| x.clone().into_owned()); - let mut default_source_name = self.default_source_name.borrow_mut(); - if new_default_source_name != *default_source_name { - if let Some(name) = &new_default_source_name { - _ = block_on( - self.sender - .borrow_mut() - .send(Event::DefaultSource(name.clone())), - ); - self.get_source_info_by_name(name); - } - *default_source_name = new_default_source_name; - } - } - - fn get_server_info(self: &Rc) { - let data = self.clone(); - self.introspector - .get_server_info(move |server_info| data.server_info_cb(server_info)); - } - - fn sink_info_cb(&self, sink_info_res: ListResult<&SinkInfo>) { - if let ListResult::Item(sink_info) = sink_info_res { - if sink_info.name.as_deref() != self.default_sink_name.borrow().as_deref() { - return; - } - let balance = (sink_info.channel_map.can_balance() - && sink_info.base_volume.is_normal()) - .then(|| sink_info.volume.get_balance(&sink_info.channel_map)); - - let volume = sink_info.volume.max().0 / (Volume::NORMAL.0 / 100); - if self.sink_mute.get() != Some(sink_info.mute) { - self.sink_mute.set(Some(sink_info.mute)); - if block_on( - self.sender - .borrow_mut() - .send(Event::SinkMute(sink_info.mute)), - ) - .is_err() - { - self.main_loop.borrow_mut().quit(Retval(0)); - } - } - if self.sink_volume.get() != Some(volume) { - self.sink_volume.set(Some(volume)); - if block_on(self.sender.borrow_mut().send(Event::SinkVolume(volume))).is_err() { - self.main_loop.borrow_mut().quit(Retval(0)); - } - } - if block_on(self.sender.borrow_mut().send(Event::Balance(balance))).is_err() { - self.main_loop.borrow_mut().quit(Retval(0)); - } - let mut main_loop = self.main_loop.borrow_mut(); - let api = main_loop.get_api(); - if let Some(mut ctx) = Context::new(&*main_loop, "balance") { - let _ = ctx.connect(None, FlagSet::NOFAIL, None); - - let channels = PulseChannels::new( - sink_info.volume, - sink_info.channel_map, - api, - sink_info.index, - ctx, - ); - - if block_on(self.sender.borrow_mut().send(Event::Channels(channels))).is_err() { - main_loop.quit(Retval(0)); - } - } - } - } - - fn source_info_cb(&self, source_info_res: ListResult<&SourceInfo>) { - if let ListResult::Item(source_info) = source_info_res { - if source_info.name.as_deref() != self.default_source_name.borrow().as_deref() { - return; - } - let volume = source_info.volume.max().0 / (Volume::NORMAL.0 / 100); - if self.source_mute.get() != Some(source_info.mute) { - self.source_mute.set(Some(source_info.mute)); - if block_on( - self.sender - .borrow_mut() - .send(Event::SourceMute(source_info.mute)), - ) - .is_err() - { - self.main_loop.borrow_mut().quit(Retval(0)); - } - } - if self.source_volume.get() != Some(volume) { - self.source_volume.set(Some(volume)); - if block_on(self.sender.borrow_mut().send(Event::SourceVolume(volume))).is_err() { - self.main_loop.borrow_mut().quit(Retval(0)); - } - } - } - } - - fn get_card_info_by_index(self: &Rc, index: u32) { - let data = self.clone(); - self.introspector - .get_card_info_by_index(index, move |card_info_res| { - data.card_info_cb(card_info_res); - }); - } - - fn get_sink_info_by_index(self: &Rc, index: u32) { - let data = self.clone(); - self.introspector.get_sink_info_by_index( - index, - move |sink_info_res: ListResult<&SinkInfo<'_>>| { - if let ListResult::Item(ref info) = sink_info_res { - if let Some(card_index) = info.card { - let data_clone = data.clone(); - data.introspector.get_card_info_by_index( - card_index, - move |card_info_res| { - data_clone.card_info_cb(card_info_res); - }, - ); - } - } - data.sink_info_cb(sink_info_res); - }, - ); - } - - fn get_sink_info_by_name(self: &Rc, name: &str) { - let data = self.clone(); - self.introspector - .get_sink_info_by_name(name, move |sink_info_res| { - if let ListResult::Item(ref info) = sink_info_res { - if let Some(card_index) = info.card { - let data_clone = data.clone(); - data.introspector.get_card_info_by_index( - card_index, - move |card_info_res| { - data_clone.card_info_cb(card_info_res); - }, - ); - } - } - data.sink_info_cb(sink_info_res); - }); - } - - fn get_source_info_by_index(self: &Rc, index: u32) { - let data = self.clone(); - self.introspector - .get_source_info_by_index(index, move |source_info_res| { - if let ListResult::Item(ref info) = source_info_res { - if let Some(card_index) = info.card { - let data_clone = data.clone(); - data.introspector.get_card_info_by_index( - card_index, - move |card_info_res| { - data_clone.card_info_cb(card_info_res); - }, - ); - } - } - data.source_info_cb(source_info_res); - }); - } - - fn get_source_info_by_name(self: &Rc, name: &str) { - let data = self.clone(); - self.introspector - .get_source_info_by_name(name, move |source_info_res| { - if let ListResult::Item(ref info) = source_info_res { - if let Some(card_index) = info.card { - let data_clone = data.clone(); - data.introspector.get_card_info_by_index( - card_index, - move |card_info_res| { - data_clone.card_info_cb(card_info_res); - }, - ); - } - } - data.source_info_cb(source_info_res); - }); - } - - fn subscribe_cb( - self: &Rc, - facility: Facility, - _operation: Option, - index: u32, - ) { - match facility { - Facility::Server => { - self.get_server_info(); - } - Facility::Sink => { - self.get_sink_info_by_index(index); - } - Facility::Source => { - self.get_source_info_by_index(index); - } - Facility::Card => { - self.get_card_info_by_index(index); - } - _ => {} - } - } -} - -fn collect_profiles(profiles: &[CardProfileInfo]) -> Vec { - profiles.iter().map(CardProfile::from).collect() -} - -impl From<&CardProfileInfo<'_>> for CardProfile { - fn from(profile: &CardProfileInfo) -> Self { - CardProfile { - name: profile - .name - .as_ref() - .map(Cow::to_string) - .unwrap_or_default(), - description: profile - .description - .as_ref() - .map(Cow::to_string) - .unwrap_or_default(), - available: profile.available, - n_sinks: profile.n_sinks, - n_sources: profile.n_sources, - priority: profile.priority, - } - } -} diff --git a/subscriptions/upower/Cargo.toml b/subscriptions/upower/Cargo.toml index 24aad4bfb..d0c752f88 100644 --- a/subscriptions/upower/Cargo.toml +++ b/subscriptions/upower/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cosmic-settings-upower-subscription" -version = "0.1.0" +version = "1.0.0-beta6" edition = "2024" license = "MPL-2.0" rust-version.workspace = true