diff --git a/Cargo.lock b/Cargo.lock index 02a0107a3..8fbcfc64b 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.108", ] @@ -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" @@ -1515,7 +1520,7 @@ dependencies = [ [[package]] name = "cosmic-comp-config" version = "0.1.0" -source = "git+https://github.com/pop-os/cosmic-comp#6e4164643ea81d3a2c3b40ffc441950722a61bfb" +source = "git+https://github.com/pop-os/cosmic-comp#7fd033295fdff18a730d60b7b49940c4c44ef15f" dependencies = [ "cosmic-config", "input", @@ -1527,7 +1532,7 @@ dependencies = [ [[package]] name = "cosmic-config" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic#6439507aa2d8d7e6a89c0fc016895dc0ab9252d4" 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#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic#6439507aa2d8d7e6a89c0fc016895dc0ab9252d4" dependencies = [ "quote", "syn 2.0.108", @@ -1870,9 +1875,13 @@ dependencies = [ "async-fn-stream", "futures", "indexmap 2.12.0", + "intmap", "libcosmic", "libpulse-binding", + "libspa", + "libspa-sys", "log", + "numtoa", "pipewire", "rustix 1.1.2", "tokio", @@ -1949,7 +1958,7 @@ dependencies = [ [[package]] name = "cosmic-theme" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic#6439507aa2d8d7e6a89c0fc016895dc0ab9252d4" dependencies = [ "almost", "cosmic-config", @@ -3405,7 +3414,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.61.2", ] [[package]] @@ -3420,7 +3429,7 @@ dependencies = [ [[package]] name = "iced" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic#6439507aa2d8d7e6a89c0fc016895dc0ab9252d4" dependencies = [ "dnd", "iced_accessibility", @@ -3438,7 +3447,7 @@ dependencies = [ [[package]] name = "iced_accessibility" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic#6439507aa2d8d7e6a89c0fc016895dc0ab9252d4" dependencies = [ "accesskit", "accesskit_winit", @@ -3447,7 +3456,7 @@ dependencies = [ [[package]] name = "iced_core" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic#6439507aa2d8d7e6a89c0fc016895dc0ab9252d4" dependencies = [ "bitflags 2.10.0", "bytes", @@ -3472,7 +3481,7 @@ dependencies = [ [[package]] name = "iced_futures" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic#6439507aa2d8d7e6a89c0fc016895dc0ab9252d4" dependencies = [ "futures", "iced_core", @@ -3498,7 +3507,7 @@ dependencies = [ [[package]] name = "iced_graphics" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic#6439507aa2d8d7e6a89c0fc016895dc0ab9252d4" dependencies = [ "bitflags 2.10.0", "bytemuck", @@ -3520,7 +3529,7 @@ dependencies = [ [[package]] name = "iced_renderer" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic#6439507aa2d8d7e6a89c0fc016895dc0ab9252d4" dependencies = [ "iced_graphics", "iced_tiny_skia", @@ -3532,7 +3541,7 @@ dependencies = [ [[package]] name = "iced_runtime" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic#6439507aa2d8d7e6a89c0fc016895dc0ab9252d4" dependencies = [ "bytes", "cosmic-client-toolkit", @@ -3548,7 +3557,7 @@ dependencies = [ [[package]] name = "iced_tiny_skia" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic#6439507aa2d8d7e6a89c0fc016895dc0ab9252d4" dependencies = [ "bytemuck", "cosmic-text", @@ -3564,7 +3573,7 @@ dependencies = [ [[package]] name = "iced_wgpu" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic#6439507aa2d8d7e6a89c0fc016895dc0ab9252d4" dependencies = [ "as-raw-xcb-connection", "bitflags 2.10.0", @@ -3595,7 +3604,7 @@ dependencies = [ [[package]] name = "iced_widget" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic#6439507aa2d8d7e6a89c0fc016895dc0ab9252d4" dependencies = [ "cosmic-client-toolkit", "dnd", @@ -3615,7 +3624,7 @@ dependencies = [ [[package]] name = "iced_winit" version = "0.14.0-dev" -source = "git+https://github.com/pop-os/libcosmic#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic#6439507aa2d8d7e6a89c0fc016895dc0ab9252d4" dependencies = [ "cosmic-client-toolkit", "dnd", @@ -4255,6 +4264,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" @@ -4281,6 +4296,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" @@ -4623,12 +4647,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" @@ -4644,7 +4662,7 @@ checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" [[package]] name = "libcosmic" version = "0.1.0" -source = "git+https://github.com/pop-os/libcosmic#d2f7fdea6d24e70b54e017e89973b8a5a44b4e54" +source = "git+https://github.com/pop-os/libcosmic#6439507aa2d8d7e6a89c0fc016895dc0ab9252d4" dependencies = [ "apply", "ashpd 0.12.0", @@ -4753,9 +4771,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", @@ -4763,20 +4781,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]] @@ -5061,7 +5079,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" dependencies = [ "cfg-if", - "unicode-width", + "unicode-width 0.1.14", ] [[package]] @@ -5212,17 +5230,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" @@ -5445,6 +5452,12 @@ dependencies = [ "syn 2.0.108", ] +[[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" @@ -6005,30 +6018,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]] @@ -6423,7 +6436,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", @@ -6974,6 +6987,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" @@ -7437,13 +7459,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" @@ -7481,6 +7516,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" @@ -7731,11 +7772,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" @@ -7773,7 +7829,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", ] @@ -7799,6 +7855,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" @@ -8009,6 +8071,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" @@ -8654,20 +8722,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]] @@ -8765,15 +8820,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" @@ -8783,15 +8829,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" @@ -9359,15 +9396,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/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/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..81c4b25d5 --- /dev/null +++ b/cosmic-settings/src/pages/sound/device_profiles.rs @@ -0,0 +1,125 @@ +// 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, pipewire}; +use itertools::Itertools; +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_profiles + .iter() + .filter_map(|(device_id, profiles)| { + let name = sound_page.model.device_names.get(device_id)?.as_str(); + + // TODO: cache + let active_profile = + sound_page + .model + .active_profiles + .get(device_id) + .and_then(|profile| { + profiles + .iter() + .filter(|p| !matches!(p.available, pipewire::Availability::No)) + .enumerate() + .find(|(_, p)| p.index == profile.index) + .map(|(pos, _)| pos) + }); + + // TODO: cache + let (indexes, profiles): (Vec<_>, Vec<_>) = profiles + .iter() + .filter(|p| !matches!(p.available, pipewire::Availability::No)) + .map(|p| (p.index as u32, p.description.clone())) + .collect(); + + let dropdown = widget::dropdown::popup_dropdown( + Vec::from_iter(profiles), + 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); + + Some(( + name, + widget::settings::item::builder(name).control(dropdown), + )) + }) + .sorted_by(|a, b| a.0.cmp(b.0)) + .map(|(_, element)| element); + + 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 80% rename from cosmic-settings/src/pages/sound.rs rename to cosmic-settings/src/pages/sound/mod.rs index 3d66222d6..f0c24a388 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,25 +11,26 @@ 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 { + /// Reload the model + Reload, + /// Set the profile of a sound device. + SetProfile(u32, u32), /// Change the balance of the active sink. SinkBalanceChanged(u32), /// 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 @@ -36,8 +39,6 @@ pub enum Message { SourceChanged(usize), /// Toggle the mute status of the input output. SourceMuteToggle, - /// Change the active profile for an output. - SourceProfileChanged(usize), /// Request to change the input volume. SourceVolumeChanged(u32), /// Toggle amplification for sink @@ -66,15 +67,33 @@ 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 { + fix_wireplumber_stream_properties(); + 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 +116,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 +129,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 +142,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,50 +152,43 @@ 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) => { - return self - .model - .sink_balance_changed(balance) - .map(|message| Message::Subscription(message).into()); - } - Message::SinkChanged(pos) => { + Message::Surface(a) => return cosmic::task::message(crate::app::Message::Surface(a)), + + Message::Subscription(message) => { return self .model - .sink_changed(pos) + .update(message) .map(|message| Message::Subscription(message).into()); } - Message::SinkMuteToggle => self.model.sink_mute_toggle(), - - Message::SinkProfileChanged(profile) => { + Message::SinkBalanceChanged(balance) => { return self .model - .sink_profile_changed(profile) + .sink_balance_changed(balance) .map(|message| Message::Subscription(message).into()); } - Message::SinkVolumeChanged(volume) => { + Message::SinkChanged(pos) => { return self .model - .sink_volume_changed(volume) + .sink_changed(pos) .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::SourceChanged(pos) => { return self .model @@ -177,12 +196,14 @@ impl Page { .map(|message| Message::Subscription(message).into()); } + Message::SinkMuteToggle => self.model.sink_mute_toggle(), + Message::SourceMuteToggle => self.model.source_mute_toggle(), - Message::SourceProfileChanged(profile) => { + Message::SinkVolumeChanged(volume) => { return self .model - .source_profile_changed(profile) + .sink_volume_changed(volume) .map(|message| Message::Subscription(message).into()); } @@ -193,6 +214,16 @@ impl Page { .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) => { self.amplification_source = enabled; @@ -203,14 +234,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); } - 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 +257,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")); @@ -282,21 +315,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 +334,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")); @@ -376,20 +393,6 @@ fn output() -> Section { )) .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( &*section.descriptions[balance], @@ -431,6 +434,58 @@ 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 fix_wireplumber_stream_properties() { + let path = std::env::home_dir() + .expect("no home dir") + .join(".local/state/wireplumber/stream-properties"); + + let Ok(mut data) = std::fs::read_to_string(&path) else { + return; + }; + + let mut insert_pos = 0; + let mut lines = data.split('\n'); + while let Some(line) = lines.next() { + if line.starts_with("Input/Audio:") { + return; + } else if line.starts_with("Output") { + break; + } + + insert_pos += 1 + line.len(); + } + + data.insert_str(insert_pos, "\nInput/Audio:application.id:org.PulseAudio.pavucontrol={\"channelMap\":[\"MONO\"], \"mute\":false, \"volume\":1.000000, \"channelVolumes\":[1.000000]}\""); +} + // fn alerts() -> Section { // let mut descriptions = Slab::new(); // let volume = descriptions.insert(fl!("sound-alerts", "volume")); diff --git a/i18n/en/cosmic_settings.ftl b/i18n/en/cosmic_settings.ftl index eaa153c0c..13215cc2a 100644 --- a/i18n/en/cosmic_settings.ftl +++ b/i18n/en/cosmic_settings.ftl @@ -483,10 +483,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/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/sound/Cargo.toml b/subscriptions/sound/Cargo.toml index 147964c08..6bdc17732 100644 --- a/subscriptions/sound/Cargo.toml +++ b/subscriptions/sound/Cargo.toml @@ -9,11 +9,15 @@ publish = true [dependencies] async-fn-stream = "0.3.2" futures = "0.3.31" -indexmap = "2.12.0" +indexmap = "2.11.4" +intmap = "3.1.2" libcosmic = { git = "https://github.com/pop-os/libcosmic" } libpulse-binding = "2.30.1" +libspa = "0.9.2" +libspa-sys = "0.9.2" log = "0.4.28" -pipewire = "0.8" -rustix = "1.1.2" -tokio = "1.48.0" +numtoa = "1.0.0-alpha1" +pipewire = "0.9" +rustix = "1.0.8" +tokio = "1.47.1" tracing = "0.1.41" diff --git a/subscriptions/sound/src/lib.rs b/subscriptions/sound/src/lib.rs index 54b0a4bb7..44fba3812 100644 --- a/subscriptions/sound/src/lib.rs +++ b/subscriptions/sound/src/lib.rs @@ -3,33 +3,43 @@ pub mod pipewire; pub mod pulse; +mod wpctl; +use crate::pipewire::Availability; use cosmic::Task; use cosmic::iced_futures::MaybeSend; use futures::{Stream, StreamExt}; -use indexmap::IndexMap; -use std::{collections::BTreeMap, sync::Arc, time::Duration}; +use intmap::IntMap; +use std::{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 { let (cancel_tx, mut cancel_rx) = futures::channel::oneshot::channel::<()>(); - let (tx, mut pulse_rx) = futures::channel::mpsc::channel(1); + let (tx, 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); + let (tx, pw_rx) = futures::channel::mpsc::channel(1); + + let (request_tx, request_rx) = pipewire::channel(); + std::thread::spawn(move || { + if let Err(why) = pipewire::run(request_rx, tx) { + tracing::error!(?why, "failed to run pipewire thread"); + } + }); emitter .emit( Message::SubHandle(Arc::new(SubscriptionHandle { cancel_tx, - pipewire: pipewire_terminate, + pipewire: request_tx, })) .into(), ) @@ -42,34 +52,41 @@ pub fn watch() -> impl Stream + MaybeSend + 'static { let mut events = Vec::new(); let mut timer = tokio::time::interval(Duration::from_millis(64)); + enum Variant { + Pulse(pulse::Event), + Pipewire(pipewire::Event), + } + + let mut stream = + futures::stream::select(pulse_rx.map(Variant::Pulse), pw_rx.map(Variant::Pipewire)); + loop { tokio::select! { - event = pulse_rx.next() => { + event = stream.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)); + Variant::Pulse(event) => 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(); + } + } + + Variant::Pipewire(event) => { + events.push(Server::Pipewire(event)); timer.reset(); } } } - 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))); @@ -98,9 +115,7 @@ pub fn watch() -> impl Stream + MaybeSend + 'static { } } - drop(pulse_rx); - drop(pw_rx); - + drop(stream); futures::future::pending::().await; }) } @@ -110,49 +125,48 @@ pub struct Model { subscription_handle: Option, sink_channels: Option, - devices: BTreeMap, - card_names: IndexMap, - card_profiles: IndexMap>, - active_profiles: IndexMap>, + // Translated text + pub unplugged_text: String, + pub hd_audio_text: String, + pub usb_audio_text: String, + + device_ids: IntMap, + pub node_names: IntMap, + card_profile_devices: IntMap, + node_route_plugged: IntMap, + + pub device_names: IntMap, + pub device_profiles: IntMap>, + pub active_profiles: IntMap, + pub device_routes: IntMap>, + + prev_profile_node: Option<(DeviceId, NodeId)>, /** 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, + /// Device ID of active sink device. + active_sink_node: 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, - - /// Device identifier of the default sink. - default_sink: String, - /// Device identifier of the default source. - default_source: String, + /// Node ID of active source device. + active_source_node: Option, + /// Node identifier of the default source. + active_source_node_name: String, pub sink_volume_text: String, pub source_volume_text: String, @@ -168,9 +182,6 @@ pub struct Model { sink_balance_debounce: bool, pub source_mute: bool, source_volume_debounce: bool, - - changing_sink_profile: Option, - changing_source_profile: Option, } impl Model { @@ -178,38 +189,22 @@ 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); } if let Some(channel) = self.sink_channels.take() { @@ -217,6 +212,40 @@ impl Model { } } + /// 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) { + if let Some(profiles) = self.device_profiles.get(device_id) { + for profile in profiles { + if profile.index as u32 == index { + // Pipewire will change the default device if the profile on that device is changed. + // We can prevent this by re-setting the default after changing it. + self.prev_profile_node = + self.device_ids.iter().find_map(|(node_id, &dev_id)| { + if dev_id != device_id { + return None; + } + + if Some(node_id) == self.active_source_node + || Some(node_id) == self.active_sink_node + { + Some((dev_id, node_id)) + } else { + None + } + }); + + self.active_profiles.insert(device_id, profile.clone()); + + tokio::spawn(async move { + wpctl::set_profile(device_id, index).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}")); @@ -224,11 +253,7 @@ impl Model { return Task::none(); } - if !self - .sink_pw_ids - .get(self.active_sink.unwrap_or(0)) - .is_none() - { + if self.active_sink_node.is_some() { self.sink_balance_debounce = true; return cosmic::Task::future(async move { tokio::time::sleep(Duration::from_millis(64)).await; @@ -240,19 +265,11 @@ impl Model { } 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() - }); - } - } - } + if let Some(&object_id) = self.sink_node_ids.get(pos) { + self.set_default_sink_id(object_id); + tokio::task::spawn(async move { + wpctl::set_default(object_id).await; + }); } Task::none() @@ -260,40 +277,22 @@ impl Model { 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 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(); - } - } + if let Some(node_id) = self.active_sink_node { + let mute = self.sink_mute; + tokio::task::spawn(async move { + wpctl::set_mute(node_id, mute).await; + }); } - - Task::none() } pub fn sink_volume_changed(&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)) { + 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; @@ -305,19 +304,11 @@ impl Model { } 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() - }); - } - } - } + if let Some(&object_id) = self.source_node_ids.get(pos) { + self.set_default_source_id(object_id); + tokio::task::spawn(async move { + wpctl::set_default(object_id).await; + }); } Task::none() @@ -325,39 +316,22 @@ impl Model { 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 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(); - } - } + if let Some(node_id) = self.active_source_node { + let mute = self.source_mute; + tokio::task::spawn(async move { + wpctl::set_mute(node_id, mute).await; + }); } - - Task::none() } pub fn source_volume_changed(&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)) { + 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; @@ -375,199 +349,139 @@ impl Model { match event { Server::Pulse(event) => match event { pulse::Event::SourceVolume(volume) => { - if self.sink_volume_debounce { - return Task::none(); + if self.source_volume_debounce { + continue; } self.source_volume = volume; - self.source_volume_text = volume.to_string(); + self.source_volume_text = + numtoa::BaseN::<10>::u32(volume).as_str().to_owned(); } pulse::Event::SinkVolume(volume) => { if self.sink_volume_debounce { - return Task::none(); + continue; } self.sink_volume = volume; - self.sink_volume_text = volume.to_string(); + self.sink_volume_text = + numtoa::BaseN::<10>::u32(volume).as_str().to_owned(); } - 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) - } + pulse::Event::SourcePortChange(name, availability) => { + let Some(node_id) = self.active_source_node else { + continue; }; - eprintln!( - "inserting card {:?}: name={}, active_profile={:?}, profiles={:?}", - device_id, - card.name, - card.active_profile.as_ref().map(|p| p.name.as_str()), - card.profiles - ); - - 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)); - } + let Some(device_id) = self.device_ids.get(node_id).cloned() else { + continue; + }; - pulse::Event::DefaultSink(sink) => { - if !self.changing_sink_profile.is_some() { - self.set_default_sink(sink); + let Some(routes) = self.device_routes.get_mut(device_id) else { + continue; + }; + + let mut description = None; + + for route in routes { + if route.name == name { + route.available = availability; + description = Some(route.description.clone()); + } } - } - pulse::Event::DefaultSource(source) => { - if !self.changing_source_profile.is_some() { - self.set_default_source(source); + + if !matches!(availability, Availability::No) { + if let Some(description) = description { + if let Some((name, _)) = self.route_name_get( + &description, + availability, + device_id, + ) { + if let Some(pos) = self.active_source { + self.sources[pos] = name; + } + } + } } } - 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); - } - }, - 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 {}, + pulse::Event::SinkPortChange(name, availability) => { + let Some(node_id) = self.active_sink_node else { + continue; }; - match device.media_class { - pipewire::MediaClass::Sink => { - self.sinks.push(device.product_name.clone()); - self.sink_pw_ids.push(device.object_id); + let Some(device_id) = self.device_ids.get(node_id).cloned() else { + continue; + }; - sort_pulse_devices(&mut self.sinks, &mut self.sink_pw_ids); + let Some(routes) = self.device_routes.get_mut(device_id) else { + continue; + }; - 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); - } - } + let mut description = None; - 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); - } + for route in routes { + if route.name == name { + route.available = availability; + description = Some(route.description.clone()); } } - 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, - }, - ); - - card.ports.sort_unstable_by(|_, av, _, bv| { - av.description.cmp(&bv.description) - }); - } - - 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()); + if !matches!(availability, Availability::No) { + if let Some(description) = description { + if let Some((name, _)) = self.route_name_get( + &description, + availability, + device_id, + ) { + if let Some(pos) = self.active_sink { + self.sinks[pos] = name; + } } - break; } } + } - if let Some(card_id) = remove { - _ = self.devices.remove(&card_id); + pulse::Event::DefaultSink(node_name) => { + if self.active_sink_node_name == node_name { + continue; } - 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; - } + if let Some(id) = self.node_id_from_name(&node_name) { + self.set_default_sink_id(id); } + + self.active_sink_node_name = node_name; } - }, - } - } + pulse::Event::DefaultSource(node_name) => { + if self.active_source_node_name == node_name { + continue; + } - let mut tasks = Task::none(); + if let Some(id) = self.node_id_from_name(&node_name) { + self.set_default_source_id(id); + } - if let Some(device_id) = self.changing_sink_profile.take() { - tasks = tasks.chain(self.sink_profile_select(device_id)); - } + self.active_source_node_name = node_name; + } + 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(device_id) = self.changing_source_profile.take() { - tasks = tasks.chain(self.source_profile_select(device_id)); + Server::Pipewire(event) => self.pipewire_update(event), + } } - - return tasks; } Message::SinkBalanceApply => { @@ -579,21 +493,31 @@ impl Model { } } - Message::SinkVolumeApply(_) => { + Message::SinkVolumeApply(node_id) => { + let volume = self.sink_volume; + return cosmic::Task::future(async move { + wpctl::set_volume(node_id, volume).await; + tokio::time::sleep(Duration::from_millis(64)).await; + Message::SinkVolumeDebounce.into() + }); + } + + Message::SinkVolumeDebounce => { self.sink_volume_debounce = false; - if let Some(channels) = self.sink_channels.as_mut() { - channels.set_volume(self.sink_volume as f32 / 100.); - } } Message::SourceVolumeApply(node_id) => { - self.source_volume_debounce = false; - wpctl_set_volume(node_id, self.source_volume); + let volume = self.source_volume; + return cosmic::Task::future(async move { + wpctl::set_volume(node_id, volume).await; + tokio::time::sleep(Duration::from_millis(64)).await; + Message::SourceVolumeDebounce.into() + }); } - Message::SetDefaultSink(identifier) => self.set_default_sink(identifier), - - Message::SetDefaultSource(identifier) => self.set_default_source(identifier), + Message::SourceVolumeDebounce => { + self.source_volume_debounce = false; + } Message::SubHandle(handle) => { if let Some(handle) = Arc::into_inner(handle) { @@ -605,155 +529,278 @@ impl Model { 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() - }); + fn pipewire_update(&mut self, event: pipewire::Event) { + match event { + pipewire::Event::ActiveProfile(id, profile) => { + let index = profile.index as u32; + self.active_profiles.insert(id, profile); + tokio::spawn(async move { + wpctl::set_profile(id, index).await; + }); + } - 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::ActiveRoute(id, _index, route) => { + self.update_device_route(&route, id); + } - (profiles, profile_descriptions, active_profile) - } + pipewire::Event::AddProfile(id, profile) => { + let profiles = self.device_profiles.entry(id).or_default(); + for p in profiles.iter_mut() { + if p.index == profile.index { + *p = profile; + return; + } + } - /// Update the state of the default sink and its profiles. - fn set_default_sink(&mut self, sink: String) { - if self.default_sink == sink { - return; - } + profiles.push(profile); + } - self.default_sink = sink; + pipewire::Event::AddRoute(id, index, route) => { + self.update_device_route(&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; + } - 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; + pipewire::Event::AddDevice(device) => { + self.device_names + .insert(device.id, self.translate_device_name(&device.name)); + } + + pipewire::Event::AddNode(node) => { + if let Some(device_id) = node.device_id { + self.device_ids.insert(node.object_id, device_id); + + if let Some(card_profile_device) = node.card_profile_device { + self.card_profile_devices + .insert(node.object_id, card_profile_device); } } - } - } - } - fn set_default_source(&mut self, source: String) { - if self.default_source == source { - return; - } + let description = self.translate_device_name(&node.description); - 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; + if self + .node_names + .insert(node.object_id, node.node_name.clone()) + .is_none() + { + match node.media_class { + pipewire::MediaClass::Sink => { + self.sinks.push(description); + self.sink_node_ids.push(node.object_id); + + if self.active_sink_node_name == node.node_name { + self.set_default_sink_id(node.object_id); + tokio::task::spawn(async move { + wpctl::set_default(node.object_id).await; + }); + } + } + + pipewire::MediaClass::Source => { + self.sources.push(description); + self.source_node_ids.push(node.object_id); + + if self.active_source_node_name == node.node_name { + self.set_default_source_id(node.object_id); + tokio::task::spawn(async move { + wpctl::set_default(node.object_id).await; + }); + } + } + } + } + + if let Some((device_id, node_id)) = self.prev_profile_node { + if Some(device_id) == node.device_id && node.object_id == node_id { + self.prev_profile_node = None; + tokio::task::spawn(async move { + wpctl::set_default(node_id).await; + }); } } } + + pipewire::Event::RemoveDevice(id) => self.remove_device(id), + pipewire::Event::RemoveNode(id) => self.remove_node(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); + fn node_id_from_name(&self, name: &str) -> Option { + self.node_names + .iter() + .find(|&(_, n)| *n == name) + .map(|(id, _)| id) } - 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); + fn remove_device(&mut self, id: DeviceId) { + _ = self.device_names.remove(id); + _ = self.device_profiles.remove(id); + _ = self.active_profiles.remove(id); + _ = self.device_routes.remove(id); } - 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 remove_node(&mut self, id: NodeId) { + 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 { + self.set_default_sink_id(node_id); + } + } 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 { + self.set_default_source_id(node_id); } } - Task::none() + _ = self.device_ids.remove(id); + _ = self.node_names.remove(id); + _ = self.node_route_plugged.remove(id); + _ = self.card_profile_devices.remove(id); } - fn source_profile_select(&mut self, device_id: DeviceId) -> Task { - self.changing_source_profile = None; - let source_pos = self.active_source.unwrap_or(0); + /// 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(); + } - 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) - }); + /// 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(); + } + + /// Check if a node has had its route appended to the name, and return a name if we should update it. + fn route_plug_check( + &mut self, + node: NodeId, + device: DeviceId, + route: &pipewire::Route, + ) -> Option { + if self.node_route_plugged.get(node).is_some() { + return None; + } + + let profile = self.active_profiles.get(device)?; + + if !profile.name.starts_with("pro-audio") { + let Some(&card_profile_device) = self.card_profile_devices.get(node) else { + return None; + }; + + if !route.devices.contains(&(card_profile_device as i32)) { + return None; } } - Task::none() + let (name, plugged) = self.route_name_get(&route.description, route.available, device)?; + + if plugged { + self.node_route_plugged.insert(node, ()); + } + + Some(name) } -} -#[derive(Debug)] -struct Card { - ports: IndexMap, -} + fn route_name_get( + &self, + route_description: &str, + route_available: Availability, + device: DeviceId, + ) -> Option<(String, bool)> { + let Some(device_name) = self.device_names.get(device) else { + return None; + }; + + let (port_name, plugged) = if matches!(route_available, Availability::No) { + (self.unplugged_text.as_str(), false) + } else { + (route_description, true) + }; + + Some(([&port_name, " - ", device_name].concat(), plugged)) + } -#[derive(Debug)] -struct CardPort { - class: pipewire::MediaClass, - identifier: String, - description: String, -} + fn update_device_route(&mut self, route: &pipewire::Route, id: DeviceId) { + if matches!(route.available, Availability::No) { + return; + } + + match route.direction { + pipewire::Direction::Output => { + for (pos, &node) in self.sink_node_ids.iter().enumerate() { + let Some(&device) = self.device_ids.get(node) else { + continue; + }; + + if device != id { + continue; + } -#[derive(Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)] -pub enum DeviceId { - Alsa(u32), - Bluez5(String), - Unknown(), + if let Some(node_name) = self.route_plug_check(node, device, &route) { + self.sinks[pos] = node_name; + } + + break; + } + } + + pipewire::Direction::Input => { + for (pos, &node) in self.source_node_ids.iter().enumerate() { + let Some(&device) = self.device_ids.get(node) else { + continue; + }; + + if device != id { + continue; + } + + if let Some(node_name) = self.route_plug_check(node, device, &route) { + self.sources[pos] = node_name; + } + + break; + } + } + } + } + + fn translate_device_name(&self, input: &str) -> String { + input + .replace(" Controller", "") + .replace("High Definition Audio", &self.hd_audio_text) + .replace("HD Audio", &self.hd_audio_text) + .replace("USB Audio Device", &self.usb_audio_text) + } } #[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), /// Change the output volume. SinkVolumeApply(NodeId), + /// Unset the debounce + SinkVolumeDebounce, /// Change the output balance. SinkBalanceApply, /// Change the input volume. SourceVolumeApply(NodeId), + /// Unset the debounce. + SourceVolumeDebounce, /// On init of the subscription, channels for closing background threads are given to the app. SubHandle(Arc), } @@ -763,12 +810,12 @@ pub enum Server { /// Get default sinks/sources and their volumes/mute status. Pulse(pulse::Event), /// Get ALSA cards and their profiles. - Pipewire(pipewire::DeviceEvent), + Pipewire(pipewire::Event), } pub struct SubscriptionHandle { cancel_tx: futures::channel::oneshot::Sender<()>, - pipewire: pipewire::Sender<()>, + pipewire: pipewire::Sender, } impl std::fmt::Debug for SubscriptionHandle { @@ -776,52 +823,3 @@ impl std::fmt::Debug for SubscriptionHandle { f.write_str("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(); -} - -async fn pactl_set_card_profile(id: String, profile: String) { - tracing::debug!("pactl set-card-profile {id} {profile}"); - _ = tokio::process::Command::new("pactl") - .args(["set-card-profile", id.as_str(), profile.as_str()]) - .status() - .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()]) - .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; - }); -} 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/pipewire/device.rs b/subscriptions/sound/src/pipewire/device.rs new file mode 100644 index 000000000..a456fd511 --- /dev/null +++ b/subscriptions/sound/src/pipewire/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/subscriptions/sound/src/pipewire/mod.rs b/subscriptions/sound/src/pipewire/mod.rs new file mode 100644 index 000000000..43edc84c3 --- /dev/null +++ b/subscriptions/sound/src/pipewire/mod.rs @@ -0,0 +1,419 @@ +// 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}; + +mod profile; +pub use profile::Profile; + +mod route; +pub use route::Route; + +use libspa::{param::ParamType, pod::Pod}; +pub use pipewire::channel::Sender; + +use cosmic::iced_futures::{self, Subscription, stream}; +use futures::{SinkExt, executor::block_on}; +pub use pipewire::channel::channel; +use pipewire::{ + device::DeviceListener, + main_loop::MainLoopWeak, + node::NodeListener, + proxy::{ProxyListener, ProxyT}, + types::ObjectType, +}; +use std::{any::TypeId, cell::RefCell, ffi::CStr, rc::Rc, u32}; + +use crate::{DeviceId, NodeId}; +pub type PipewireId = u32; + +pub fn subscription() -> iced_futures::Subscription { + Subscription::run_with_id( + TypeId::of::(), + stream::channel(1, |sender| async { + let (_tx, rx) = channel(); + std::thread::spawn(move || run(rx, sender)); + + futures::future::pending().await + }), + ) +} + +pub fn run( + rx: pipewire::channel::Receiver, + on_event: futures::channel::mpsc::Sender, +) -> 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(), + nodes: IntMap::new(), + }, + main_loop: main_loop.downgrade(), + on_event, + })); + + let _request_handler = rx.attach(main_loop.loop_(), { + let state = Rc::downgrade(&state); + move |request| { + match request { + // Receives device object IDs for enumerating its profiles. + Request::EnumerateDevice(id) => { + if let Some(state) = state.upgrade() { + state.borrow_mut().enumerate_device(id); + } + } + + // Exit main loop on receivering terminate message. + 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; + }; + + let pw_id = device.upcast_ref().id(); + + let listener = device + .add_listener_local() + .info({ + let state = Rc::downgrade(&state); + move |info| { + 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; + }; + + 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); + } + } + } + }) + .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, (node, 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), + /// 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 { + EnumerateDevice(DeviceId), + Quit, +} + +#[derive(Copy, Clone, Debug, Default, Hash, Eq, PartialEq)] +pub enum Availability { + #[default] + Unknown, + No, + Yes, +} + +#[derive(Clone, Debug, Default, Hash, Eq, PartialEq)] +pub enum Direction { + Input, + #[default] + Output, +} + +struct Proxies { + devices: IntMap< + PipewireId, + ( + DeviceId, + pipewire::device::Device, + DeviceListener, + ProxyListener, + ), + >, + nodes: IntMap, +} + +struct State { + nodes: IntMap)>, + pub(self) proxies: Proxies, + main_loop: MainLoopWeak, + on_event: futures::channel::mpsc::Sender, +} + +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)); + self.enumerate_device(device_id); + } + + fn add_node(&mut self, id: PipewireId, node: Node) { + self.nodes.insert(id, (node.object_id, node.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) { + self.on_event(Event::AddRoute(id, index, route)); + } + + fn enumerate_device(&mut self, id: DeviceId) { + if let Some((_, device, _, _)) = self + .proxies + .devices + .values() + .find(|(device_id, ..)| id == *device_id) + { + 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 on_event(&mut self, event: Event) { + if block_on(self.on_event.send(event)).is_err() { + if let Some(main_loop) = self.main_loop.upgrade() { + main_loop.quit(); + } + } + } + + 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.on_event(Event::RemoveDevice(device_id)); + } + } + + fn remove_node(&mut self, id: PipewireId) { + if let Some((node_id, _)) = self.nodes.remove(id) { + self.on_event(Event::RemoveNode(node_id)); + } + + self.proxies.nodes.remove(id); + } +} + +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 an integer array. +unsafe fn int_array_from_pod(pod: &Pod) -> Option> { + if !pod.is_array() { + return None; + } + + let mut len = 0; + + unsafe { + let array: *mut std::ffi::c_int = + 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_owned()) + } +} diff --git a/subscriptions/sound/src/pipewire/node.rs b/subscriptions/sound/src/pipewire/node.rs new file mode 100644 index 000000000..d45f8d1e4 --- /dev/null +++ b/subscriptions/sound/src/pipewire/node.rs @@ -0,0 +1,123 @@ +// Copyright 2025 System76 +// SPDX-License-Identifier: MPL-2.0 + +use pipewire::node::{NodeInfoRef, NodeState}; + +/// Node information +#[must_use] +#[derive(Clone, Debug)] +pub struct Node { + pub object_id: u32, + pub device_id: Option, + pub card_profile_device: Option, + pub audio_channels: u32, + pub audio_position: String, + pub icon_name: String, + pub description: 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 object_id = None; + let mut device_id = None; + let mut card_profile_device = None; + let mut node_description: &str = ""; + let mut profile_description: &str = ""; + let mut icon_name = String::new(); + let mut node_name = String::new(); + let mut media_class = None; + let mut audio_channels = 1; + let mut audio_position = String::new(); + + 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" => { + profile_description = value; + } + + // 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 profile_description.is_empty() { + node_description.to_owned() + } else { + let device_name = node_description + .strip_suffix(profile_description) + .unwrap_or(node_description) + .trim_ascii_end(); + device_name.to_owned() + }, + 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) + } +} diff --git a/subscriptions/sound/src/pipewire/port.rs b/subscriptions/sound/src/pipewire/port.rs new file mode 100644 index 000000000..e2fc9577d --- /dev/null +++ b/subscriptions/sound/src/pipewire/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/subscriptions/sound/src/pipewire/profile.rs b/subscriptions/sound/src/pipewire/profile.rs new file mode 100644 index 000000000..8a0bb6c6d --- /dev/null +++ b/subscriptions/sound/src/pipewire/profile.rs @@ -0,0 +1,53 @@ +// Copyright 2025 System76 +// SPDX-License-Identifier: MPL-2.0 + +use crate::pipewire::{Availability, 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/subscriptions/sound/src/pipewire/route.rs b/subscriptions/sound/src/pipewire/route.rs new file mode 100644 index 000000000..40ad16957 --- /dev/null +++ b/subscriptions/sound/src/pipewire/route.rs @@ -0,0 +1,76 @@ +// Copyright 2025 System76 +// SPDX-License-Identifier: MPL-2.0 + +//! Currently unusued + +use crate::pipewire::{Availability, Direction, string_from_pod}; +use libspa::pod::Pod; + +#[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, +} + +impl Route { + pub fn from_pod(pod: &Pod) -> Option { + let mut index = 0; + let mut priority = 0; + // let mut device = 0; + let mut available = Availability::Unknown; + let mut direction = Direction::Output; + let mut name = String::new(); + let mut description = String::new(); + let mut devices = Vec::new(); + + let profile = pod.as_object().ok()?; + + for prop in profile.props() { + match prop.key().0 { + libspa_sys::SPA_PARAM_ROUTE_index => index = prop.value().get_int().ok()?, + libspa_sys::SPA_PARAM_ROUTE_priority => priority = prop.value().get_int().ok()?, + // libspa_sys::SPA_PARAM_ROUTE_device => device = prop.value().get_int().ok()?, + libspa_sys::SPA_PARAM_ROUTE_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_ROUTE_name => name = string_from_pod(prop.value())?, + libspa_sys::SPA_PARAM_ROUTE_description => { + description = string_from_pod(prop.value())?; + } + libspa_sys::SPA_PARAM_ROUTE_direction => { + 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 { super::int_array_from_pod(prop.value()) } { + devices = data; + } + } + _ => (), + } + } + + Some(Self { + index, + priority, + // device, + available, + direction, + name, + description, + devices, + }) + } +} diff --git a/subscriptions/sound/src/pulse.rs b/subscriptions/sound/src/pulse.rs index 5fd0d7ace..158fe6ad1 100644 --- a/subscriptions/sound/src/pulse.rs +++ b/subscriptions/sound/src/pulse.rs @@ -11,10 +11,10 @@ use libpulse_binding::{ channelmap::Map, context::{ Context, FlagSet, State, - introspect::{CardInfo, CardProfileInfo, Introspector, ServerInfo, SinkInfo, SourceInfo}, + introspect::{Introspector, ServerInfo, SinkInfo, SourceInfo}, subscribe::{Facility, InterestMaskSet, Operation}, }, - def::{PortAvailable, Retval}, + def::Retval, mainloop::{ api::MainloopApi, events::io::IoEventInternal, @@ -23,19 +23,18 @@ use libpulse_binding::{ 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, }; +use crate::pipewire::Availability; + pub fn subscription() -> iced_futures::Subscription { Subscription::run_with_id( "pulse", @@ -62,8 +61,10 @@ pub fn thread(sender: futures::channel::mpsc::Sender) { _inner: Rc::clone(&main_loop._inner), }), introspector: context.introspect(), + sink_port: RefCell::new(None), sink_volume: Cell::new(None), sink_mute: Cell::new(None), + source_port: RefCell::new(None), source_volume: Cell::new(None), source_mute: Cell::new(None), default_sink_name: RefCell::new(None), @@ -98,16 +99,6 @@ pub fn thread(sender: futures::channel::mpsc::Sender) { } } - // 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, @@ -122,14 +113,15 @@ pub fn thread(sender: futures::channel::mpsc::Sender) { #[derive(Clone, Debug)] pub enum Event { Balance(Option), - CardInfo(Card), + Channels(PulseChannels), DefaultSink(String), DefaultSource(String), - SinkVolume(u32), - Channels(PulseChannels), + SinkPortChange(String, Availability), SinkMute(bool), - SourceVolume(u32), + SinkVolume(u32), + SourcePortChange(String, Availability), SourceMute(bool), + SourceVolume(u32), } enum Request { @@ -315,101 +307,14 @@ impl PulseChannels { } } -#[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_port: RefCell>, sink_volume: Cell>, sink_mute: Cell>, + source_port: RefCell>, source_volume: Cell>, source_mute: Cell>, introspector: Introspector, @@ -417,85 +322,6 @@ struct Data { } 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 @@ -543,6 +369,28 @@ impl Data { if sink_info.name.as_deref() != self.default_sink_name.borrow().as_deref() { return; } + + if let Some(port) = sink_info.active_port.as_deref() { + let port_name = port.name.as_deref(); + if self.sink_port.borrow().as_deref() != port_name { + *self.sink_port.borrow_mut() = port_name.map(str::to_owned); + if let Some(name) = port_name { + if block_on(self.sender.borrow_mut().send(Event::SinkPortChange( + name.to_owned(), + match port.available { + libpulse_binding::def::PortAvailable::No => Availability::No, + libpulse_binding::def::PortAvailable::Yes => Availability::Yes, + _ => Availability::Unknown, + }, + ))) + .is_err() + { + self.main_loop.borrow_mut().quit(Retval(0)); + } + } + } + } + let balance = (sink_info.channel_map.can_balance() && sink_info.base_volume.is_normal()) .then(|| sink_info.volume.get_balance(&sink_info.channel_map)); @@ -594,6 +442,28 @@ impl Data { if source_info.name.as_deref() != self.default_source_name.borrow().as_deref() { return; } + + if let Some(port) = source_info.active_port.as_deref() { + let port_name = port.name.as_deref(); + if self.source_port.borrow().as_deref() != port_name { + *self.source_port.borrow_mut() = port_name.map(str::to_owned); + if let Some(name) = port_name { + if block_on(self.sender.borrow_mut().send(Event::SourcePortChange( + name.to_owned(), + match port.available { + libpulse_binding::def::PortAvailable::No => Availability::No, + libpulse_binding::def::PortAvailable::Yes => Availability::Yes, + _ => Availability::Unknown, + }, + ))) + .is_err() + { + self.main_loop.borrow_mut().quit(Retval(0)); + } + } + } + } + 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)); @@ -616,30 +486,11 @@ impl Data { } } - 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); }, ); @@ -649,17 +500,6 @@ impl Data { 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); }); } @@ -668,17 +508,6 @@ impl Data { 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); }); } @@ -687,17 +516,6 @@ impl Data { 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); }); } @@ -718,35 +536,7 @@ impl Data { 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/sound/src/wpctl.rs b/subscriptions/sound/src/wpctl.rs new file mode 100644 index 000000000..2d7b45be7 --- /dev/null +++ b/subscriptions/sound/src/wpctl.rs @@ -0,0 +1,48 @@ +// Copyright 2025 System76 +// SPDX-License-Identifier: MPL-2.0 + +use numtoa::BaseN; +use std::process::Stdio; + +pub async fn set_default(id: u32) { + 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; +} + +pub async fn set_profile(id: u32, index: u32) { + let id = BaseN::<10>::u32(id); + let index = BaseN::<10>::u32(index); + let value = ["{ index: ", index.as_str(), ", save: true }"].concat(); + _ = tokio::process::Command::new("pw-cli") + .args(["s", id.as_str(), "Profile", &value]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await; +} + +pub async fn set_mute(id: u32, mute: bool) { + let default = numtoa::BaseN::<10>::u32(id); + _ = tokio::process::Command::new("wpctl") + .args(["set-mute", default.as_str(), if mute { "1" } else { "0" }]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await; +} + +pub async fn set_volume(id: u32, volume: u32) { + let id = numtoa::BaseN::<10>::u32(id); + let volume = format!("{}.{:02}", volume / 100, volume % 100); + _ = tokio::process::Command::new("wpctl") + .args(["set-volume", id.as_str(), volume.as_str()]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await; +}