Compare commits

...

184 Commits

Author SHA1 Message Date
arabianq 6c59137639 fix icon 2026-06-01 23:37:46 +03:00
arabianq 3693a678ea new icon 2026-06-01 23:20:11 +03:00
arabianq 5511d23c3e deps: update cargo-sources.json 2026-06-01 23:05:02 +03:00
arabianq 818cd8b50d deps: cargo update 2026-06-01 23:04:42 +03:00
arabianq 6f7d631e28 deps: update rodio 2026-06-01 23:03:21 +03:00
arabianq 18904052c7 change version to 1.10.0 2026-06-01 23:01:32 +03:00
Tarasov Aleksandr 6841d8d1c3 refactor(gui): break down monolithic draw_footer into helper methods (#127)
Split the long continuous block in `draw_footer` into smaller,
modular methods (`draw_mic_selection`, `draw_master_volume`,
`draw_hotkeys_button`, `draw_settings_button`) for better
readability and maintainability.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-06-01 22:56:37 +03:00
Tarasov Aleksandr 105be87222 refactor(daemon): Refactor commands_loop to use handle_connection (#126)
- Extracted the main token processing loop body in `commands_loop` into `handle_connection` to resolve deep nesting and improve code readability.
- Improved request reading logic by using `(&mut stream).take(request_len as u64).read_to_end(&mut buffer)` to strictly bound allocation to `request_len` and prevent initialization overhead.
- Passed `cargo fmt` and `cargo clippy`.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-06-01 22:54:27 +03:00
Tarasov Aleksandr 0f8abbc443 refactor(daemon): Refactor src/utils/pipewire.rs to flatten deep conditionals and consolidate logic. (#124)
Moved redundant struct initialization into `AudioDevice::new` and unified port mapping assignments in an `add_port` method. This removes nesting using early returns and eliminates an unnecessary clone on the hashmap conversion step.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-06-01 22:51:58 +03:00
Tarasov Aleksandr 54011e7ff1 fix(daemon): Replace unwrap with safe Option handling in audio_player (#125)
The `play` method in `src/types/audio_player.rs` previously used `.unwrap()`
directly on `self.stream_handle.as_ref()`. This posed a security/stability risk
where if `stream_handle` was uninitialized or became `None` unexpectedly
despite the prior `ensure_stream()` call, it would cause the thread to panic
and potentially crash the application.

This commit replaces the `.unwrap()` call with `.ok_or_else` to safely handle
the `None` case, returning an `anyhow` error instead of panicking, adhering to
the project's no-panic policy.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-06-01 22:48:08 +03:00
arabianq dac9d53cef deps: update cargo-sources.json 2026-05-28 01:09:33 +03:00
arabianq 9da3799cd3 cargo update 2026-05-28 01:08:52 +03:00
arabianq d66369884c deps: update rodio 2026-05-28 01:08:13 +03:00
Tarasov Aleksandr 5e47e7d6fb feat(gui): support for soundpad:// uri (#123)
* feat(gui): support for soundpad:// uri

* fix: flatpak

* do not open gui when downloading file
2026-05-28 00:58:03 +03:00
Tarasov Aleksandr 695c83c9e6 feat(gui): theme selection (#122)
* fix: increment pkgrel to 2 for pwsp aur package

* feat(gui): implemented theme switching

* fix(gui): fixed incorrect colors in light theme

* fix(gui): fixed incorrect colors in light theme
2026-05-27 18:24:28 +03:00
dependabot[bot] 798a6d1887 chore(deps): bump system-fonts from 0.1.0 to 0.1.1 (#118)
* chore(deps): bump system-fonts from 0.1.0 to 0.1.1

Bumps [system-fonts](https://github.com/yijehyung/system-fonts) from 0.1.0 to 0.1.1.
- [Commits](https://github.com/yijehyung/system-fonts/commits)

---
updated-dependencies:
- dependency-name: system-fonts
  dependency-version: 0.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* deps: update cargo-sources.json

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: arabian <a.tevg@ya.ru>
2026-05-27 18:03:02 +03:00
Ryan Lucas bb18175a30 fix: incorrect install icon name on AUR PKGBUILDs (#117)
Co-authored-by: Ryan Lucas <36653660+maxteer@users.noreply.github.com>
2026-05-25 15:04:01 +03:00
arabianq 6ef3f8d76e deps: update cargo-sources.json 2026-05-22 00:33:13 +03:00
arabianq bec77f59bd change version to 1.9.1 2026-05-22 00:32:45 +03:00
arabianq dad1a62798 deps: cargo update
Removing audio_thread_priority v0.35.1
    Updating cpal v0.18.0 (https://github.com/RustAudio/cpal#2c7acf8e) -> #81b4d659
    Removing dbus v0.6.5
    Updating either v1.15.0 -> v1.16.0
    Removing libdbus-sys v0.2.7
    Removing mach2 v0.4.3
    Updating peniko v0.6.0 -> v0.6.1
    Updating serde_json v1.0.149 -> v1.0.150
    Updating winnow v1.0.2 -> v1.0.3
2026-05-22 00:30:53 +03:00
dependabot[bot] 84a4a01282 chore(deps): bump tokio from 1.52.1 to 1.52.3 (#115)
* chore(deps): bump tokio from 1.52.1 to 1.52.3

Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.52.1 to 1.52.3.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.52.1...tokio-1.52.3)

---
updated-dependencies:
- dependency-name: tokio
  dependency-version: 1.52.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix: replace std::sync::mpsc::sync_channel with tokio::sync::oneshot::channel to avoid deadlocks

* deps: update cargo-sources.json

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: arabian <a.tevg@ya.ru>
2026-05-22 00:28:46 +03:00
dependabot[bot] 88995f6fd1 chore(deps): bump pipewire from 0.9.2 to 0.10.0 (#116)
* chore(deps): bump pipewire from 0.9.2 to 0.10.0

Bumps pipewire from 0.9.2 to 0.10.0.

---
updated-dependencies:
- dependency-name: pipewire
  dependency-version: 0.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* deps: update cargo-sources.json

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: arabian <a.tevg@ya.ru>
2026-05-21 22:36:23 +03:00
arabianq 660ece9866 cargo fmt 2026-05-17 20:41:06 +03:00
Tarasov Aleksandr f2dcf2e0fe refactor: Split large draw.rs into modular views (#113)
Replaced the monolithic `src/gui/draw.rs` with a new `src/gui/views` directory module.
The GUI drawing logic is now cleanly separated into distinct files:
- `body.rs`
- `footer.rs`
- `header.rs`
- `hotkey_capture.rs`
- `hotkeys.rs`
- `settings.rs`
- `waiting_for_daemon.rs`

This organization vastly improves readability and maintainability without altering functionality. All shared helpers are centralized in `src/gui/views/mod.rs` and imports are strictly managed.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-05-17 18:36:02 +03:00
Tarasov Aleksandr fe655be59a fix: insecure fallback directory and secure file creation (#111)
* 🔒 Fix insecure fallback directory and secure file creation

The daemon's fallback runtime directory `get_runtime_dir()` was hardcoded to `/run/pwsp`, creating a risk of shared, insecure access in multi-user systems.
This commit secures the fallback logic by:
1. Creating a user-specific temporary directory (`/tmp/pwsp-$UID`).
2. Ensuring directory creation happens atomically with `0o700` permissions using `std::fs::DirBuilder`.
3. Validating the fallback directory strictly (checking UID, 0o700 permissions, and symlink status) if it already exists to mitigate symlink attacks.
4. Using `libc::geteuid()` for robust cross-platform UID extraction.
5. Fixing `is_daemon_running` and locking logic to use `fs::OpenOptions` instead of `fs::File::create` to prevent accidental file truncation on active lock files.

Co-authored-by: arabianq <55220741+arabianq@users.noreply.github.com>

* 🔒 Fix insecure fallback directory and secure file creation

The daemon's fallback runtime directory `get_runtime_dir()` was hardcoded to `/run/pwsp`, creating a risk of shared, insecure access in multi-user systems.
This commit secures the fallback logic by:
1. Creating a user-specific temporary directory (`/tmp/pwsp-$UID`).
2. Ensuring directory creation happens atomically with `0o700` permissions using `std::fs::DirBuilder`.
3. Validating the fallback directory strictly (checking UID, 0o700 permissions, and symlink status) if it already exists to mitigate symlink attacks.
4. Using safe `rustix::process::geteuid()` for robust cross-platform UID extraction, avoiding `unsafe` blocks.
5. Fixing `is_daemon_running` and locking logic to use `fs::OpenOptions` instead of `fs::File::create` to prevent accidental file truncation on active lock files.

Co-authored-by: arabianq <55220741+arabianq@users.noreply.github.com>

* small refactor

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-05-17 17:43:51 +03:00
Tarasov Aleksandr 78960cdc10 Refactor draw_files and draw_tree_node to improve maintainability and readability (#108)
- Extracted search field rendering to `draw_files_search_field`
- Extracted list rendering to `draw_files_list`
- Split `draw_tree_node` file and directory branch logic to `draw_tree_node_file` and `draw_tree_node_dir`

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-05-17 17:07:32 +03:00
Tarasov Aleksandr 0439cf815e perf: eliminate redundant PathBuf clone in GUI directory list (#110)
This commit optimizes the GUI render loop in `src/gui/draw.rs` during the rendering of the drag and drop directory list. Previously, `self.app_state.dirs.clone()` was cloning the entire vector of `PathBuf`s on every frame, which caused unnecessary allocations.

Now, `std::mem::take` temporarily removes the list of directories from `app_state.dirs` inside `show_vec`, and items are passed by reference rather than being cloned (`let path = item;` instead of `item.clone()`). Finally, the original list is restored into `app_state.dirs`. To ensure the state doesn't mutate or invalidate when `self.open_dir(&path)` is clicked, this logic has been deferred to run after the `app_state` vector is restored.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-05-17 17:07:04 +03:00
Tarasov Aleksandr 5ae82ef28c refactor(audio_player): remove unwrap in ensure_stream (#107)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-05-16 08:59:54 +03:00
Ryan Lucas 5f69345d45 Added Brazilian Portuguese translations and fixed some incorrect English translations. (#112)
* feat: add Brazilian Portuguese translations.

* fix: some incorrect English translations.
2026-05-16 08:52:32 +03:00
arabianq 930857312d change version to 1.9.0 2026-05-15 22:18:21 +03:00
Denis e884993dba feat: add Kazakh and Hebrew translations for various UI elements (#106) 2026-05-15 22:05:48 +03:00
arabianq 05dd4319cc refactor: remove selected file handling from FileAction and related input logic 2026-05-15 22:05:30 +03:00
arabianq e320c85a6f ci: fix regular builds 2026-05-15 21:48:00 +03:00
arabianq f02bbc1e1c ci: remove --release flag from regular builds 2026-05-15 21:47:30 +03:00
arabianq 02f1116076 fix: incorrect string for dirs.open 2026-05-15 21:42:08 +03:00
Tarasov Aleksandr 8155cceac8 feat: recursively show directories in files list (#105) 2026-05-15 21:41:12 +03:00
arabianq d974a93c04 refactor: cargo fmt 2026-05-15 21:06:20 +03:00
Tarasov Aleksandr c6d9f2d6e7 feat/localization (#104)
* initial i18n setup for PWSP-GUI

* add Russian locale

* add missing entries

* add Spanish locale

* add French locale

* add Chinese locale

* add Arabic locale

* update cargo-sources.json
2026-05-15 20:29:39 +03:00
Tarasov Aleksandr dc1ecc81ea refactor: replace all rust Result with anyhow::Result (#103) 2026-05-15 19:32:26 +03:00
Tarasov Aleksandr 9b70bcd69d Update copyright year and owner in LICENSE file 2026-05-15 18:50:20 +03:00
arabianq a07025b1f6 change version to 1.8.1 2026-05-15 15:02:15 +03:00
Tarasov Aleksandr c1d145fbc8 Revert "chore(deps): bump tokio from 1.52.1 to 1.52.3 (#99)" (#101)
* Revert "chore(deps): bump tokio from 1.52.1 to 1.52.3 (#99)"

This reverts commit 911417af40.

* deps: update cargo-sources.json
2026-05-15 14:55:52 +03:00
Tarasov Aleksandr 3d4b59761b packages(rpm): add required dependencies 2026-05-13 23:47:25 +03:00
arabianq ca9b5dd517 ci: add missing deps 2026-05-13 23:21:22 +03:00
arabianq 6863c9a6f8 deps: update cargo sources 2026-05-13 23:06:08 +03:00
arabianq 958a3efde5 change version to 1.8.0 2026-05-13 23:06:08 +03:00
Tarasov Aleksandr 30e75e924c fix: Opus audio does not work in mkv files (#95)
* change rodio to the fork with symphonia-adapter-libopus-v0.2.8

* update flatpak sources

* deps: update arabianq/rodio rev
2026-05-13 23:02:31 +03:00
arabianq 2b4b7ea730 cargo update
Adding audio_thread_priority v0.35.1
    Updating cc v1.2.61 -> v1.2.62
    Updating color v0.3.2 -> v0.3.3
    Updating coreaudio-rs v0.14.1 -> v0.14.2
    Updating cpal v0.18.0 (https://github.com/RustAudio/cpal#f938e338) -> #2c7acf8e
      Adding dbus v0.6.5
    Updating egui_extras v0.34.1 -> v0.34.2
    Updating hashbrown v0.17.0 -> v0.17.1
    Updating js-sys v0.3.97 -> v0.3.98
    Updating kurbo v0.13.0 -> v0.13.1
      Adding libdbus-sys v0.2.7
      Adding mach2 v0.4.3
    Updating naga v29.0.1 -> v29.0.3
    Updating no_std_io2 v0.9.3 -> v0.9.4
    Updating normpath v1.5.0 -> v1.5.1
    Updating opusic-sys v0.6.0 -> v0.7.3
    Updating orbclient v0.3.53 -> v0.3.54
    Updating pin-project v1.1.11 -> v1.1.13
    Updating pin-project-internal v1.1.11 -> v1.1.13
      Adding polycool v0.4.0
    Updating profiling v1.0.17 -> v1.0.18
    Updating quick-xml v0.39.2 -> v0.39.4
    Updating redox_syscall v0.7.4 -> v0.7.5
    Updating siphasher v1.0.2 -> v1.0.3
    Updating symphonia-adapter-libopus v0.2.7 -> v0.2.9
    Updating wasm-bindgen v0.2.120 -> v0.2.121
    Updating wasm-bindgen-futures v0.4.70 -> v0.4.71
    Updating wasm-bindgen-macro v0.2.120 -> v0.2.121
    Updating wasm-bindgen-macro-support v0.2.120 -> v0.2.121
    Updating wasm-bindgen-shared v0.2.120 -> v0.2.121
    Updating web-sys v0.3.97 -> v0.3.98
    Updating wgpu v29.0.1 -> v29.0.3
    Updating wgpu-core v29.0.1 -> v29.0.3
    Updating wgpu-core-deps-windows-linux-android v29.0.0 -> v29.0.3
    Updating wgpu-hal v29.0.1 -> v29.0.3
    Updating wgpu-naga-bridge v29.0.1 -> v29.0.3
    Updating wgpu-types v29.0.1 -> v29.0.3
    Updating zerofrom v0.1.7 -> v0.1.8
    Updating zvariant v5.10.1 -> v5.11.0
    Updating zvariant_derive v5.10.1 -> v5.11.0
2026-05-13 23:00:10 +03:00
arabianq dafe67f35f assets: update screenshot.png 2026-05-13 22:59:03 +03:00
arabianq 8fa22ca5b0 docs: update README.md 2026-05-13 22:56:37 +03:00
arabianq d72eaabf54 feat: load system fonts 2026-05-13 22:02:24 +03:00
arabianq 377b218592 deps: update cargo-sources.json for flatpak 2026-05-13 21:45:50 +03:00
dependabot[bot] 911417af40 chore(deps): bump tokio from 1.52.1 to 1.52.3 (#99)
Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.52.1 to 1.52.3.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.52.1...tokio-1.52.3)

---
updated-dependencies:
- dependency-name: tokio
  dependency-version: 1.52.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-13 21:44:25 +03:00
dependabot[bot] 573958c05b chore(deps): bump eframe from 0.34.1 to 0.34.2 (#98)
Bumps [eframe](https://github.com/emilk/egui) from 0.34.1 to 0.34.2.
- [Release notes](https://github.com/emilk/egui/releases)
- [Changelog](https://github.com/emilk/egui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/emilk/egui/compare/0.34.1...0.34.2)

---
updated-dependencies:
- dependency-name: eframe
  dependency-version: 0.34.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-13 21:44:09 +03:00
dependabot[bot] 0bb7ef3f33 chore(deps): bump egui from 0.34.1 to 0.34.2 (#97)
Bumps [egui](https://github.com/emilk/egui) from 0.34.1 to 0.34.2.
- [Release notes](https://github.com/emilk/egui/releases)
- [Changelog](https://github.com/emilk/egui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/emilk/egui/compare/0.34.1...0.34.2)

---
updated-dependencies:
- dependency-name: egui
  dependency-version: 0.34.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-13 21:43:54 +03:00
Tarasov Aleksandr 10f07cd895 ci: Add Cargo package ecosystem to Dependabot config (#96)
Configured Dependabot to update Cargo packages weekly.
2026-05-12 23:19:45 +03:00
Tarasov Aleksandr b2f2894aa1 perf(gui): remove O(N) allocation in hotkeys table render (#94)
This optimization removes an unnecessary `.cloned()` call inside `draw_hotkeys_table`
which previously forced a clone of every filtered `HotkeySlot` on every frame render.
Instead, we now hold a `Vec<&HotkeySlot>` and only clone `slot.slot` exactly when
a user interaction requires ownership to dispatch a `HotkeyAction`.

This eliminates constant heap allocations of `String` and `Request` components
while scrolling or idling in the hotkeys view.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-05-01 01:48:43 +03:00
Tarasov Aleksandr e6c8d720d5 update deps and change version to 1.7.6 (#92)
* change version to 1.7.6

* cargo update

cpal v0.18.0 #e5d618c6 -> #f938e338
fax v0.2.6 -> v0.2.7
fax_derive -
idna_adapter v1.2.1 -> v1.2.2
js-sys v0.3.95 -> v0.3.97
wasm-bindgen v0.2.118 -> v0.2.120
wasm-bindgen-futures v0.4.68 -> v0.4.70
wasm-bindgen-macro v0.2.118 -> v0.2.120
wasm-bindgen-macro-support v0.2.118 -> v0.2.120
wasm-bindgen-shared v0.2.118 -> v0.2.120
web-sys v0.3.95 -> v0.3.97
winnow -
zbus v5.14.0 -> v5.15.0
zbus_macros v5.14.0 -> v5.15.0
zbus_names v4.3.1 -> v4.3.2
zvariant v5.10.0 -> v5.10.1
zvariant_derive v5.10.0 -> v5.10.1
zvariant_utils v3.3.0 -> v3.3.1

* deps: update cargo-sources.json
2026-04-28 14:31:27 +03:00
RiDDiX a6d93ff528 fix clippy lints under rust 1.95 (#90) 2026-04-28 14:13:11 +03:00
RiDDiX bcf791d84c fix(packages): add cmake makedepend to aur source pkgbuild (#91) 2026-04-28 14:10:17 +03:00
Tarasov Aleksandr e4b0b10393 refactor(gui): refactor draw_hotkeys to improve code health (#86)
- Break down the monolithic `draw_hotkeys` method into smaller,
  focused component functions: `draw_hotkeys_header`,
  `draw_hotkeys_search`, `draw_hotkeys_table`, and
  `handle_hotkey_action`.
- Improve readability and maintainability of the `src/gui/draw.rs` file
  while preserving identical behavior.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-04-27 23:07:13 +03:00
Tarasov Aleksandr 11de96db58 refactor: simplify draw_track_control by extracting helper functions (#87)
Extracted distinct UI sections (playback controls, position slider, volume controls, stop button) into their own well-scoped helper functions within `SoundpadGui`. This significantly improves the maintainability and readability of `draw_track_control` while preserving the existing layout structure and state mutation behavior.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-04-27 23:05:39 +03:00
Tarasov Aleksandr 7396c0aef8 perf(gui): Optimize UI rendering loop by iterating over tracks by reference (#88)
* perf: optimize UI rendering loop by removing unnecessary Vec clone\n\n- Removed `clone()` on `self.audio_player_state.tracks` in `draw_header`\n- Iterated by reference instead of using an owned collection\n- Benchmarked and showed a significant performance improvement (7us -> 87ns)

Co-authored-by: arabianq <55220741+arabianq@users.noreply.github.com>

* build(flatpak): update cargo-sources.json to include criterion\n\nThe CI failed during the offline flatpak build because the newly added `criterion` dev-dependency was missing from `cargo-sources.json`. Regenerated `packages/flatpak/cargo-sources.json` to fix it.

Co-authored-by: arabianq <55220741+arabianq@users.noreply.github.com>

* Delete benches/ui_benchmark.rs

* refactor: remove garbage

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-04-27 23:03:37 +03:00
Tarasov Aleksandr fc2cd5e2da fix(daemon): Remove .expect() panics from PipeWire initialization (#89)
This commit addresses a security and stability vulnerability where failures in PipeWire context setup, connection, or registry acquisition would crash the entire thread (or daemon) via `.expect()` panics.

We now gracefully capture and propagate initialization errors up the call stack. A `sync_channel(0)` is used to signal the success or failure of the initial pipewire setup back to the calling functions (`get_all_devices`, `create_virtual_mic`, `create_link`). This prevents unexpected crashes and improves error resilience.

Also removed unneeded `pw_sender` panics on channel termination by simply dropping/ignoring the result.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-04-27 22:54:17 +03:00
Tarasov Aleksandr 1a37729cf1 fix: rpm builds
Added cmake as a build requirement for the package.
2026-04-26 19:52:03 +03:00
Tarasov Aleksandr 86b38a250e change version to 1.7.5 (#83)
* deps: rodio v0.22.2 -> 57ad9d8a9f30398f634fbf8e4e1d53dde7243c21 with symphonia-libopus

* change version to 1.7.5

* deps: update cargo-sources.json
2026-04-26 19:32:12 +03:00
Tarasov Aleksandr 54fa278cea feat: opus support (#82)
* deps: rodio v0.22.2 -> 57ad9d8a9f30398f634fbf8e4e1d53dde7243c21 with symphonia-libopus

* deps: update cargo-sources.json

* feat(gui): add .opus file extension support
2026-04-26 19:27:36 +03:00
Tarasov Aleksandr db040aa820 fix(ci): update input descriptions in workflow for clarity (#81) 2026-04-25 19:31:20 +03:00
Tarasov Aleksandr 04449e7525 fix(ci): update workflow inputs for tag and build branch selection (#80) 2026-04-25 19:28:48 +03:00
Tarasov Aleksandr 9f50809a99 fix(ci): add SDK extensions installation step (#79) 2026-04-25 16:57:01 +03:00
Tarasov Aleksandr 7dda4bc2b1 fix(ci): update Flatter container image to version 25.08 (#77) 2026-04-25 16:46:56 +03:00
Tarasov Aleksandr 1569955e12 chore(ci): Add flatter to host Flatpak repo on GitHub Pages (#76)
* chore(ci): Add flatter to host Flatpak repo on GitHub Pages

- Update release.yml to not upload .flatpak file to releases
- Create flatter.yml to automate building and hosting of Flatpak via GitHub pages using andyholmes/flatter
- Add nightly branch for main pushes and stable branch for releases
- Update README.md with the new Flatpak installation instructions

Co-authored-by: arabianq <55220741+arabianq@users.noreply.github.com>

* Potential fix for pull request finding 'CodeQL / Workflow does not contain permissions'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-04-25 16:38:34 +03:00
Tarasov Aleksandr 9adc6cfbda update dependencies and change version to 1.7.4 (#75)
* deps: cargo update

cc v1.2.60 -> v1.2.61
libc v0.2.185 -> v0.2.186
winnow v1.0.1 -> v1.0.2

* deps: update cargo-sources.json for flatpak

* change version to 1.7.4
2026-04-25 15:58:49 +03:00
Tarasov Aleksandr 76b1d4f345 fix(gui): footer and hotkeys table are no longer clipped because of long filenames (#74)
* fix: truncate file button text in draw function so footer is no clipped

* fix(gui): fix hotkeys table clipping with egui_extras::TableBuilder

fully reworked hotkeys page

* deps: update flatpak cargo-sources.json
2026-04-25 15:44:50 +03:00
Tarasov Aleksandr 10f9937dc3 tests: parse_command set_volume edge cases (#73)
Add unit tests for parse_command in src/utils/commands.rs to ensure
robust handling of set_volume edge cases including missing or
invalid volume and id arguments.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-04-25 14:37:55 +03:00
Tarasov Aleksandr 498c09eb50 fix(gui): remove unwrap() calls in input handling to prevent potential panics (#72)
Replaced `.chars().next().unwrap()` with `.chars().next().is_some_and(...)` in `chord_from_event` and `parse_chord` functions in `src/gui/input.rs`. This ensures that even if the string is empty, the application will not panic, adhering to the project's safety guidelines and resolving a potential security vulnerability.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-04-25 14:36:46 +03:00
Tarasov Aleksandr 78e0a133b6 Bump version to 1.7.3 and update dependencies (#70)
* change version to 1.7.3

* deps: cargo update

orbclient -> v0.3.53
wasip2 -> v1.0.3+wasi-0.2.9
wit-bindgen + v0.57.1

* deps: update cargo-sources.json for flatpak
2026-04-21 19:41:19 +03:00
Tarasov Aleksandr 7f8b7194b6 refactor: move PipeWire initialization into a reusable helper function (#69)
Extract duplicated `pipewire::init()`, `MainLoopRc::new()`, and `ContextRc::new()` setup code from `pw_get_global_objects_thread`, `create_virtual_mic`, and `create_link` into a shared `setup_pipewire_context` helper in `src/utils/pipewire.rs`. Also ran codebase-wide linters to improve code quality.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-04-20 19:22:50 +03:00
Tarasov Aleksandr 302f153b91 refactor: break down handle_input into smaller methods in src/gui/input.rs (#67)
Extract sections from the long `handle_input` function into smaller,
context-specific helper methods such as `handle_hotkey_assignment`,
`handle_toggles`, `handle_playback_and_focus`, `handle_file_playback`,
`handle_navigation`, and `handle_hotkey_triggers`. This significantly
improves the maintainability and readability of `src/gui/input.rs`
while preserving original functionality.

In addition, ran `cargo clippy --fix` on the project to resolve a few
other minor health issues, like collapsing nested `if` statements and
reducing unnecessary allocations.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-04-20 19:21:59 +03:00
Tarasov Aleksandr f87dcb1564 refactor: remove unnecessary string cloning when finding hotkey conflicts (#68)
Changed `HotkeyConfig::find_conflicts` to return a `Vec<(&str, &str)>` rather than allocating owned `Strings`. In `src/gui/draw.rs`, the code now builds a `HashSet<&str>` directly from the borrowed strings using array-based flat-mapping, avoiding intermediate `Vec` allocations and redundant clones.

Benchmarked to be approximately 3.5x faster in scenarios involving many configured slots.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-04-20 19:20:54 +03:00
Tarasov Aleksandr d4d16f6ce7 Prepare Application for Flathub Submission (#64)
* chore(flatpak): prepare application for Flathub submission

- Change Flatpak filesystem permissions from `host` to `home` to comply with Flathub sandbox rules
- Remove `--share=network` build argument and implement offline Rust building using `flatpak-cargo-generator.py`
- Add generated `cargo-sources.json` to the Flatpak manifest to download dependencies
- Update `CARGO_HOME` environment variable to ensure vendored dependencies are found by `cargo build --offline`
- Update `.desktop` categories to meet Flathub specs (`AudioVideo` requirement)
- Add required `<releases>`, `bugtracker`, `vcs-browser` URLs, and valid `<developer>` tags to `metainfo.xml`

Co-authored-by: arabianq <55220741+arabianq@users.noreply.github.com>

* chore(flatpak): prepare application for Flathub submission

- Change Flatpak filesystem permissions from `host` to `home` to comply with Flathub sandbox rules
- Remove `--share=network` build argument and implement offline Rust building using `flatpak-cargo-generator.py`
- Add generated `cargo-sources.json` to the Flatpak manifest to download dependencies
- Explicitly set `CARGO_HOME=$PWD/cargo` in build-commands to ensure vendored dependencies are found by `cargo build --offline`
- Update `.desktop` categories to meet Flathub specs (`AudioVideo` requirement)
- Add required `<releases>`, `bugtracker`, `vcs-browser` URLs, and valid `<developer>` tags to `metainfo.xml`

Co-authored-by: arabianq <55220741+arabianq@users.noreply.github.com>

* chore(flatpak): add script to generate cargo sources

- Added `packages/flatpak/generate-sources.sh` to automate the generation of `cargo-sources.json`
- Script downloads the `flatpak-cargo-generator.py` tool from upstream, generates the offline sources map based on `Cargo.lock`, and cleans up after itself

Co-authored-by: arabianq <55220741+arabianq@users.noreply.github.com>

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-04-17 19:35:36 +03:00
Tarasov Aleksandr 949307fcf8 Update dependencies and change version to 1.7.2 (#63)
* deps: bump tokio to 1.52.1

* deps: bump clap to 4.6.1

* deps: cargo update

bitflags -> v2.11.1
core2 -
dary_heap -> v0.3.9
font-types -> v0.11.3
include-flate -> v0.3.3
include-flate-codegen -> v0.3.3
include-flate-compress -> v0.3.3
libc -> v0.2.185
libflate -> v2.3.0
libflate_lz77 -> v2.3.0
no_std_io2 + v0.9.3
portable-atomic-util -> v0.2.7
pxfm -> v0.1.29
rayon -> v1.12.0
uuid -> v1.23.1
webbrowser -> v1.2.1

* change version to 1.7.2
2026-04-17 14:42:59 +03:00
Tarasov Aleksandr 2a8fcca06b Fix virtual mic audio linking (#62)
* Fix virtual mic audio linking by managing it in AudioPlayer lifecycle

- Moved `link_player_to_virtual_mic` to `src/utils/pipewire.rs` and updated it to return a termination sender.
- Added `player_link_sender` to `AudioPlayer` to manage the PipeWire link between the daemon and the virtual mic.
- Integrated linking logic into `AudioPlayer::play` and `AudioPlayer::update` to ensure the link is established when audio starts playing.
- Ensured the link is terminated in `AudioPlayer::drop_stream` when the audio sink is closed.
- Removed redundant and potentially failing startup linking loop from the daemon.
- Fixed log spam by ensuring `link_player` is only attempted when necessary and errors are handled gracefully.
- Maintained compatibility with stable Rust by avoiding unstable features.

Co-authored-by: arabianq <55220741+arabianq@users.noreply.github.com>

* small refactor

* refactor

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-04-17 14:24:58 +03:00
Tarasov Aleksandr 5c4b8f4b45 refactor(gui): replace verbose key matching with egui native methods (#60)
Replaced the large match blocks in `chord_from_event` and `parse_chord`
with `egui::Key::name()` and `egui::Key::from_name()`. This drastically
reduces boilerplate code while maintaining the existing behavior that
strictly allows only single-character alphanumeric keys and 'F' keys for
chords.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-04-17 13:58:08 +03:00
Tarasov Aleksandr 70c7e3789b 🔒 Fix potential memory exhaustion in socket reads (#59)
Addresses a security vulnerability where the daemon or client could be
forced to allocate up to 10MB of memory per malformed socket message,
potentially leading to Out-Of-Memory (OOM) crashes.

Changes:
- Introduced a central `MAX_MESSAGE_SIZE` constant of 128KB in `src/types/socket.rs`.
- Enforced the 128KB limit on incoming requests in `src/bin/daemon.rs`.
- Enforced the 128KB limit on incoming responses in `src/utils/daemon.rs`.
- Preserved detailed `eprintln!` logging when messages are rejected.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-04-17 13:56:29 +03:00
Tarasov Aleksandr 5367a3daae version 1.7.1, update deps, update docs (#57)
* refactor: removed garbage

* change version to 1.7.1

* cargo fmt

* cargo update

* docs: add information about hotkeys to README

* docs: small refactor
2026-04-12 17:23:04 +03:00
Tarasov Aleksandr 42c0170044 fix: hotkeys setting from pwsp-gui (#56)
* refactor: do not overwrite incorrect hotkeys config

* fix: hotkeys not saved via pwsp-gui
2026-04-12 17:05:10 +03:00
RiDDiX cb56cb3a04 fix: drop audio stream when idle to allow system suspend (#54)
* fix: drop audio stream when idle to allow system suspend

The daemon kept its ALSA playback stream open permanently, which
PipeWire reported as a running Stream/Output/Audio node even with
no tracks playing. This prevented desktop environments from detecting
idle state and entering suspend.

- Make the audio sink on-demand: created when playback starts,
  dropped when all tracks finish
- Reduce player loop polling from 100ms to 2s when idle
- Throttle PipeWire device enumeration to every ~5s while playing
- Log only first and last link retry attempt instead of all 60
2026-04-12 00:42:10 +03:00
Tarasov Aleksandr 5a2418325d change version to 1.7.0 (#52) 2026-04-09 10:10:50 +03:00
Tarasov Aleksandr a948ea2dcd 🧹 remove unsafe unwrap in file name parsing (#51)
Replaced an unsafe `.unwrap()` with `.unwrap_or_default()` in `src/gui/draw.rs`
when parsing file names. This prevents potential panics on invalid paths.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-04-09 09:52:14 +03:00
RiDDiX a156df346b feat: add hotkey system (#48)
* feat: add hotkey system for playing individual sounds

Slot-based hotkey mappings stored in ~/.config/pwsp/hotkeys.json.
Daemon serves hotkey IPC commands for CLI/compositor bindings.
GUI supports focused hotkey triggers, a dedicated Hotkeys panel
with search and conflict detection, file badges, and a key chord
capture dialog. CLI gains play-hotkey, get hotkeys, set hotkey,
set hotkey-key, and clear-hotkey subcommands.

* feat: add global hotkey support via evdev

Listen for keyboard events directly from /dev/input using evdev,
enabling hotkeys to work system-wide regardless of window focus
or display server (X11, GNOME, KDE Plasma, Hyprland).

The daemon spawns async listeners for each keyboard device at
startup, tracks modifier state, and triggers playback when a
configured chord matches. Requires the user to be in the 'input'
group; logs a warning and continues without global hotkeys if
devices are inaccessible.

* various changes

* refactor: route hotkey mutations through daemon IPC

GUI no longer writes hotkey config directly to disk. Instead, all
mutations (set slot, set key chord, clear chord, remove slot) are
sent to the daemon via IPC, which persists the changes. The state
thread periodically syncs the hotkey config back from the daemon,
so CLI-made changes are reflected in the GUI.

New IPC commands: set_hotkey_action (arbitrary action per slot),
clear_hotkey_key (remove key chord without removing the slot).

Also removes unreachable capture overlay from draw_hotkeys().

* small refactor

---------

Co-authored-by: arabian <a.tevg@ya.ru>
2026-04-06 21:43:41 +03:00
qrlh 7a13ae55a6 Add mka (Matroska audio) to the extensions exposed in the GUI (#49)
Since the mka and mkv extensions are both Matroska format and share
magic bytes, mka should work perfectly fine, even though it isn't
explicitly mentioned by Symphonia. Tested it and it works, (as long as
the audio codec is supported).
2026-04-04 18:58:24 +03:00
Tarasov Aleksandr b2b83f5c32 merge dev
* cargo fmt

* deps: bump clap to 4.6.0

* deps: cargo update

* Fix daemon autostart issue caused by sync pipewire retry loop (#43) (#44)

At boot time, PipeWire takes some time to register the `pwsp-daemon` and `pwsp-virtual-mic` devices. Previously, the daemon's retry loop for `link_player_to_virtual_mic()` was synchronous and limited to 5 attempts (1.5 seconds total). This caused systemd autostarts to fail with a code 1 if the devices were not yet available.

This change replaces the synchronous wait with an asynchronous `tokio::spawn` task. It will retry the link attempt up to 60 times with a 1-second delay without blocking the startup of the rest of the daemon. This prevents it from exiting abruptly during autostart.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>

* deps: bump egui & eframe version to 0.34.1

* feat: replaced App::update with new App::logic and App::ui

* deps: bump egui_material_icons to 0.6.0

* deps: bump egui_dnd to 0.15.0

* fix: use .codepoint for icons

* refactor

* refactor: replaced deprecated CentralPanel::show with CentralPanel::show_inside

* refactor

* change version to 1.6.3

* update rust toolchain in github actions

* update freedesktop platform version to 25.08 for flaptak

* update github actions for flatpak builds

* add flatpak-builder installation inside actions

* add flathub configuration to actions

* add --user flag to flathub configuration

* remove sudo from flatpak actions

* Fix/dev flatpak actions 6082245116761610541 (#47)

* remove sudo

* remove steps

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-27 15:46:06 +03:00
Tarasov Aleksandr f01a0e656c Fix daemon autostart issue caused by sync pipewire retry loop (#43)
At boot time, PipeWire takes some time to register the `pwsp-daemon` and `pwsp-virtual-mic` devices. Previously, the daemon's retry loop for `link_player_to_virtual_mic()` was synchronous and limited to 5 attempts (1.5 seconds total). This caused systemd autostarts to fail with a code 1 if the devices were not yet available.

This change replaces the synchronous wait with an asynchronous `tokio::spawn` task. It will retry the link attempt up to 60 times with a 1-second delay without blocking the startup of the rest of the daemon. This prevents it from exiting abruptly during autostart.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-22 17:05:26 +03:00
Tarasov Aleksandr 6114b9a7f8 🔒 [security] Set restricted permissions on socket and runtime directory (#40)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-21 21:03:48 +03:00
Tarasov Aleksandr b8baeb6226 refactor: replace contains_key/unwrap with if let Some in pipewire.rs (#39)
This commit replaces two instances of `contains_key` followed by
`get_mut().unwrap()` with the more idiomatic `if let Some(...)` pattern
in `src/utils/pipewire.rs`. This reduces redundant hash map lookups and
eliminates potential panics from `unwrap()`.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-21 21:03:30 +03:00
Tarasov Aleksandr 02306b5893 refactor: simplify file and directory navigation logic (#37)
Refactored the navigation logic in `src/gui/input.rs` to use idiomatic Rust patterns.
Replaced clunky manual index calculations and type casting with `match` expressions and modular arithmetic on `usize`.
This improvement enhances readability and maintainability by eliminating nested `if/else` blocks and potential overflow issues from integer casts.

🎯 **What:** The code health issue addressed
- Refactored directory and file navigation logic to use `usize` and modular arithmetic.
- Replaced manual wrap-around logic with idiomatic `match` expressions.

💡 **Why:** How this improves maintainability
- Eliminates unnecessary and potentially risky type casting (e.g., `i8`, `i64`).
- Reduces code nesting and complexity, making it easier to read and extend.
- Standardizes the circular navigation pattern across the GUI.

 **Verification:** How you confirmed the change is safe
- Manually reviewed and verified the logic for all key combinations (ArrowUp, ArrowDown, both, or none).
- Confirmed correct behavior for both initial selection (None) and existing selection (Some) states.

 **Result:** The improvement achieved
- Cleaner, more idiomatic Rust code for list navigation.
- Reduced potential for index-related bugs.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-08 02:40:39 +03:00
Tarasov Aleksandr 3add499bd7 Optimize get_device with iterator chaining (#38)
Replaces manual vector extension and linear search with an iterator
chain. This avoids an unnecessary allocation and potential reallocation
of the `input_devices` vector and allows for short-circuiting if the
device is found early.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-08 02:40:25 +03:00
Tarasov Aleksandr 3c2e943e18 fix(security): eliminate TOCTOU vulnerability during socket removal (#36)
Directly attempt to remove the daemon socket file and handle NotFound errors
instead of checking for its existence first. This prevents a potential
race condition where the file could be replaced between the check and
the removal.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-08 02:13:19 +03:00
Tarasov Aleksandr 261f83efd4 🧹 Code Health: Handle AudioPlayer initialization errors safely (#35)
* 🧹 Refactor: Replace unsafe unwrap in get_audio_player

Co-authored-by: arabianq <55220741+arabianq@users.noreply.github.com>

* 🧹 Refactor: Replace unsafe unwrap in get_audio_player

Resolved GitHub CI failure where a syntax error was introduced due to a bad automated merge with main. Rebased cleanly to ensure only the get_audio_player code health changes are included.

Co-authored-by: arabianq <55220741+arabianq@users.noreply.github.com>

* Delete tests/perf_play.rs

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-08 01:36:25 +03:00
Tarasov Aleksandr c6577cd5e0 🧹 Replace unsafe unwrap on Mutex lock with unwrap_or_else (#33)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-08 00:43:36 +03:00
Tarasov Aleksandr 95761f6a5a Optimize AudioPlayer::play by offloading sync file I/O to spawn_blocking (#29)
* perf(audio_player): offload synchronous I/O and decoder init to spawn_blocking

Moved synchronous file system operations (`fs::File::open` and `file_path.exists()`) and CPU-bound decoder initialization (`Decoder::try_from`) inside the async `AudioPlayer::play` method to `tokio::task::spawn_blocking`.
This prevents starving the Tokio worker threads during disk operations and significantly reduces event loop latency.

Co-authored-by: arabianq <55220741+arabianq@users.noreply.github.com>

* perf(audio_player): offload synchronous I/O and decoder init to spawn_blocking

Moved synchronous file system operations (`fs::File::open` and `file_path.exists()`) and CPU-bound decoder initialization (`Decoder::try_from`) inside the async `AudioPlayer::play` method to `tokio::task::spawn_blocking`.
This prevents starving the Tokio worker threads during disk operations and significantly reduces event loop latency.

Co-authored-by: arabianq <55220741+arabianq@users.noreply.github.com>

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-08 00:41:34 +03:00
Tarasov Aleksandr d6effc972e 🧹 refactor: replace unsafe unwraps in pipewire port parsing with safe destructuring (#34)
Replaced sequential unwraps on pipewire properties ("node.id", "port.id", "port.name")
with an `if let` and `and_then()` pattern in `src/utils/pipewire.rs`. This provides
safety against daemon crashes when properties are missing or malformed by silently
returning instead of panicking.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-08 00:41:06 +03:00
Tarasov Aleksandr 498d0d25af perf: optimize AudioPlayer::update to avoid blocking the Tokio executor thread (#32)
Offload synchronous `fs::File::open` and `Decoder::try_from` operations to `tokio::task::spawn_blocking` in `AudioPlayer::update`. This allows the Tokio runtime to process other asynchronous tasks concurrently without being blocked by file I/O operations and audio header decoding during looped playback.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-08 00:36:08 +03:00
Tarasov Aleksandr c99d0749e3 🔒 Add request size limit to daemon socket IPC to prevent OOM panic (#31)
The daemon was allocating memory based on an unverified length prefix
sent over the unauthenticated Unix socket, potentially allowing a malicious
client to cause an Out-Of-Memory panic (DoS). A 10 MB size limit has been
introduced.

Note: The previously reported `unwrap()` panic on invalid JSON payloads
was already fixed and replaced with a safe `match` block in a prior commit.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-08 00:34:50 +03:00
Tarasov Aleksandr 151f43f1ab Optimize value lookup in GetFullStateCommand loop (#30)
Moves the check `if let Some(current_input_name) = &audio_player.input_device_name`
outside the loop over `input_devices` in `GetFullStateCommand::execute`.

By creating two separate loops (one for when an input device name is selected,
and one for when it is not), we eliminate the overhead of evaluating the `Option`
on every single iteration of the `input_devices` array. This effectively unswitches
the loop and avoids repeatedly accessing the `audio_player` struct field.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-08 00:32:42 +03:00
Tarasov Aleksandr 077518019f perf: cache and sort microphone inputs once instead of every frame (#27)
In `draw_footer`, the `all_inputs` HashMap was being collected into a Vec
and sorted on every single UI frame (~60 FPS). This caused unnecessary
overhead and allocations.

This commit introduces a cached `all_inputs_sorted` vector in the
`AudioPlayerState` which is populated only when the inputs map changes
during the state sync. `draw_footer` now loops over this pre-sorted
vector directly.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-08 00:28:45 +03:00
Tarasov Aleksandr 968eba80e6 Fix: Prevent panic on invalid configuration files (#26)
When the user's daemon.json or gui.json configuration files become corrupted or invalid (e.g. invalid JSON), the application panics as the load_from_file function previously bubbled up the error causing a panic. This fix modifies load_from_file for both DaemonConfig and GuiConfig to catch JSON parsing errors using a match statement and return a Default configuration instead.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-08 00:28:09 +03:00
Tarasov Aleksandr aa77a8d212 refactor: replace unsafe unwrap with if let in config saving (#24)
Removed the unsafe `.unwrap()` call when attempting to get the parent directory of `config_path` in `DaemonConfig::save_to_file` and `GuiConfig::save_to_file`. Replaced it with an idiomatic `if let Some(config_dir)` check to improve code safety and maintainability.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-08 00:26:45 +03:00
Tarasov Aleksandr 4b50645c93 refactor(gui): replace unsafe unwrap on gui thread lock (#23)
Replaces `audio_player_state_shared.lock().unwrap()` with `.unwrap_or_else(|e| e.into_inner())` in `src/utils/gui.rs` to allow safe recovery from poisoned locks and avoid application panics.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-08 00:25:42 +03:00
Tarasov Aleksandr 39648f7781 refactor(gui): replace unsafe unwrap with idiomatic if let Some in input handling (#22)
This commit addresses a code health issue in `src/gui/input.rs` where an `.is_some()` check was followed by an unsafe `.unwrap()` on `self.app_state.selected_file`.

The logic has been updated to use the idiomatic `if let Some(path) = self.app_state.selected_file.clone()` pattern. The `.clone()` is necessary because the subsequent methods (`self.play_file` and `self.stop`) require a mutable borrow (`&mut self`), which would conflict with an immutable borrow of `self.app_state.selected_file`. This change ensures the code is safe and panic-free while satisfying Rust's borrow checker rules. Behavior remains unchanged.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-08 00:24:25 +03:00
Tarasov Aleksandr c649ef5410 merge dev (#21)
* change version to 1.6.2

* deps: bump tokio to 1.50.0

* deps: bump rodio to 0.22.2

* cargo update
2026-03-07 15:34:19 +03:00
Tarasov Aleksandr 0dfd841e6d change version to 1.6.2 (#20) 2026-03-07 15:28:10 +03:00
Tarasov Aleksandr 89ce111542 🔒 [security fix] Handle serialization failures in daemon commands and socket communication. (#16)
- Replaced `.unwrap()` with proper error handling during JSON serialization in `GetStateCommand`, `GetTracksCommand`, and `GetFullStateCommand`.
- Added error handling for malformed client requests in the daemon's main loop.
- Ensured the daemon stays running even if serialization or deserialization fails.
- Handled potential errors from `get_all_devices()`.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-06 23:02:07 +03:00
Tarasov Aleksandr 80a8b1a45f perf: optimize value lookup in loop in GetFullStateCommand::execute (#18)
Moved the access of `audio_player.input_device_name` outside the loop
in `GetFullStateCommand::execute` to avoid repeated field access and
Option checking during iteration.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-06 23:00:56 +03:00
Tarasov Aleksandr f5c7d9bb2c Merge pull request #19 from arabianq/fix-unsafe-unwrap-on-file-path-conversion-in-cli-6901726970689342812
🧹 Fix unsafe unwrap on file path conversion in CLI and GUI
2026-03-06 22:59:13 +03:00
Tarasov Aleksandr bcd39eb6a2 Merge pull request #17 from arabianq/fix/github-actions-8199872172158141449
chore: consolidate and optimize github actions workflows
2026-03-06 22:56:37 +03:00
google-labs-jules[bot] 47a7674c14 🧹 Fix unsafe unwrap on file path conversion in CLI and GUI
Replaced `.to_str().unwrap()` with `.to_string_lossy()` when converting
`PathBuf` to `String` to prevent potential crashes if the path contains
invalid Unicode. This change improves the robustness of both the CLI
and GUI components when handling file paths.

- Modified `src/bin/cli.rs` to safely handle `file_path`.
- Modified `src/gui/mod.rs` to safely handle `path` in `play_file`.

Co-authored-by: arabianq <55220741+arabianq@users.noreply.github.com>
2026-03-06 19:52:30 +00:00
google-labs-jules[bot] d33ee0c69e chore: consolidate and optimize github actions workflows
Replaced 6 separate, redundant workflow files (`git-archive.yml`, `git-deb.yml`, `git-flatpak.yml`, `release-archive.yml`, `release-deb.yml`, `release-flatpak.yml`) with 2 consolidated workflows (`build.yml` and `release.yml`).

- Consolidated `.zip` and `.deb` building into a single `linux-build` and `linux-release` job to avoid running `cargo build --release` multiple times.
- Added parallel `flatpak-build` and `flatpak-release` jobs to the respective unified workflows.
- Improved `release.yml` with a `prepare` job that correctly queries and passes the release tag to dependent build jobs.
- Fixed an issue in the `prepare` job where an undefined bash `$GITHUB_TOKEN` was used instead of `${{ secrets.GITHUB_TOKEN }}`.

Co-authored-by: arabianq <55220741+arabianq@users.noreply.github.com>
2026-03-06 19:43:38 +00:00
arabianq 624310eae5 feat: convert aur submodules to regular directories 2026-03-06 22:27:02 +03:00
arabianq 92a576de37 fix(pwsp-daemon): added retries to link_player_to_virtual_mic()
https://github.com/arabianq/pipewire-soundpad/issues/15
2026-03-06 15:19:06 +03:00
arabianq ce948ce678 feat: you can now get volume for all sound individually, not only via fullstate 2026-02-25 00:34:05 +03:00
arabianq e72fc519a0 Merge branch 'main' of github.com:arabianq/pipewire-soundpad 2026-02-25 00:13:49 +03:00
arabianq 8126efe8d9 feat(pwsp-gui): slash now toggles search focus 2026-02-25 00:12:50 +03:00
arabianq a7dd0b97d1 fix(pwsp-gui): some hotkeys now won't work when search entry is focused 2026-02-25 00:10:55 +03:00
arabianq 5f9aad7fa2 fix(pwsp-gui): some hotkeys now won't work when search entry is focused 2026-02-25 00:09:39 +03:00
arabianq 7e66a9241b change version to 1.6.1 2026-02-23 14:01:40 +03:00
arabianq 02ad7337a1 cargo update 2026-02-23 13:59:42 +03:00
arabianq c08898e4f2 deps: bump rodio to 0.22.1 2026-02-23 13:58:08 +03:00
arabianq ed8b04caa9 deps: bump clap to 4.5.60 2026-02-23 13:53:13 +03:00
arabianq 58e5f039be feat(cli, flatpak): implemented kill action for pwsp-cli.
use it instead of pkill in the flatpak wrapper
2026-02-23 13:40:41 +03:00
arabianq eb89733715 fix(flatpak): typo in wrapper 2026-02-23 13:17:26 +03:00
arabianq 476fd325ef fix(flatpak): use pkill -f instead of killall 2026-02-23 12:55:16 +03:00
arabianq da49c96e53 fix(flatpak): removed color option from wrapper 2026-02-23 12:44:17 +03:00
arabianq f0e05379f7 fix(flatpak): removed suggest_on_error from wrapper 2026-02-23 12:30:22 +03:00
arabianq 3d3523fd7a feat(flatpak): new wrapper in python that supports pwsp-daemon, pwsp-cli and pwsp-gui 2026-02-23 12:08:47 +03:00
arabianq 81da36f03c bump version to 1.6.0 2026-02-14 15:50:06 +03:00
arabianq 8bfa5daf78 feat: show pwsp-gui version in settings 2026-02-14 15:46:56 +03:00
arabianq b816d2aa88 feat: get daemon's version using pwsp-cli
pwsp-cli get daemon-version
2026-02-14 15:43:17 +03:00
arabianq 23ae562849 refactor: better Cargo.toml formatting 2026-02-14 15:20:03 +03:00
arabianq e3bc1fd55f deps: cargo update 2026-02-14 15:16:43 +03:00
arabianq 15964f205b deps: bump clap version to 4.5.58 2026-02-14 15:15:36 +03:00
arabianq 6a0ac61033 refactor: removed icons:: everywhere 2026-02-14 15:14:03 +03:00
arabianq 4b802273f4 Merge branch 'main' of github.com:arabianq/pipewire-soundpad 2026-02-14 15:09:25 +03:00
arabianq baae7a1ccf feat: you can now open dirs/files in system's file manager using context menus 2026-02-14 15:09:05 +03:00
arabianq 654694cecf feat: dirs and files now support context menu (right mouse button) 2026-02-14 14:58:47 +03:00
Tarasov Aleksandr 04ecf66beb Add custom funding link to FUNDING.yml 2026-02-08 21:55:40 +03:00
Tarasov Aleksandr 0fe94f9112 Update README.md
add deepwiki.com badge
2026-02-03 04:33:04 +03:00
arabianq 9fbe42c201 update version to 1.5.1 2026-01-28 23:34:12 +03:00
arabianq fac04c4533 docs: update AUR installation command to include binary option 2026-01-28 23:32:09 +03:00
arabianq f93852bf8e add submodules for aur/standart and aur/bin 2026-01-28 23:31:15 +03:00
arabianq 1bb0aa959a rename workflow from Flatpak CI to Git Flatpak 2026-01-28 22:56:47 +03:00
arabianq 7a1723fbcb fix: enable build-bundle option in Flatpak workflows and adjust source path 2026-01-28 22:50:15 +03:00
arabianq 712a0968a7 fix: remove unnecessary file sources from Flatpak manifest 2026-01-28 22:45:33 +03:00
arabianq e98e6bc2f3 fix: remove submodule checkout option from Flatpak workflows 2026-01-28 22:41:40 +03:00
arabianq 5007b483aa refactor 2026-01-28 22:38:10 +03:00
arabianq b936b58e75 add Flatpak CI and release workflows, update paths in manifest 2026-01-28 22:37:36 +03:00
arabianq 502ef2ed89 create wrapper for flatpak, flatpak yaml, desktop entry and metainfo 2026-01-28 22:33:34 +03:00
arabianq ce5910b9a6 fix: improve device lookup in get_device function and update daemon device name 2026-01-28 22:30:33 +03:00
arabianq b0c670235e fix: impossible to remove directories 2026-01-28 22:07:05 +03:00
arabianq f1d4ffd7fa fix: handle errors when opening a directory in SoundpadGui 2026-01-28 21:44:00 +03:00
arabianq 6f35ab7b8b update github workflows 2026-01-28 21:20:56 +03:00
arabianq 9a67f5479a add submodule for AUR package 2026-01-28 21:16:58 +03:00
arabianq b727eba988 move pwsp.spec for rpm into packages/rpm directory 2026-01-28 21:15:37 +03:00
arabianq 330c3d79d4 cargo update 2026-01-28 03:40:43 +03:00
arabianq dff20daace deps: bump clap to 4.5.55 2026-01-28 03:39:39 +03:00
arabianq cdc44328a8 docs: update README to include new features for collapsible audio tracks, drag and drop directories, and automatic device detection 2026-01-28 03:38:22 +03:00
arabianq ac61a71dcb feat: bump version to 1.5.0 2026-01-28 03:35:04 +03:00
arabianq 71c800c396 docs(assets): update screenshot image 2026-01-28 03:34:32 +03:00
arabianq 577a6d279b fix(daemon): remove unnecessary ExecStartPre sleep command 2026-01-28 03:32:29 +03:00
arabianq 49e01f0318 fix(gui): correct calculation of vertical separator's position 2026-01-28 03:31:54 +03:00
arabianq 5ea9b3b0ba feat(daemon): implementet get full-state command 2026-01-28 02:41:33 +03:00
arabianq ca85d4c369 refactor: remove redundant device linking in play method 2026-01-28 02:28:23 +03:00
arabianq 4499b1d3aa feat(gui): now directories can be reordered using drag and drop 2026-01-28 02:10:36 +03:00
arabianq d385e5356e refactor: simplify device retrieval in link_player_to_virtual_mic function 2026-01-28 01:30:03 +03:00
arabianq b4a0dc6a83 feat: now pwsp will automatically detect when input device is connected/disconnected and properly link/unlink it 2026-01-28 01:26:43 +03:00
arabianq 2e570b3bb0 fix: navigating through files using keyboard now works correctly with filtered files 2026-01-28 00:45:52 +03:00
arabianq ee4554286e refactor: improved filtering functionality 2026-01-28 00:45:20 +03:00
arabianq 2c6f0d932e refactor: refactor input handling for Enter key and directory navigation 2026-01-28 00:34:44 +03:00
arabianq 4e7606fdc6 feat: remove escape key functionality from input handling 2026-01-28 00:28:34 +03:00
arabianq 03df631690 refactor: enhance search field focus functionality and input handling 2026-01-28 00:28:08 +03:00
arabianq 6df826f210 feat: you can now collapse every audio track 2026-01-28 00:03:56 +03:00
arabianq cdf306cfe9 feat: make vertical separator in GUI adjustable 2026-01-27 23:51:14 +03:00
arabianq 74a436b171 fix: add serde default attribute to DaemonConfig and GuiConfig structs 2026-01-27 23:50:50 +03:00
58 changed files with 15372 additions and 2030 deletions
+1
View File
@@ -0,0 +1 @@
custom: ['https://boosty.to/arabian']
+6
View File
@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "weekly"
+121
View File
@@ -0,0 +1,121 @@
name: Build
permissions:
contents: write
packages: write
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
workflow_dispatch:
jobs:
linux-build:
runs-on: ubuntu-latest
steps:
- name: Install apt deps (jq/zip + dev-libs)
run: |
sudo apt-get update
sudo apt-get install -y \
zip jq \
libpipewire-0.3-dev \
libclang-dev \
libasound2-dev \
libdbus-1-dev \
pkg-config
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Rust toolchain
uses: actions-rs/toolchain@v1
with:
toolchain: 1.94.1
- name: Extract all binary names
id: cargo-meta
run: |
set -euo pipefail
BIN_NAMES=$(cargo metadata --no-deps --format-version 1 \
| jq -r '.packages[0].targets[] | select(.kind[] | contains("bin")) | .name')
echo "bin_names<<EOF" >> $GITHUB_OUTPUT
echo "$BIN_NAMES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Build all binaries
run: cargo build --locked
- name: Package all binaries into one archive
shell: bash
run: |
set -euo pipefail
COMMIT_SHA="${{ github.sha }}"
ARCHIVE_NAME="pwsp-${COMMIT_SHA}-linux-x64.zip"
echo "Creating archive: $ARCHIVE_NAME"
FILES=()
while IFS= read -r BIN; do
[ -z "$BIN" ] && continue
FILES+=("target/debug/$BIN")
done <<< "${{ steps.cargo-meta.outputs.bin_names }}"
if [ "${#FILES[@]}" -eq 0 ]; then
echo "Error: no binaries were discovered via cargo metadata." >&2
exit 1
fi
for f in "${FILES[@]}"; do
if [ ! -f "$f" ]; then
echo "Error: expected binary not found: $f" >&2
exit 1
fi
echo "Will add: $f"
done
zip -j "$ARCHIVE_NAME" "${FILES[@]}"
- name: Upload archive as artifact
uses: actions/upload-artifact@v4
with:
name: archive
path: pwsp-*.zip
retention-days: 7
- name: Install cargo-deb and create .deb
shell: bash
run: |
set -euo pipefail
cargo install --locked cargo-deb
export PATH="$HOME/.cargo/bin:$PATH"
cargo-deb
- name: Upload .deb(s) as artifacts
uses: actions/upload-artifact@v4
with:
name: deb-packages
path: target/debian/*.deb
retention-days: 7
flatpak-build:
runs-on: ubuntu-latest
container:
image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-25.08
options: --privileged
steps:
- uses: actions/checkout@v4
- name: Build Flatpak
uses: flatpak/flatpak-github-actions/flatpak-builder@v6
with:
bundle: ru.arabianq.pwsp.flatpak
manifest-path: packages/flatpak/ru.arabianq.pwsp.yaml
cache: true
branch: master
build-bundle: true
+88
View File
@@ -0,0 +1,88 @@
name: Flatter
on:
push:
branches: [ main, master ]
release:
types: [ published ]
workflow_dispatch:
inputs:
tag_name:
description: "TAG (empty to build from current branch)"
required: false
type: string
build_branch:
description: "Flatpak branch to build (stable/nightly)"
required: true
type: choice
options:
- stable
- nightly
default: "stable"
jobs:
flatter:
name: Flatter
runs-on: ubuntu-latest
permissions:
contents: read
container:
image: ghcr.io/andyholmes/flatter/freedesktop:25.08
options: --privileged
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ inputs.tag_name || github.ref }}
- name: Setup GPG
id: gpg
uses: crazy-max/ghaction-import-gpg@v6
with:
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.GPG_PASSPHRASE }}
- name: Set Default Branch
id: set_branch
run: |
if [ "${{ github.event_name }}" == "release" ]; then
echo "branch=stable" >> $GITHUB_OUTPUT
elif [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
echo "branch=${{ inputs.build_branch }}" >> $GITHUB_OUTPUT
else
echo "branch=nightly" >> $GITHUB_OUTPUT
fi
- name: Modify Manifest
run: |
echo "branch: ${{ steps.set_branch.outputs.branch }}" >> packages/flatpak/ru.arabianq.pwsp.yaml
echo "default-branch: ${{ steps.set_branch.outputs.branch }}" >> packages/flatpak/ru.arabianq.pwsp.yaml
- name: Install SDK Extensions
run: flatpak install -y flathub org.freedesktop.Sdk.Extension.rust-stable//25.08
org.freedesktop.Sdk.Extension.llvm20//25.08
- name: Build Flatpak
uses: andyholmes/flatter@main
with:
files: packages/flatpak/ru.arabianq.pwsp.yaml
gpg-sign: ${{ steps.gpg.outputs.fingerprint }}
upload-bundles: false
upload-pages-artifact: true
deploy:
name: Deploy to GitHub Pages
runs-on: ubuntu-latest
needs: flatter
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
-102
View File
@@ -1,102 +0,0 @@
name: Release deb
permissions:
contents: write
packages: write
on:
release:
types: [created]
workflow_dispatch:
inputs:
tag:
description: 'Tag to attach assets to (e.g. v1.0.0)'
required: false
jobs:
build-and-release:
runs-on: ubuntu-latest
steps:
- name: Install apt deps (jq/zip + dev-libs)
run: |
sudo apt-get update
sudo apt-get install -y \
zip jq \
libpipewire-0.3-dev \
libclang-dev \
libasound2-dev
- name: Determine tag to use
id: tag
run: |
set -euo pipefail
INPUT_TAG="${{ github.event.inputs.tag || '' }}"
if [ -n "$INPUT_TAG" ]; then
echo "Using input tag: $INPUT_TAG"
echo "tag=$INPUT_TAG" >> $GITHUB_OUTPUT
exit 0
fi
EVENT_TAG="${{ github.event.release.tag_name || '' }}"
if [ -n "$EVENT_TAG" ]; then
echo "Using event tag: $EVENT_TAG"
echo "tag=$EVENT_TAG" >> $GITHUB_OUTPUT
exit 0
fi
if [[ "${GITHUB_REF:-}" =~ ^refs/tags/(.+)$ ]]; then
echo "Using GITHUB_REF tag: ${BASH_REMATCH[1]}"
echo "tag=${BASH_REMATCH[1]}" >> $GITHUB_OUTPUT
exit 0
fi
echo "No tag in input/event/GITHUB_REF — querying latest release via API..."
LATEST_JSON=$(curl -sSf -H "Authorization: Bearer $GITHUB_TOKEN" -H "Accept: application/vnd.github+json" "https://api.github.com/repos/${{ github.repository }}/releases/latest" || true)
TAG_NAME=$(echo "$LATEST_JSON" | jq -r '.tag_name // empty')
if [ -n "$TAG_NAME" ]; then
echo "Found latest release tag: $TAG_NAME"
echo "tag=$TAG_NAME" >> $GITHUB_OUTPUT
exit 0
fi
echo "No tag found"
echo "tag=" >> $GITHUB_OUTPUT
- name: Fail if no tag determined
if: ${{ steps.tag.outputs.tag == '' }}
run: |
echo "ERROR: No tag determined. Provide a tag when running manually or ensure a release exists."
exit 1
- name: Checkout code at tag
uses: actions/checkout@v4
with:
ref: ${{ steps.tag.outputs.tag || github.ref }}
fetch-depth: 0
- name: Setup Rust toolchain
uses: actions-rs/toolchain@v1
with:
toolchain: stable
- name: Build all release binaries
run: cargo build --release --locked
- name: Install cargo-deb and create .deb
shell: bash
run: |
set -euo pipefail
cargo install --locked cargo-deb
export PATH="$HOME/.cargo/bin:$PATH"
cargo-deb
- name: Upload .deb(s) to release
uses: softprops/action-gh-release@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
tag_name: ${{ steps.tag.outputs.tag }}
files: |
target/debian/*.deb
@@ -1,4 +1,4 @@
name: Release archive name: Release
permissions: permissions:
contents: write contents: write
@@ -10,29 +10,20 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
tag: tag:
description: 'Tag to attach assets to (e.g. v1.0.0)' description: "Tag to attach assets to (e.g. v1.0.0)"
required: false required: false
jobs: jobs:
build-and-release: prepare:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs:
tag: ${{ steps.tag.outputs.tag }}
steps: steps:
- name: Install apt deps (jq/zip + dev-libs)
run: |
sudo apt-get update
sudo apt-get install -y \
zip jq \
libpipewire-0.3-dev \
libclang-dev \
libasound2-dev
- name: Determine tag to use - name: Determine tag to use
id: tag id: tag
run: | run: |
set -euo pipefail set -euo pipefail
# приоритет 1: входной параметр workflow_dispatch
INPUT_TAG="${{ github.event.inputs.tag || '' }}" INPUT_TAG="${{ github.event.inputs.tag || '' }}"
if [ -n "$INPUT_TAG" ]; then if [ -n "$INPUT_TAG" ]; then
echo "Using input tag: $INPUT_TAG" echo "Using input tag: $INPUT_TAG"
@@ -40,7 +31,6 @@ jobs:
exit 0 exit 0
fi fi
# приоритет 2: если запущено событием release
EVENT_TAG="${{ github.event.release.tag_name || '' }}" EVENT_TAG="${{ github.event.release.tag_name || '' }}"
if [ -n "$EVENT_TAG" ]; then if [ -n "$EVENT_TAG" ]; then
echo "Using event tag: $EVENT_TAG" echo "Using event tag: $EVENT_TAG"
@@ -48,16 +38,14 @@ jobs:
exit 0 exit 0
fi fi
# приоритет 3: если GITHUB_REF — refs/tags/...
if [[ "${GITHUB_REF:-}" =~ ^refs/tags/(.+)$ ]]; then if [[ "${GITHUB_REF:-}" =~ ^refs/tags/(.+)$ ]]; then
echo "Using GITHUB_REF tag: ${BASH_REMATCH[1]}" echo "Using GITHUB_REF tag: ${BASH_REMATCH[1]}"
echo "tag=${BASH_REMATCH[1]}" >> $GITHUB_OUTPUT echo "tag=${BASH_REMATCH[1]}" >> $GITHUB_OUTPUT
exit 0 exit 0
fi fi
# приоритет 4: пробуем получить последний релиз через API
echo "No tag in input/event/GITHUB_REF — querying latest release via API..." echo "No tag in input/event/GITHUB_REF — querying latest release via API..."
LATEST_JSON=$(curl -sSf -H "Authorization: Bearer $GITHUB_TOKEN" -H "Accept: application/vnd.github+json" "https://api.github.com/repos/${{ github.repository }}/releases/latest" || true) LATEST_JSON=$(curl -sSf -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" -H "Accept: application/vnd.github+json" "https://api.github.com/repos/${{ github.repository }}/releases/latest" || true)
TAG_NAME=$(echo "$LATEST_JSON" | jq -r '.tag_name // empty') TAG_NAME=$(echo "$LATEST_JSON" | jq -r '.tag_name // empty')
if [ -n "$TAG_NAME" ]; then if [ -n "$TAG_NAME" ]; then
echo "Found latest release tag: $TAG_NAME" echo "Found latest release tag: $TAG_NAME"
@@ -74,16 +62,32 @@ jobs:
echo "ERROR: No tag determined. Provide a tag when running manually or ensure a release exists." echo "ERROR: No tag determined. Provide a tag when running manually or ensure a release exists."
exit 1 exit 1
linux-release:
needs: prepare
runs-on: ubuntu-latest
steps:
- name: Install apt deps (jq/zip + dev-libs)
run: |
sudo apt-get update
sudo apt-get install -y \
zip jq \
libpipewire-0.3-dev \
libclang-dev \
libasound2-dev \
libdbus-1-dev \
pkg-config
- name: Checkout code at tag - name: Checkout code at tag
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
ref: ${{ steps.tag.outputs.tag || github.ref }} ref: ${{ needs.prepare.outputs.tag }}
fetch-depth: 0 fetch-depth: 0
- name: Setup Rust toolchain - name: Setup Rust toolchain
uses: actions-rs/toolchain@v1 uses: actions-rs/toolchain@v1
with: with:
toolchain: stable toolchain: 1.94.1
- name: Extract all binary names - name: Extract all binary names
id: cargo-meta id: cargo-meta
@@ -91,7 +95,6 @@ jobs:
set -euo pipefail set -euo pipefail
BIN_NAMES=$(cargo metadata --no-deps --format-version 1 \ BIN_NAMES=$(cargo metadata --no-deps --format-version 1 \
| jq -r '.packages[0].targets[] | select(.kind[] | contains("bin")) | .name') | jq -r '.packages[0].targets[] | select(.kind[] | contains("bin")) | .name')
# сохраним построчно в выход
echo "bin_names<<EOF" >> $GITHUB_OUTPUT echo "bin_names<<EOF" >> $GITHUB_OUTPUT
echo "$BIN_NAMES" >> $GITHUB_OUTPUT echo "$BIN_NAMES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT
@@ -103,11 +106,10 @@ jobs:
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
TAG="${{ steps.tag.outputs.tag }}" TAG="${{ needs.prepare.outputs.tag }}"
ARCHIVE_NAME="pwsp-${TAG}-linux-x64.zip" ARCHIVE_NAME="pwsp-${TAG}-linux-x64.zip"
echo "Creating archive: $ARCHIVE_NAME" echo "Creating archive: $ARCHIVE_NAME"
# читаем построчно список бинарников и формируем массив файлов
FILES=() FILES=()
while IFS= read -r BIN; do while IFS= read -r BIN; do
[ -z "$BIN" ] && continue [ -z "$BIN" ] && continue
@@ -119,7 +121,6 @@ jobs:
exit 1 exit 1
fi fi
# проверим, что все бинарники действительно есть
for f in "${FILES[@]}"; do for f in "${FILES[@]}"; do
if [ ! -f "$f" ]; then if [ ! -f "$f" ]; then
echo "Error: expected binary not found: $f" >&2 echo "Error: expected binary not found: $f" >&2
@@ -128,13 +129,50 @@ jobs:
echo "Will add: $f" echo "Will add: $f"
done done
# создаём архив с бинарниками внутри как просто pwsp-gui, pwsp-daemon, pwsp-cli
zip -j "$ARCHIVE_NAME" "${FILES[@]}" zip -j "$ARCHIVE_NAME" "${FILES[@]}"
- name: Upload release archive - name: Upload release archive
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
tag_name: ${{ steps.tag.outputs.tag }} tag_name: ${{ needs.prepare.outputs.tag }}
files: | files: |
pwsp-*.zip pwsp-*.zip
- name: Install cargo-deb and create .deb
shell: bash
run: |
set -euo pipefail
cargo install --locked cargo-deb
export PATH="$HOME/.cargo/bin:$PATH"
cargo-deb
- name: Upload .deb(s) to release
uses: softprops/action-gh-release@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
tag_name: ${{ needs.prepare.outputs.tag }}
files: |
target/debian/*.deb
flatpak-release:
needs: prepare
runs-on: ubuntu-latest
container:
image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-25.08
options: --privileged
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.prepare.outputs.tag }}
- name: Build Flatpak
uses: flatpak/flatpak-github-actions/flatpak-builder@v6
with:
bundle: ru.arabianq.pwsp.flatpak
manifest-path: packages/flatpak/ru.arabianq.pwsp.yaml
cache: true
branch: master
build-bundle: true
+2
View File
@@ -1,2 +1,4 @@
/target /target
.idea .idea
packages/aur/bin/.git
packages/aur/standart/.git
View File
Generated
+2739 -590
View File
File diff suppressed because it is too large Load Diff
+77 -15
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "pwsp" name = "pwsp"
version = "1.4.0" version = "1.10.0"
edition = "2024" edition = "2024"
authors = ["arabian"] authors = ["arabian"]
description = "PWSP lets you play audio files through your microphone. Has both CLI and GUI clients." description = "PWSP lets you play audio files through your microphone. Has both CLI and GUI clients."
@@ -12,22 +12,60 @@ keywords = ["soundpad", "pipewire", "linux", "cli", "gui"]
[dependencies] [dependencies]
tokio = { version = "1.49.0", features = ["full"] } tokio = { version = "1.52.3", features = ["full"] }
async-trait = "0.1.89" async-trait = "0.1.89"
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149" serde_json = "1.0.149"
clap = { version = "4.5.54", default-features = false, features = ["std", "suggestions", "help", "usage", "error-context", "derive"] } clap = { version = "4.6.1", default-features = false, features = [
"std",
"suggestions",
"help",
"usage",
"error-context",
"derive",
] }
dirs = "6.0.0" dirs = "6.0.0"
itertools = "0.14.0"
evdev = { version = "0.13.2", features = ["tokio"] }
rfd = { version = "0.17.2", default-features = false, features = [
rodio = { version = "0.21.1", default-features = false, features = ["symphonia-all", "playback"] } "xdg-portal",
pipewire = "0.9.2"
rfd = { version = "0.17.2", default-features = false, features = ["xdg-portal"]}
egui = { version = "0.33.3", default-features = false, features = ["default_fonts", "rayon"] } ] }
eframe = { version = "0.33.3", default-features = false, features = ["default_fonts", "glow", "x11", "wayland"] } opener = { version = "0.8.4", features = ["reveal"] }
egui_material_icons = "0.5.0" system-fonts = "0.1.1"
anyhow = "1.0.102"
rustix = { version = "1.1.4", features = ["process"] }
rust-i18n = "4.0.0"
sys-locale = "0.3.2"
rodio = { git = "https://github.com/arabianq/rodio.git", rev = "a634dd471e9d59196e19bf01323fb45f2f899821", default-features = false, features = [
"symphonia-all",
"symphonia-libopus",
"playback",
] }
pipewire = "0.10.0"
egui = { version = "0.34.2", default-features = false, features = [
"default_fonts",
"rayon",
] }
eframe = { version = "0.34.2", default-features = false, features = [
"default_fonts",
"glow",
"x11",
"wayland",
] }
egui_extras = "0.34.1"
egui_material_icons = "0.6.0"
egui_dnd = "0.15.0"
reqwest = "0.13.4"
percent-encoding = "2.3.2"
[[bin]] [[bin]]
name = "pwsp-daemon" name = "pwsp-daemon"
@@ -50,10 +88,34 @@ panic = "abort"
[package.metadata.deb] [package.metadata.deb]
assets = [ assets = [
["target/release/pwsp-daemon", "usr/bin/", "755"], [
["target/release/pwsp-cli", "usr/bin/", "755"], "target/release/pwsp-daemon",
["target/release/pwsp-gui", "usr/bin/", "755"], "usr/bin/",
["assets/pwsp-gui.desktop", "usr/share/applications/pwsp.desktop", "644"], "755",
["assets/icon.png", "usr/share/icons/hicolor/256x256/apps/pwsp.png", "644"], ],
["assets/pwsp-daemon.service", "usr/lib/systemd/user/pwsp-daemon.service", "644"], [
"target/release/pwsp-cli",
"usr/bin/",
"755",
],
[
"target/release/pwsp-gui",
"usr/bin/",
"755",
],
[
"assets/pwsp-gui.desktop",
"usr/share/applications/pwsp.desktop",
"644",
],
[
"assets/icon.png",
"usr/share/icons/hicolor/256x256/apps/pwsp.png",
"644",
],
[
"assets/pwsp-daemon.service",
"usr/lib/systemd/user/pwsp-daemon.service",
"644",
],
] ]
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2025 Tarasov Alexander Copyright (c) 2026 arabianq
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+76 -166
View File
@@ -1,208 +1,118 @@
# **🎵 Pipewire Soundpad (PWSP)** <div align="center">
<h1>🎵 PipeWire Soundpad (PWSP)</h1>
<p><b>A simple, modern, and powerful soundboard for Linux, written in Rust.</b></p>
<img src="assets/screenshot.png" alt="PWSP Screenshot" width="700"/>
</div>
**PipeWire Soundpad (PWSP)** is a simple yet powerful **soundboard application** written in **Rust**. It provides a ## 🌟 Overview
user-friendly graphical interface for **managing and playing audio files, directing their output directly to the virtual **PipeWire Soundpad (PWSP)** is a graphical soundboard application that routes audio directly to your virtual microphone using **PipeWire**. It provides an intuitive interface for managing your audio collection, making it an ideal tool for gamers, streamers, and anyone looking to inject sound effects into voice chats on platforms like Discord, Zoom, or TeamSpeak.
microphone.** This makes it an ideal tool for gamers, streamers, and anyone looking to inject sound effects into voice
chats on platforms like **Discord, Zoom, or Teamspeak**.
![screenshot.png](assets/screenshot.png) ## ✨ Key Features
* **🎙️ Virtual Microphone Output:** Seamlessly mixes your microphone input with sound effects by automatically managing PipeWire virtual devices.
* **🎵 Multi-Format Support:** Plays popular audio formats including `mp3`, `wav`, `ogg`, `flac`, `mp4`, and `aac`.
* **⚡ Global Hotkeys:** Trigger sounds instantly from anywhere, even when the app is running in the background.
* **📂 Smart Collection Management:** Drag-and-drop folders, quick search, and collapsible tracks to keep your library organized.
* **🎛️ Advanced Playback Controls:** Individual volume sliders, play/pause, position scrubbing, and concurrent multi-track playback.
* **🔌 Plug & Play:** Automatically detects when an input device is connected or disconnected and handles linking/unlinking on the fly.
* **🖥️ Modern GUI:** Clean, responsive, and lightweight interface powered by [egui](https://egui.rs/).
# **🌟 Key Features** ## ⚙️ Architecture
PWSP is built with a client-server model to ensure stability and separation of concerns:
* **`pwsp-daemon`**: The background engine. It runs silently, managing PipeWire virtual devices, audio routing, and playback.
* **`pwsp-gui`**: The graphical interface. Communicates with the daemon via a Unix socket to control playback and settings.
* **`pwsp-cli`**: The command-line tool. Perfect for scripting, hotkey binding, or quick terminal-based control.
* **Multi-Format Support**: Play audio files in popular formats, including _**mp3**_, _**wav**_, _**ogg**_, _**flac**_, ---
_**mp4**_, and _**aac**_.
* **Virtual Microphone Output**: The application routes audio through a virtual device created by PipeWire, allowing
other users to hear the sounds as if you were speaking into your microphone.
* **Modern and Clean GUI**: The interface is built with the [egui](https://egui.rs) library, ensuring an intuitive and
responsive user experience.
* **Sound Collection Management**: Easily add and remove directories containing your audio files. The application scans
these folders and displays all supported files for quick access.
* **Quick Search**: Use the built-in search bar to instantly find any sound file within your library.
* **Detailed Playback Controls**:
* **Play/Pause button**.
* **Volume slider** for individual sound adjustment.
* **Position slider** to fast-forward or rewind the audio.
* **Persistent Configuration**: The list of added directories and your selected audio output device are saved
automatically, so you won't need to reconfigure them every time you launch the application.
# **⚙️ How It Works** ## 🚀 Installation
PWSP is designed with a clear separation of concerns, operating through a client-server architecture. It consists of ### 📦 Flatpak (Recommended)
three main components: Install PWSP via Flatpak from our custom repository:
```bash
flatpak remote-add --user --if-not-exists pwsp-repo https://arabianq.github.io/pipewire-soundpad/index.flatpakrepo
* **pwsp-daemon**: This is the core of the application. It runs silently in the background, managing all the # Install stable version
heavy-lifting tasks. The daemon is responsible for: flatpak install --user arabianq-repo ru.arabianq.pwsp//stable
* Creating and managing virtual audio devices.
* Linking these devices within the PipeWire graph.
* Handling all audio playback.
* **pwsp-gui**: This is the graphical user interface. It acts as a client that communicates with pwsp-daemon via a
**UnixSocket**. This is how you interact with your sound collection, control playback, and configure settings.
* **pwsp-cli**: This is the command-line interface, also acting as a client. It provides a way to control the daemon
without a GUI, allowing for scripting or quick command-based actions.
# **🚀 Installation** # Or install the nightly version (latest commit)
flatpak install --user arabianq-repo ru.arabianq.pwsp//nightly
## **Pre-built Packages** ```
You can download pre-built binaries and .deb packages from
the [releases page](https://github.com/arabianq/pipewire-soundpad/releases).
## **Fedora Linux (and derivatives)**
If you're using Fedora, you can install PWSP from a dedicated repository using DNF.
Add the repository:
### 🐧 Linux Packages
**Fedora (and derivatives):**
```bash ```bash
sudo dnf copr enable arabianq/pwsp sudo dnf copr enable arabianq/pwsp
```
Update cache:
```bash
sudo dnf makecache
```
Install PWSP:
```bash
sudo dnf install pwsp sudo dnf install pwsp
``` ```
## **Arch Linux** **Arch Linux (AUR):**
There is pwsp package in AUR.
You can install it using yay, paru or any other AUR helper.
```bash ```bash
paru pwsp paru -S pwsp-bin # or 'pwsp' to build from source
``` ```
## **Installing using cargo** **Debian / Ubuntu:**
Download pre-built `.deb` packages or standalone binaries from the [Releases page](https://github.com/arabianq/pipewire-soundpad/releases).
### 🦀 Cargo / Source Build
```bash ```bash
cargo install pwsp cargo install pwsp
```
## **Building from source** # OR clone and build manually:
#### **Requirements**
* **Rust**: Install [Rust](https://www.rust-lang.org/tools/install) (using rustup is recommended).
* **PipeWire**: Ensure that [PipeWire](https://pipewire.org/) is installed and running on your system.
#### **Build Instructions**
Clone the repository:
```bash
git clone https://github.com/arabianq/pipewire-soundpad.git git clone https://github.com/arabianq/pipewire-soundpad.git
cd pipewire-soundpad cd pipewire-soundpad
```
Build the project:
```bash
cargo build --release cargo build --release
``` ```
*(Note: Requires Rust toolchain and PipeWire running on your system).*
Now you have three binary files inside ./target/release/: ---
- **pwsp-gui** ## 🎮 Usage
- **pwsp-cli**
- **pwsp-daemon**
# **🎮 Usage** ### 1. Start the Daemon
Before using the GUI or CLI, the daemon must be running in the background.
Before using pwsp-gui or pwsp-cli, you **must** first run the pwsp-daemon in the background.
### **Running the Daemon**
You can start the daemon from the terminal or enable the systemd service for automatic startup.
* **Manual Start:**
```bash ```bash
/path/to/your/pwsp-daemon & # Recommended: Start and enable via systemd (starts on login)
systemctl --user enable --now pwsp-daemon
# Manual start (if not using systemd):
pwsp-daemon &
``` ```
* **Using systemd (recommended):** ### 2. Using the GUI
If you installed PWSP using prebuilt packages, the systemd service is added automatically. 1. **Add Sounds:** Click the **"+"** button to add a directory containing your audio files.
1. **Start the service:** 2. **Select Mic:** Choose your physical microphone from the dropdown. PWSP will instantly create a virtual microphone combining your voice and the soundboard.
```bash 3. **Play:** Click any sound to play it, adjust its volume, or assign a hotkey for quick access.
systemctl --user start pwsp-daemon
```
2. **Enable autostart (starts on login):**
```bash
systemctl --user enable --now pwsp-daemon
```
### **Using the GUI**
1. **Add Sounds**: Click the **"+"** button and select a folder containing your audio files. The application
will automatically list all supported files.
2. **Select Microphone**: In the main application window, select your microphone. PWSP will automatically
create a virtual microphone and feed it sound from two sources: **your microphone** and the **audio files**.
3. **Playback**: Click on a file in the list to load it, then use the **"Play"** and **"Pause"** buttons to control
playback. You can also play single file once using **"Play File"** button.
### **Using the CLI**
The pwsp-cli tool allows you to control the daemon from the command line.
* **General Help**: To see a list of all available commands, run:
### 3. Using the CLI
Control the daemon directly from your terminal:
```bash ```bash
pwsp-cli --help pwsp-cli action play /path/to/sound.mp3
pwsp-cli get volume
pwsp-cli set position 20
pwsp-cli --help # View all commands
``` ```
* **Example Commands**: ---
* **Play a file**:
```bash ## ⌨️ Shortcuts & Controls
pwsp-cli action play <file_path>
```
* **Get the current volume**: | Action | Keyboard | Mouse |
| :----------------------------------- | :--------------------- | :------------------- |
| **Play Track** (Stops others) | | `Left Click` |
| **Add Track** (Plays simultaneously) | | `Ctrl + Left Click` |
| **Replace Last Track** | | `Shift + Left Click` |
| **Pause / Resume** | `Space` | |
| **Stop All Tracks** | `Backspace` | |
| **Open / Close Settings** | `I` | |
| **Search** | `/` | |
```bash ---
pwsp-cli get volume
```
* **Set playback position to 20 seconds**: ## 🤝 Contributing
Contributions, issues, and feature requests are welcome! Feel free to check out the [issues page](https://github.com/arabianq/pipewire-soundpad/issues).
```bash [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/arabianq/pipewire-soundpad)
pwsp-cli set position 20
```
### **Hotkeys & Controls** ## 📜 License
This project is licensed under the [MIT License](LICENSE).
#### **Keyboard Shortcuts**
| Key | Action |
| :----------------------- | :--------------------------------------------------- |
| **Esc** | Close application |
| **Space** | Pause / Resume audio |
| **Backspace** | Stop all audio tracks |
| **Enter** | Play selected file (stops all other tracks) |
| **Ctrl + Enter** | Add selected file to playback (plays simultaneously) |
| **Shift + Enter** | Replace the last added track with the selected one |
| **I** | Open / Close settings |
| **/** | Focus search field |
| **Ctrl + ↑ / ↓** | Navigate through files |
| **Ctrl + Shift + ↑ / ↓** | Navigate through directories |
#### **Mouse Controls**
* **Left Click**: Play track (stops all other tracks).
* **Ctrl + Left Click**: Add track (plays simultaneously with current tracks).
* **Shift + Left Click**: Replace the last added track with the selected one.
# **🤝 Contributing**
Contributions are welcome\! If you have ideas for improvements or find a bug, feel free to create
an [issue](https://github.com/arabianq/pipewire-soundpad/issues) or submit
a [pull request](https://github.com/arabianq/pipewire-soundpad/pulls).
# **📜 License**
This project is licensed under
the [MIT License](https://github.com/arabianq/pipewire-soundpad/blob/main/LICENSE).
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 79 KiB

-1
View File
@@ -3,7 +3,6 @@ Description=Pipewire Soundpad Daemon
After=pipewire.service After=pipewire.service
[Service] [Service]
ExecStartPre=/usr/bin/sleep 10
ExecStart=/usr/bin/pwsp-daemon ExecStart=/usr/bin/pwsp-daemon
Restart=no Restart=no
RuntimeDirectory=pwsp RuntimeDirectory=pwsp
+2 -1
View File
@@ -1,8 +1,9 @@
[Desktop Entry] [Desktop Entry]
Name=PWSP (Soundpad) Name=PWSP (Soundpad)
Comment=Let's you play audio files through you microphone Comment=Let's you play audio files through you microphone
Exec=pwsp-gui %u Exec=/usr/bin/pwsp-gui %u
Icon=pwsp Icon=pwsp
Terminal=false Terminal=false
Type=Application Type=Application
Categories=Audio Categories=Audio
MimeType=x-scheme-handler/soundpad;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 94 KiB

+420
View File
@@ -0,0 +1,420 @@
_version = 2
# ----------------
# Main page
# ----------------
[gui.play_file_button]
en = "Play file"
ru = "Выбрать файл"
es = "Reproducir archivo"
fr = "Lire le fichier"
zh = "播放文件"
ar = "تشغيل الملف"
kz = "Файлды ойнату"
he = "נגן קובץ"
pt-BR = "Reproduzir arquivo"
[gui.choose_mic_select]
en = "Select microphone"
ru = "Выбрать микрофон"
es = "Seleccionar micrófono"
fr = "Sélectionner le microphone"
zh = "选择麦克风"
ar = "اختر الميكروفون"
kz = "Микрофонды таңдау"
he = "בחר מיקרופון"
pt-BR = "Selecionar microfone"
[gui.search_placeholder]
en = "Search files..."
ru = "Поиск файлов..."
es = "Buscar archivos..."
fr = "Rechercher des fichiers..."
zh = "搜索文件..."
ar = "البحث عن ملفات..."
kz = "Файлдарды іздеу..."
he = "חפש קבצים..."
pt-BR = "Buscar arquivos..."
[gui.context.dirs.open]
en = "Open"
ru = "Открыть"
es = "Abrir"
fr = "Ouvrir"
zh = "打开"
ar = "فتح"
kz = "Ашу"
he = "פתח"
pt-BR = "Abrir"
[gui.context.dirs.open_in_fm]
en = "Open in File Manager"
ru = "Открыть в менеджере файлов"
es = "Abrir en el gestor de archivos"
fr = "Ouvrir dans le gestionnaire de fichiers"
zh = "在文件管理器中打开"
ar = "فتح في مدير الملفات"
kz = "Файл менеджерінде ашу"
he = "פתח במנהל הקבצים"
pt-BR = "Abrir no gestor de arquivos"
[gui.context.dirs.remove]
en = "Remove"
ru = "Удалить"
es = "Eliminar"
fr = "Supprimer"
zh = "移除"
ar = "إزالة"
kz = "Жою"
he = "הסר"
pt-BR = "Remover"
[gui.context.files.play_solo]
en = "Play Solo"
ru = "Играть"
es = "Reproducir solo"
fr = "Jouer en solo"
zh = "单独播放"
ar = "تشغيل منفرد"
kz = "Жалғыз ойнату"
he = "נגן סולו"
pt-BR = "Reproduzir"
[gui.context.files.add_new]
en = "Add New"
ru = "Добавить"
es = "Añadir nuevo"
fr = "Ajouter un nouveau"
zh = "添加新项"
ar = "إضافة جديد"
kz = "Жаңасын қосу"
he = "הוסף חדש"
pt-BR = "Adicionar"
[gui.context.files.replace_last]
en = "Replace Last"
ru = "Заменить Последний"
es = "Reemplazar último"
fr = "Remplacer le dernier"
zh = "替换上一个"
ar = "استبدال الأخير"
kz = "Соңғысын ауыстыру"
he = "החלף אחרון"
pt-BR = "Substituir"
[gui.context.files.show_in_fm]
en = "Show in File Manager"
ru = "Открыть в менеджере файлов"
es = "Mostrar en el gestor de archivos"
fr = "Afficher dans le gestionnaire de fichiers"
zh = "在文件管理器中显示"
ar = "عرض في مدير الملفات"
kz = "Файл менеджерінде көрсету"
he = "הצג במנהל הקבצים"
pt-BR = "Mostrar no gestor de arquivos"
[gui.context.files.asign_hotkey]
en = "Assign Hotkey"
ru = "Назначить Горячую Клавишу"
es = "Asignar atajo"
fr = "Assigner un raccourci"
zh = "分配快捷键"
ar = "تعيين مفتاح اختصار"
kz = "Ыстық пернені тағайындау"
he = "הקצה מקש קיצור"
pt-BR = "Definir tecla de atalho"
# ----------------
# Settings
# ----------------
[gui.settings.header]
en = "Settings"
ru = "Настройки"
es = "Ajustes"
fr = "Paramètres"
zh = "设置"
ar = "الإعدادات"
kz = "Баптаулар"
he = "הגדרות"
pt-BR = "Configurações"
[gui.settings.remember_volume]
en = "Always remember volume"
ru = "Всегда запоминать громкость"
es = "Recordar siempre el volumen"
fr = "Toujours se souvenir du volume"
zh = "始终记住音量"
ar = "تذكر مستوى الصوت دائمًا"
kz = "Әрқашан дыбыс деңгейін есте сақтау"
he = "זכור תמיד עוצמת קול"
pt-BR = "Lembrar volume"
[gui.settings.remember_mic]
en = "Always remember microphone"
ru = "Всегда запоминать микрофон"
es = "Recordar siempre el micrófono"
fr = "Toujours se souvenir du microphone"
zh = "始终记住麦克风"
ar = "تذكر الميكروفون دائمًا"
kz = "Әрқашан микрофонды есте сақтау"
he = "זכור תמיד מיקרופון"
pt-BR = "Lembrar microfone"
[gui.settings.remember_ui_scale]
en = "Always remember UI scale factor"
ru = "Всегда запоминать масштаб интерфейса"
es = "Recordar siempre la escala de la interfaz"
fr = "Toujours se souvenir de l'échelle de l'interface"
zh = "始终记住界面缩放比例"
ar = "تذكر عامل تكبير الواجهة دائمًا"
kz = "Әрқашан интерфейс масштабын есте сақтау"
he = "זכור תמיד קנה מידה של ממשק משתמש"
pt-BR = "Lembrar fator de escala da interface"
[gui.settings.pause_on_window_close]
en = "Pause audio playback when the window is closed"
ru = "Останавливать воспроизведение при закрытии окна"
es = "Pausar la reproducción de audio al cerrar la ventana"
fr = "Mettre en pause la lecture audio à la fermeture de la fenêtre"
zh = "关闭窗口时暂停音频播放"
ar = "إيقاف الصوت مؤقتًا عند إغلاق النافذة"
kz = "Терезе жабылған кезде дыбысты ойнатуды кідірту"
he = "השהה השמעת שמע כאשר החלון נסגר"
pt-BR = "Pausar reprodução de aúdio ao fechar a janela"
[gui.settings.version]
en = "GUI version: %{version}"
ru = "Версия GUI: %{version}"
es = "Versión de la GUI: %{version}"
fr = "Version de l'interface : %{version}"
zh = "GUI 版本: %{version}"
ar = "إصدار الواجهة: %{version}"
kz = "GUI нұсқасы: %{version}"
he = "גרסת ממשק משתמש: %{version}"
pt-BR = "Versão da GUI: %{version}"
[gui.settings.theme.label]
en = "Color Scheme"
ru = "Цветовая схема"
es = "Esquema de color"
fr = "Schéma de couleurs"
zh = "配色方案"
ar = "نظام الألوان"
kz = "Түс схемасы"
he = "ערכת צבעים"
pt-BR = "Esquema de cores"
[gui.settings.theme.system]
en = "System"
ru = "Системная"
es = "Sistema"
fr = "Système"
zh = "系统"
ar = "النظام"
kz = "Жүйе"
he = "מערכת"
pt-BR = "Sistema"
[gui.settings.theme.light]
en = "Light"
ru = "Светлая"
es = "Claro"
fr = "Clair"
zh = "浅色"
ar = "فاتح"
kz = "Жарық"
he = "בהיר"
pt-BR = "Claro"
[gui.settings.theme.dark]
en = "Dark"
ru = "Тёмная"
es = "Oscuro"
fr = "Sombre"
zh = "暗色"
ar = "داكن"
kz = "Қараңғы"
he = "כהה"
pt-BR = "Escuro"
# ----------------
# Hotkeys
# ----------------
[gui.hotkeys.header]
en = "Hotkeys"
ru = "Горячие клавиши"
es = "Atajos de teclado"
fr = "Raccourcis clavier"
zh = "快捷键"
ar = "اختصارات لوحة المفاتيح"
kz = "Ыстық пернелер"
he = "מקשי קיצור"
pt-BR = "Atalhos"
[gui.hotkeys.search_placeholder]
en = "Search hotkeys..."
ru = "Поиск горячих клавиш..."
es = "Buscar atajos..."
fr = "Rechercher des raccourcis..."
zh = "搜索快捷键..."
ar = "البحث عن الاختصارات..."
kz = "Ыстық пернелерді іздеу..."
he = "חפש מקשי קיצור..."
pt-BR = "Buscar atalhos..."
[gui.hotkeys.add_command_select]
en = "Add Command"
ru = "Добавить команду"
es = "Añadir comando"
fr = "Ajouter une commande"
zh = "添加命令"
ar = "إضافة أمر"
kz = "Команда қосу"
he = "הוסף פקודה"
pt-BR = "Adicionar comando"
[gui.hotkeys.toggle_pause_command]
en = "Toggle Pause"
ru = "Переключить паузу"
es = "Alternar pausa"
fr = "Basculer la pause"
zh = "切换暂停"
ar = "تبديل الإيقاف المؤقت"
kz = "Кідіртуді ауыстыру"
he = "הפעל/השהה"
pt-BR = "Alternar reprodução"
[gui.hotkeys.stop_playback_command]
en = "Stop Playback"
ru = "Остановить воспроизведение"
es = "Detener reproducción"
fr = "Arrêter la lecture"
zh = "停止播放"
ar = "إيقاف التشغيل"
kz = "Ойнатуды тоқтату"
he = "עצור השמעה"
pt-BR = "Parar reprodução"
[gui.hotkeys.pause_playback_command]
en = "Pause Playback"
ru = "Поставить воспроизведение на паузу"
es = "Pausar reproducción"
fr = "Mettre en pause la lecture"
zh = "暂停播放"
ar = "إيقاف التشغيل مؤقتاً"
kz = "Ойнатуды кідірту"
he = "השהה השמעה"
pt-BR = "Pausar reprodução"
[gui.hotkeys.resume_playback_command]
en = "Resume Playback"
ru = "Продолжить воспроизведение"
es = "Reanudar reproducción"
fr = "Reprendre la lecture"
zh = "恢复播放"
ar = "استئناف التشغيل"
kz = "Ойнатуды жалғастыру"
he = "המשך השמעה"
pt-BR = "Resumir reprodução"
[gui.hotkeys.toggle_loop_command]
en = "Toggle Loop"
ru = "Переключить зацикливание"
es = "Alternar bucle"
fr = "Basculer la boucle"
zh = "切换循环"
ar = "تبديل التكرار"
kz = "Қайталауды ауыстыру"
he = "הפעל/כבה לולאה"
pt-BR = "Alternar loop"
[gui.hotkeys.column_slot]
en = "Slot"
ru = "Слот"
es = "Ranura"
fr = "Emplacement"
zh = "插槽"
ar = "الخانة"
kz = "Ұяшық"
he = "משבצת"
pt-BR = "Slot"
[gui.hotkeys.column_sound]
en = "Sound"
ru = "Звук"
es = "Sonido"
fr = "Son"
zh = "声音"
ar = "الصوت"
kz = "Дыбыс"
he = "צליל"
pt-BR = "Som"
[gui.hotkeys.column_key_chord]
en = "Key Chord"
ru = "Клавиша"
es = "Combinación de teclas"
fr = "Combinaison de touches"
zh = "组合键"
ar = "تركيبة المفاتيح"
kz = "Пернелер тіркесімі"
he = "צירוף מקשים"
pt-BR = "Combinação de teclas"
[gui.hotkeys.column_actions]
en = "Actions"
ru = "Действия"
es = "Acciones"
fr = "Actions"
zh = "操作"
ar = "الإجراءات"
kz = "Әрекеттер"
he = "פעולות"
pt-BR = "Ações"
[gui.hotkeys.no_hotkeys_configured]
en = "No hotkeys configured"
ru = "Горячие клавиши не настроены"
es = "No hay atajos configurados"
fr = "Aucun raccourci configuré"
zh = "未配置快捷键"
ar = "لا توجد اختصارات معينة"
kz = "Ыстық пернелер бапталмаған"
he = "לא הוגדרו מקשי קיצור"
pt-BR = "Nenhum atalho configurado"
[gui.hotkeys.capture.header]
en = "Press a key combination (e.g. Ctrl+Alt+1)"
ru = "Нажмите сочетание клавиш (например, Ctrl+Alt+1)"
es = "Presione una combinación de teclas (ej. Ctrl+Alt+1)"
fr = "Appuyez sur une combinaison de touches (ex. Ctrl+Alt+1)"
zh = "按下一个组合键 (例如 Ctrl+Alt+1)"
ar = "اضغط على تركيبة مفاتيح (مثلاً Ctrl+Alt+1)"
kz = "Пернелер тіркесімін басыңыз (мысалы, Ctrl+Alt+1)"
he = "לחץ על צירוף מקשים (למשל Ctrl+Alt+1)"
pt-BR = "Pressione uma combinação de tecla (ex: Ctrl+Alt+1)"
[gui.hotkeys.capture.for]
en = "for"
ru = "для"
es = "para"
fr = "pour"
zh = "用于"
ar = "لـ"
kz = "үшін"
he = "עבור"
pt-BR = "para"
[gui.hotkeys.capture.cancel]
en = "Press Escape to cancel"
ru = "Нажмите Escape для отмены"
es = "Presione Escape para cancelar"
fr = "Appuyez sur Échap pour annuler"
zh = "按 Escape 取消"
ar = "اضغط Esc للإلغاء"
kz = "Болдырмау үшін Escape пернесін басыңыз"
he = "לחץ על Escape לביטול"
pt-BR = "Pressione Esc para cancelar"
+17
View File
@@ -0,0 +1,17 @@
pkgbase = pwsp-bin
pkgdesc = Lets you play audio files through your microphone (Pre-built binaries)
pkgver = 1.10.0
pkgrel = 1
url = https://github.com/arabianq/pipewire-soundpad
arch = x86_64
license = MIT
depends = pipewire
depends = alsa-lib
provides = pwsp
conflicts = pwsp
source = pwsp-bin-1.10.0.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.10.0/pwsp-v1.10.0-linux-x64.zip
source = pipewire-soundpad-1.10.0.tar.gz :: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.10.0.tar.gz
sha256sums = SKIP
sha256sums = SKIP
pkgname = pwsp-bin
+163
View File
@@ -0,0 +1,163 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
*.db
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
poetry.lock
.poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cythikaaryhon_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
#.vscode/
+32
View File
@@ -0,0 +1,32 @@
# Maintainer: Alexander Tarasov <a.tevg@ya.ru>
pkgname=pwsp-bin
_pkgname=pipewire-soundpad
pkgver=1.10.0
pkgrel=1
pkgdesc="Lets you play audio files through your microphone (Pre-built binaries)"
arch=('x86_64')
url="https://github.com/arabianq/pipewire-soundpad"
license=('MIT')
depends=('pipewire' 'alsa-lib')
provides=('pwsp')
conflicts=('pwsp')
source=("${pkgname}-${pkgver}.zip::https://github.com/arabianq/$_pkgname/releases/download/v$pkgver/pwsp-v$pkgver-linux-x64.zip"
"${_pkgname}-${pkgver}.tar.gz::https://github.com/arabianq/$_pkgname/archive/refs/tags/v$pkgver.tar.gz")
sha256sums=('SKIP'
'SKIP')
package() {
_srcsrc="${srcdir}/${_pkgname}-${pkgver}"
install -Dm755 "${srcdir}/pwsp-cli" "${pkgdir}/usr/bin/pwsp-cli"
install -Dm755 "${srcdir}/pwsp-daemon" "${pkgdir}/usr/bin/pwsp-daemon"
install -Dm755 "${srcdir}/pwsp-gui" "${pkgdir}/usr/bin/pwsp-gui"
install -Dm644 "$_srcsrc/assets/pwsp-gui.desktop" "${pkgdir}/usr/share/applications/pwsp-gui.desktop"
install -Dm644 "$_srcsrc/assets/icon.png" "${pkgdir}/usr/share/icons/hicolor/256x256/apps/pwsp.png"
install -Dm644 "$_srcsrc/assets/pwsp-daemon.service" "${pkgdir}/usr/lib/systemd/user/pwsp-daemon.service"
install -Dm644 "$_srcsrc/LICENSE" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
}
+17
View File
@@ -0,0 +1,17 @@
pkgbase = pwsp
pkgdesc = Lets you play audio files through your microphone
pkgver = 1.10.0
pkgrel = 1
url = https://github.com/arabianq/pipewire-soundpad
arch = any
license = MIT
makedepends = clang
makedepends = rust
makedepends = cargo
makedepends = cmake
makedepends = pipewire
makedepends = alsa-lib
source = https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.10.0.tar.gz
sha256sums = SKIP
pkgname = pwsp
+163
View File
@@ -0,0 +1,163 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
*.db
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
poetry.lock
.poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cythikaaryhon_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
#.vscode/
+47
View File
@@ -0,0 +1,47 @@
# Maintainer: Alexander Tarasov <a.tevg@ya.ru>
pkgsubn=pwsp
pkgname=pwsp
pkgver=1.10.0
pkgrel=1
pkgdesc="Lets you play audio files through your microphone"
arch=('any')
url="https://github.com/arabianq/pipewire-soundpad"
license=('MIT')
makedepends=(clang rust cargo cmake pipewire alsa-lib)
source=("$url/archive/refs/tags/v$pkgver.tar.gz")
sha256sums=('SKIP')
prepare() {
cd "${srcdir}/pipewire-soundpad-${pkgver}"
export CARGO_HOME="${srcdir}/${pkgname%}/.cargo" # Download all to src directory, not in ~/.cargo
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
}
build() {
cd "${srcdir}/pipewire-soundpad-${pkgver}"
export CARGO_ENCODED_RUSTFLAGS="--remap-path-prefix=${srcdir}=/" # Prevent warning: 'Package contains reference to $srcdir'
[[ -n "${_sccache}" ]] && export RUSTC_WRAPPER=sccache # If $_sccache not empty, build using binary cache
export CARGO_HOME="${srcdir}/${pkgname%}/.cargo" # Use downloaded earlier from src directory, not from ~/.cargo
export CARGO_TARGET_DIR=target # Place the output in target relative to the current directory
cargo build --frozen --release
}
package() {
cd "${srcdir}/pipewire-soundpad-${pkgver}"
install -Dm755 "target/release/pwsp-cli" "${pkgdir}/usr/bin/pwsp-cli"
install -Dm755 "target/release/pwsp-daemon" "${pkgdir}/usr/bin/pwsp-daemon"
install -Dm755 "target/release/pwsp-gui" "${pkgdir}/usr/bin/pwsp-gui"
install -Dm644 "assets/pwsp-gui.desktop" "${pkgdir}/usr/share/applications/pwsp-gui.desktop"
install -Dm644 "assets/icon.png" "${pkgdir}/usr/share/icons/hicolor/256x256/apps/pwsp.png"
install -Dm644 "assets/pwsp-daemon.service" "${pkgdir}/usr/lib/systemd/user/pwsp-daemon.service"
}
File diff suppressed because one or more lines are too long
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
set -e
if [ ! -f "Cargo.lock" ]; then
echo "Error: Cargo.lock not found. Please run this script from the project root."
return 1
fi
echo "Downloading flatpak-cargo-generator.py..."
curl -sLO https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/master/cargo/flatpak-cargo-generator.py
chmod +x flatpak-cargo-generator.py
echo "Generating cargo-sources.json..."
python3 flatpak-cargo-generator.py Cargo.lock -o packages/flatpak/cargo-sources.json
echo "Cleaning up..."
rm flatpak-cargo-generator.py
echo "Successfully generated packages/flatpak/cargo-sources.json"
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env python3
import argparse
import subprocess
import sys
if __name__ == "__main__":
if len(sys.argv) == 2 and sys.argv[1].startswith("soundpad://"):
subprocess.Popen(["pwsp-gui", sys.argv[1]])
sys.exit(0)
parser = argparse.ArgumentParser(
prog="PWSP Flatpak", add_help=True, exit_on_error=True
)
subparsers = parser.add_subparsers(dest="command")
cli_parser = subparsers.add_parser("cli", add_help=False, prefix_chars=" ")
cli_parser.add_argument(
"args", nargs=argparse.REMAINDER, help="Arguments for pwsp-cli"
)
daemon_parser = subparsers.add_parser("daemon", add_help=True)
daemon_group = daemon_parser.add_mutually_exclusive_group(required=True)
daemon_group.add_argument("--start", action="store_true", help="Start pwsp-daemon")
daemon_group.add_argument("--kill", action="store_true", help="Kill pwsp-daemon")
args = parser.parse_args()
command = args.command
if not command:
subprocess.Popen("pwsp-daemon")
subprocess.Popen("pwsp-gui")
else:
if command == "cli":
subprocess.Popen(["pwsp-cli"] + args.args)
elif command == "daemon":
if args.start:
subprocess.Popen("pwsp-daemon")
elif args.kill:
subprocess.Popen(["pwsp-cli", "action", "kill"])
+10
View File
@@ -0,0 +1,10 @@
[Desktop Entry]
Name=PWSP (Soundpad)
Comment=Let's you play audio files through you microphone
Exec=pwsp-wrapper.py %u
Icon=ru.arabianq.pwsp
Terminal=false
Type=Application
Categories=AudioVideo;Audio;
Keywords=soundpad;pipewire;audio;
MimeType=x-scheme-handler/soundpad;
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>ru.arabianq.pwsp</id>
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT</project_license>
<name>PWSP</name>
<summary>Play audio files through your microphone using PipeWire</summary>
<description>
<p>
PWSP (PipeWire Soundpad) is a tool that allows you to play audio files through your
microphone.
It features both a graphical user interface and a command-line interface.
</p>
</description>
<launchable type="desktop-id">ru.arabianq.pwsp.desktop</launchable>
<screenshots>
<screenshot type="default">
<image>https://raw.githubusercontent.com/arabianq/pipewire-soundpad/master/assets/screenshot.png</image>
</screenshot>
</screenshots>
<url type="homepage">https://pwsp.arabianq.ru</url>
<url type="bugtracker">https://github.com/arabianq/pipewire-soundpad/issues</url>
<url type="vcs-browser">https://github.com/arabianq/pipewire-soundpad</url>
<developer id="ru.arabianq">
<name>arabian</name>
</developer>
<releases>
<release version="1.10.0" date="2026-06-01" />
</releases>
<content_rating type="oars-1.1" />
</component>
+44
View File
@@ -0,0 +1,44 @@
app-id: ru.arabianq.pwsp
runtime: org.freedesktop.Platform
runtime-version: "25.08"
sdk: org.freedesktop.Sdk
sdk-extensions:
- org.freedesktop.Sdk.Extension.rust-stable
- org.freedesktop.Sdk.Extension.llvm20
command: pwsp-wrapper.py
finish-args:
- --share=ipc
- --socket=fallback-x11
- --socket=wayland
- --socket=pulseaudio
- --filesystem=xdg-run/pipewire-0
- --filesystem=xdg-run/pwsp:create
- --filesystem=xdg-run/app/ru.arabianq.pwsp:create
- --filesystem=home
- --device=all
- --device=dri
- --talk-name=org.freedesktop.portal.Desktop
- --talk-name=org.freedesktop.portal.Documents
build-options:
append-path: /usr/lib/sdk/rust-stable/bin:/usr/lib/sdk/llvm20/bin
env:
CARGO_HOME: /run/build/pwsp/cargo
LIBCLANG_PATH: /usr/lib/sdk/llvm20/lib
modules:
- name: pwsp
buildsystem: simple
build-commands:
- export CARGO_HOME=$PWD/cargo && cargo build --release --offline
- install -Dm755 target/release/pwsp-daemon /app/bin/pwsp-daemon
- install -Dm755 target/release/pwsp-cli /app/bin/pwsp-cli
- install -Dm755 target/release/pwsp-gui /app/bin/pwsp-gui
- install -Dm755 packages/flatpak/pwsp-wrapper.py /app/bin/pwsp-wrapper.py
- install -Dm644 assets/icon.png /app/share/icons/hicolor/256x256/apps/ru.arabianq.pwsp.png
- install -Dm644 packages/flatpak/ru.arabianq.pwsp.desktop /app/share/applications/ru.arabianq.pwsp.desktop
- install -Dm644 packages/flatpak/ru.arabianq.pwsp.metainfo.xml /app/share/metainfo/ru.arabianq.pwsp.metainfo.xml
sources:
- type: dir
path: ../../
- cargo-sources.json
+4 -1
View File
@@ -4,7 +4,7 @@
%global cargo_install_lib 0 %global cargo_install_lib 0
Name: pwsp Name: pwsp
Version: 1.4.0 Version: 1.10.0
Release: %autorelease Release: %autorelease
Summary: Lets you play audio files through your microphone Summary: Lets you play audio files through your microphone
@@ -18,6 +18,9 @@ BuildRequires: cargo
BuildRequires: pipewire-devel BuildRequires: pipewire-devel
BuildRequires: alsa-lib-devel BuildRequires: alsa-lib-devel
BuildRequires: clang-devel BuildRequires: clang-devel
BuildRequires: cmake
BuildRequires: dbus-devel
BuildRequires: pkgconf-pkg-config
%global _description %{expand: %global _description %{expand:
PWSP lets you play audio files through your microphone. Has both CLI and PWSP lets you play audio files through your microphone. Has both CLI and
+56 -8
View File
@@ -1,9 +1,10 @@
use anyhow::{Result, anyhow};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use pwsp::{ use pwsp::{
types::socket::Request, types::socket::Request,
utils::daemon::{make_request, wait_for_daemon}, utils::daemon::{make_request, wait_for_daemon},
}; };
use std::{error::Error, path::PathBuf}; use std::path::PathBuf;
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(version, about, long_about = None)] #[command(version, about, long_about = None)]
@@ -35,6 +36,8 @@ enum Commands {
enum Actions { enum Actions {
/// Ping the daemon /// Ping the daemon
Ping, Ping,
/// Kill the daemon
Kill,
/// Pause audio playback /// Pause audio playback
Pause { Pause {
#[clap(short, long)] #[clap(short, long)]
@@ -66,6 +69,12 @@ enum Actions {
#[clap(short, long)] #[clap(short, long)]
id: Option<u32>, id: Option<u32>,
}, },
/// Play a sound by hotkey slot name
PlayHotkey { slot: String },
/// Remove the hotkey slot
ClearHotkey { slot: String },
/// Clear the key chord for a hotkey slot
ClearHotkeyKey { slot: String },
} }
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
@@ -73,7 +82,10 @@ enum GetCommands {
/// Check if the player is paused /// Check if the player is paused
IsPaused, IsPaused,
/// Playback volume /// Playback volume
Volume, Volume {
#[clap(short, long)]
id: Option<u32>,
},
/// Playback position (in seconds) /// Playback position (in seconds)
Position { Position {
#[clap(short, long)] #[clap(short, long)]
@@ -92,6 +104,12 @@ enum GetCommands {
Input, Input,
/// All audio inputs /// All audio inputs
Inputs, Inputs,
/// Version of the daemon
DaemonVersion,
/// Full player state
FullState,
/// All hotkey slots
Hotkeys,
} }
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
@@ -116,10 +134,20 @@ enum SetCommands {
#[clap(short, long)] #[clap(short, long)]
id: Option<u32>, id: Option<u32>,
}, },
/// Assign a sound file to a hotkey slot
Hotkey { slot: String, file_path: PathBuf },
/// Set the key chord for a hotkey slot (e.g. "Ctrl+Alt+1")
HotkeyKey { slot: String, key_chord: String },
/// Atomically set the action and key chord for a hotkey slot
HotkeyActionAndKey {
slot: String,
action: String,
key_chord: String,
},
} }
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> { async fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
wait_for_daemon().await?; wait_for_daemon().await?;
@@ -127,6 +155,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
let request = match cli.command { let request = match cli.command {
Commands::Action { action } => match action { Commands::Action { action } => match action {
Actions::Ping => Request::ping(), Actions::Ping => Request::ping(),
Actions::Kill => Request::kill(),
Actions::Pause { id } => Request::pause(id), Actions::Pause { id } => Request::pause(id),
Actions::Resume { id } => Request::resume(id), Actions::Resume { id } => Request::resume(id),
Actions::TogglePause { id } => Request::toggle_pause(id), Actions::TogglePause { id } => Request::toggle_pause(id),
@@ -134,30 +163,49 @@ async fn main() -> Result<(), Box<dyn Error>> {
Actions::Play { Actions::Play {
file_path, file_path,
concurrent, concurrent,
} => Request::play(file_path.to_str().unwrap(), concurrent), } => Request::play(&file_path.to_string_lossy(), concurrent),
Actions::ToggleLoop { id } => Request::toggle_loop(id), Actions::ToggleLoop { id } => Request::toggle_loop(id),
Actions::PlayHotkey { slot } => Request::play_hotkey(&slot),
Actions::ClearHotkey { slot } => Request::clear_hotkey(&slot),
Actions::ClearHotkeyKey { slot } => Request::clear_hotkey_key(&slot),
}, },
Commands::Get { parameter } => match parameter { Commands::Get { parameter } => match parameter {
GetCommands::IsPaused => Request::get_is_paused(), GetCommands::IsPaused => Request::get_is_paused(),
GetCommands::Volume => Request::get_volume(), GetCommands::Volume { id } => Request::get_volume(id),
GetCommands::Position { id } => Request::get_position(id), GetCommands::Position { id } => Request::get_position(id),
GetCommands::Duration { id } => Request::get_duration(id), GetCommands::Duration { id } => Request::get_duration(id),
GetCommands::State => Request::get_state(), GetCommands::State => Request::get_state(),
GetCommands::Tracks => Request::get_tracks(), GetCommands::Tracks => Request::get_tracks(),
GetCommands::Input => Request::get_input(), GetCommands::Input => Request::get_input(),
GetCommands::Inputs => Request::get_inputs(), GetCommands::Inputs => Request::get_inputs(),
GetCommands::DaemonVersion => Request::get_daemon_version(),
GetCommands::FullState => Request::get_full_state(),
GetCommands::Hotkeys => Request::get_hotkeys(),
}, },
Commands::Set { parameter } => match parameter { Commands::Set { parameter } => match parameter {
SetCommands::Volume { volume, id } => Request::set_volume(volume, id), SetCommands::Volume { volume, id } => Request::set_volume(volume, id),
SetCommands::Position { position, id } => Request::seek(position, id), SetCommands::Position { position, id } => Request::seek(position, id),
SetCommands::Input { name } => Request::set_input(&name), SetCommands::Input { name } => Request::set_input(&name),
SetCommands::Loop { enabled, id } => Request::set_loop(&enabled, id), SetCommands::Loop { enabled, id } => Request::set_loop(&enabled, id),
SetCommands::Hotkey { slot, file_path } => {
Request::set_hotkey(&slot, &file_path.to_string_lossy())
}
SetCommands::HotkeyKey { slot, key_chord } => {
Request::set_hotkey_key(&slot, &key_chord)
}
SetCommands::HotkeyActionAndKey {
slot,
action,
key_chord,
} => Request::set_hotkey_action_and_key(
&slot,
&serde_json::from_str::<Request>(&action)?,
&key_chord,
),
}, },
}; };
let response = make_request(request) let response = make_request(request).await.map_err(|e| anyhow!(e))?;
.await
.map_err(|e| e as Box<dyn Error>)?;
println!("{} : {}", response.status, response.message); println!("{} : {}", response.status, response.message);
Ok(()) Ok(())
+92 -21
View File
@@ -1,45 +1,61 @@
use anyhow::{Result, anyhow};
use pwsp::{ use pwsp::{
types::socket::{Request, Response}, types::socket::{MAX_MESSAGE_SIZE, Request, Response},
utils::{ utils::{
commands::parse_command, commands::parse_command,
daemon::{ daemon::{
create_runtime_dir, get_audio_player, get_daemon_config, get_runtime_dir, create_runtime_dir, get_audio_player, get_daemon_config, get_runtime_dir,
is_daemon_running, link_player_to_virtual_mic, is_daemon_running,
}, },
global_hotkeys::start_global_hotkey_listener,
pipewire::create_virtual_mic, pipewire::create_virtual_mic,
}, },
}; };
use std::{error::Error, fs, time::Duration}; use std::os::unix::fs::PermissionsExt;
use std::{fs, time::Duration};
use tokio::{ use tokio::{
io::{AsyncReadExt, AsyncWriteExt}, io::{AsyncReadExt, AsyncWriteExt},
net::UnixListener, net::{UnixListener, UnixStream},
time::sleep, time::sleep,
}; };
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> { async fn main() -> Result<()> {
create_runtime_dir()?; create_runtime_dir()?;
if is_daemon_running()? { if is_daemon_running()? {
return Err("Another instance is already running.".into()); return Err(anyhow!("Another instance is already running."));
} }
get_daemon_config(); // Initialize daemon config get_daemon_config(); // Initialize daemon config
create_virtual_mic()?; create_virtual_mic()?;
get_audio_player().await; // Initialize audio player if let Err(err) = get_audio_player().await {
link_player_to_virtual_mic().await?; eprintln!("Failed to initialize audio player: {}", err);
} // Initialize audio player
tokio::spawn(async {
start_global_hotkey_listener().await;
});
let runtime_dir = get_runtime_dir(); let runtime_dir = get_runtime_dir();
let lock_file = fs::File::create(runtime_dir.join("daemon.lock"))?; let lock_file = fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(runtime_dir.join("daemon.lock"))?;
lock_file.lock()?; lock_file.lock()?;
let socket_path = runtime_dir.join("daemon.sock"); let socket_path = runtime_dir.join("daemon.sock");
if fs::metadata(&socket_path).is_ok() { if let Err(e) = fs::remove_file(&socket_path)
fs::remove_file(&socket_path)?; && e.kind() != std::io::ErrorKind::NotFound
{
return Err(e.into());
} }
let listener = UnixListener::bind(&socket_path)?; let listener = UnixListener::bind(&socket_path)?;
fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600))?;
println!( println!(
"Daemon started. Listening on {}", "Daemon started. Listening on {}",
socket_path.to_str().unwrap_or_default() socket_path.to_str().unwrap_or_default()
@@ -65,11 +81,17 @@ async fn main() -> Result<(), Box<dyn Error>> {
Ok(()) Ok(())
} }
async fn commands_loop(listener: UnixListener) -> Result<(), Box<dyn Error>> { async fn commands_loop(listener: UnixListener) -> Result<()> {
loop { loop {
let (mut stream, _addr) = listener.accept().await?; let (stream, _addr) = listener.accept().await?;
tokio::spawn(async move { tokio::spawn(async move {
handle_connection(stream).await;
});
}
}
async fn handle_connection(mut stream: UnixStream) {
// ---------- Read request (start) ---------- // ---------- Read request (start) ----------
let mut len_bytes = [0u8; 4]; let mut len_bytes = [0u8; 4];
if stream.read_exact(&mut len_bytes).await.is_err() { if stream.read_exact(&mut len_bytes).await.is_err() {
@@ -79,13 +101,40 @@ async fn commands_loop(listener: UnixListener) -> Result<(), Box<dyn Error>> {
let request_len = u32::from_le_bytes(len_bytes) as usize; let request_len = u32::from_le_bytes(len_bytes) as usize;
let mut buffer = vec![0u8; request_len]; if request_len > MAX_MESSAGE_SIZE {
if stream.read_exact(&mut buffer).await.is_err() { eprintln!(
"Failed to read message from client: request too large ({} bytes)!",
request_len
);
return;
}
let mut buffer = Vec::new();
if (&mut stream)
.take(request_len as u64)
.read_to_end(&mut buffer)
.await
.is_err()
|| buffer.len() != request_len
{
eprintln!("Failed to read message from client!"); eprintln!("Failed to read message from client!");
return; return;
} }
let request: Request = serde_json::from_slice(&buffer).unwrap(); let request: Request = match serde_json::from_slice(&buffer) {
Ok(req) => req,
Err(err) => {
let response = Response::new(false, format!("Failed to parse request: {}", err));
let response_data = match serde_json::to_vec(&response) {
Ok(data) => data,
Err(_) => return, // Should not happen with this simple Response
};
let response_len = response_data.len() as u32;
let _ = stream.write_all(&response_len.to_le_bytes()).await;
let _ = stream.write_all(&response_data).await;
return;
}
};
// ---------- Read request (end) ---------- // ---------- Read request (end) ----------
// ---------- Generate response (start) ---------- // ---------- Generate response (start) ----------
@@ -99,7 +148,13 @@ async fn commands_loop(listener: UnixListener) -> Result<(), Box<dyn Error>> {
// ---------- Generate response (end) ---------- // ---------- Generate response (end) ----------
// ---------- Send response (start) ---------- // ---------- Send response (start) ----------
let response_data = serde_json::to_vec(&response).unwrap(); let response_data = match serde_json::to_vec(&response) {
Ok(data) => data,
Err(err) => {
eprintln!("Failed to serialize response: {}", err);
return;
}
};
let response_len = response_data.len() as u32; let response_len = response_data.len() as u32;
if stream.write_all(&response_len.to_le_bytes()).await.is_err() { if stream.write_all(&response_len.to_le_bytes()).await.is_err() {
@@ -111,16 +166,32 @@ async fn commands_loop(listener: UnixListener) -> Result<(), Box<dyn Error>> {
return; return;
} }
// ---------- Send response (end) ---------- // ---------- Send response (end) ----------
});
if response.status && response.message.eq("killed") {
std::process::exit(0);
} }
} }
async fn player_loop() { async fn player_loop() {
let mut device_check_counter: u32 = 0;
loop { loop {
let mut audio_player = get_audio_player().await.lock().await; let is_idle = match get_audio_player().await {
Ok(player_mutex) => {
audio_player.update().await; let mut audio_player = player_mutex.lock().await;
let check_devices = device_check_counter == 0;
audio_player.update(check_devices).await;
audio_player.tracks.is_empty()
}
Err(_err) => true,
};
if is_idle {
device_check_counter = 0;
sleep(Duration::from_secs(2)).await;
} else {
// Check devices every ~5 seconds (50 * 100ms) while playing
device_check_counter = (device_check_counter + 1) % 50;
sleep(Duration::from_millis(100)).await; sleep(Duration::from_millis(100)).await;
} }
}
} }
-483
View File
@@ -1,483 +0,0 @@
use crate::gui::{SUPPORTED_EXTENSIONS, SoundpadGui};
use egui::{
Align, AtomExt, Button, Color32, ComboBox, FontFamily, Label, Layout, RichText, ScrollArea,
Slider, TextEdit, Ui, Vec2,
};
use egui_material_icons::icons;
use pwsp::types::audio_player::TrackInfo;
use pwsp::utils::gui::format_time_pair;
use std::{error::Error, path::PathBuf, time::Instant};
use pwsp::types::gui::AppState;
enum TrackAction {
Pause(u32),
Resume(u32),
ToggleLoop(u32),
Stop(u32),
}
impl SoundpadGui {
fn get_volume_icon(volume: f32) -> &'static str {
if volume > 0.7 {
icons::ICON_VOLUME_UP
} else if volume <= 0.0 {
icons::ICON_VOLUME_OFF
} else if volume < 0.3 {
icons::ICON_VOLUME_MUTE
} else {
icons::ICON_VOLUME_DOWN
}
}
pub fn draw_waiting_for_daemon(&mut self, ui: &mut Ui) {
ui.centered_and_justified(|ui| {
ui.label(
RichText::new("Waiting for PWSP daemon to start...")
.size(34.0)
.monospace(),
);
});
}
pub fn draw_settings(&mut self, ui: &mut Ui) {
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 5.0;
// --------- Back Button and Title ----------
ui.horizontal_top(|ui| {
let back_button = Button::new(icons::ICON_ARROW_BACK).frame(false);
let back_button_response = ui.add(back_button);
if back_button_response.clicked() {
self.app_state.show_settings = false;
}
ui.add_space(ui.available_width() / 2.0 - 40.0);
ui.label(RichText::new("Settings").color(Color32::WHITE).monospace());
});
// --------------------------------
ui.separator();
ui.add_space(20.0);
// --------- Checkboxes ----------
let save_volume_response =
ui.checkbox(&mut self.config.save_volume, "Always remember volume");
let save_input_response =
ui.checkbox(&mut self.config.save_input, "Always remember microphone");
let save_scale_response = ui.checkbox(
&mut self.config.save_scale_factor,
"Always remember UI scale factor",
);
let pause_on_exit_response = ui.checkbox(
&mut self.config.pause_on_exit,
"Pause audio playback when the window is closed",
);
if save_volume_response.changed()
|| save_input_response.changed()
|| save_scale_response.changed()
|| pause_on_exit_response.changed()
{
self.config.save_to_file().ok();
}
// --------------------------------
});
}
pub fn draw(&mut self, ui: &mut Ui) -> Result<(), Box<dyn Error>> {
self.draw_header(ui);
self.draw_body(ui);
ui.separator();
self.draw_footer(ui);
Ok(())
}
fn draw_header(&mut self, ui: &mut Ui) {
ui.vertical_centered_justified(|ui| {
self.draw_controls(ui);
});
}
fn draw_controls(&mut self, ui: &mut Ui) {
if self.audio_player_state.tracks.is_empty() {
ui.label("No tracks playing");
return;
}
let tracks = self.audio_player_state.tracks.clone();
let mut action = None;
for track in tracks {
ui.label(
RichText::new(
track
.path
.file_stem()
.unwrap_or_default()
.to_str()
.unwrap_or_default(),
)
.color(Color32::WHITE)
.family(FontFamily::Monospace),
);
if let Some(act) = Self::draw_track_control(ui, &mut self.app_state, &track) {
action = Some(act);
}
ui.separator();
}
if let Some(action) = action {
match action {
TrackAction::Pause(id) => self.pause(Some(id)),
TrackAction::Resume(id) => self.resume(Some(id)),
TrackAction::ToggleLoop(id) => self.toggle_loop(Some(id)),
TrackAction::Stop(id) => self.stop(Some(id)),
}
}
}
fn draw_track_control(
ui: &mut Ui,
app_state: &mut AppState,
track: &TrackInfo,
) -> Option<TrackAction> {
let ui_state = app_state.track_ui_states.entry(track.id).or_default();
let should_update_position = !ui_state.position_dragged
&& ui_state
.ignore_position_update_until
.map(|t| Instant::now() > t)
.unwrap_or(true);
if should_update_position {
ui_state.position_slider_value = track.position;
}
let should_update_volume = !ui_state.volume_dragged
&& ui_state
.ignore_volume_update_until
.map(|t| Instant::now() > t)
.unwrap_or(true);
if should_update_volume {
ui_state.volume_slider_value = track.volume;
}
let mut action = None;
ui.horizontal_top(|ui| {
// ---------- Play Button ----------
let play_button = Button::new(if track.paused {
icons::ICON_PLAY_ARROW
} else {
icons::ICON_PAUSE
})
.corner_radius(15.0);
let play_button_response = ui.add_sized([30.0, 30.0], play_button);
if play_button_response.clicked() {
if track.paused {
action = Some(TrackAction::Resume(track.id));
} else {
action = Some(TrackAction::Pause(track.id));
}
}
// --------------------------------
// ---------- Loop Button ----------
let loop_button = Button::new(
RichText::new(if track.looped {
icons::ICON_REPEAT_ONE
} else {
icons::ICON_REPEAT
})
.size(18.0),
)
.frame(false);
let loop_button_response = ui.add_sized([15.0, 30.0], loop_button);
if loop_button_response.clicked() {
action = Some(TrackAction::ToggleLoop(track.id));
}
// --------------------------------
// ---------- Position Slider ----------
let duration = track.duration.unwrap_or(1.0);
let position_slider = Slider::new(&mut ui_state.position_slider_value, 0.0..=duration)
.show_value(false)
.step_by(0.01);
let default_slider_width = ui.spacing().slider_width;
let position_slider_width = ui.available_width()
- (30.0 * 3.0)
- default_slider_width
- (ui.spacing().item_spacing.x * 6.0);
ui.spacing_mut().slider_width = position_slider_width;
let position_slider_response = ui.add_sized([30.0, 30.0], position_slider);
if position_slider_response.drag_stopped() {
ui_state.position_dragged = true;
}
// --------------------------------
// ---------- Time Label ----------
let time_label =
Label::new(RichText::new(format_time_pair(track.position, duration)).monospace());
ui.add_sized([30.0, 30.0], time_label);
// --------------------------------
// ---------- Volume Icon ----------
let volume_icon = Self::get_volume_icon(track.volume);
let volume_label = Label::new(RichText::new(volume_icon).size(18.0));
ui.add_sized([30.0, 30.0], volume_label)
.on_hover_text(format!("Volume: {:.0}%", track.volume * 100.0));
// --------------------------------
// ---------- Volume Slider ----------
let volume_slider = Slider::new(&mut ui_state.volume_slider_value, 0.0..=1.0)
.show_value(false)
.step_by(0.01);
ui.spacing_mut().slider_width = default_slider_width - 30.0;
ui.spacing_mut().item_spacing.x = 0.0;
let volume_slider_response = ui.add_sized([30.0, 30.0], volume_slider);
if volume_slider_response.drag_stopped() {
ui_state.volume_dragged = true;
}
// --------------------------------
// ---------- Stop Button ---------
let stop_button = Button::new(icons::ICON_CLOSE).frame(false);
let stop_button_response = ui.add_sized([30.0, 30.0], stop_button);
if stop_button_response.clicked() {
action = Some(TrackAction::Stop(track.id));
}
// --------------------------------
});
action
}
fn draw_body(&mut self, ui: &mut Ui) {
let dirs_size = Vec2::new(ui.available_width() / 4.0, ui.available_height() - 40.0);
ui.horizontal(|ui| {
self.draw_dirs(ui, dirs_size);
ui.separator();
let files_size = Vec2::new(ui.available_width(), ui.available_height() - 40.0);
self.draw_files(ui, files_size);
});
}
fn draw_dirs(&mut self, ui: &mut Ui, area_size: Vec2) {
ui.vertical(|ui| {
ui.set_min_width(area_size.x);
ui.set_min_height(area_size.y);
ScrollArea::vertical().id_salt(0).show(ui, |ui| {
ui.set_min_width(area_size.x);
let mut dirs: Vec<PathBuf> = self.app_state.dirs.iter().cloned().collect();
dirs.sort();
for path in dirs.iter() {
ui.horizontal(|ui| {
let name = path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| path.to_string_lossy().to_string());
let mut dir_button_text = RichText::new(name.clone());
if let Some(current_dir) = &self.app_state.current_dir {
if current_dir.eq(path) {
dir_button_text = dir_button_text.color(Color32::WHITE);
}
}
let dir_button =
Button::new(dir_button_text.atom_max_width(area_size.x)).frame(false);
let dir_button_response = ui.add(dir_button);
if dir_button_response.clicked() {
self.open_dir(path);
}
let delete_dir_button = Button::new(icons::ICON_DELETE).frame(false);
let delete_dir_button_response =
ui.add_sized([18.0, 18.0], delete_dir_button);
if delete_dir_button_response.clicked() {
self.remove_dir(&path.clone());
}
});
}
ui.horizontal(|ui| {
let add_dirs_button = Button::new(icons::ICON_ADD).frame(false);
let add_dirs_button_response = ui.add_sized([18.0, 18.0], add_dirs_button);
if add_dirs_button_response.clicked() {
self.add_dirs();
}
});
ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
let play_file_button = Button::new("Play file");
let play_file_button_response = ui.add(play_file_button);
if play_file_button_response.clicked() {
self.open_file();
}
});
});
});
}
fn draw_files(&mut self, ui: &mut Ui, area_size: Vec2) {
ui.vertical(|ui| {
ui.horizontal(|ui| {
let search_field = ui.add_sized(
[ui.available_width(), 22.0],
TextEdit::singleline(&mut self.app_state.search_query).hint_text("Search..."),
);
self.app_state.search_field_id = Some(search_field.id);
});
ui.separator();
ScrollArea::vertical().id_salt(1).show(ui, |ui| {
ui.set_min_width(area_size.x);
ui.set_min_height(area_size.y);
ui.vertical(|ui| {
let mut files: Vec<PathBuf> = self.app_state.files.iter().cloned().collect();
files.sort();
for entry_path in files {
if entry_path.is_dir() {
continue;
}
if !SUPPORTED_EXTENSIONS
.contains(&entry_path.extension().unwrap_or_default().to_str().unwrap())
{
continue;
}
let file_name = entry_path
.file_name()
.unwrap()
.to_string_lossy()
.to_string();
let search_query = self
.app_state
.search_query
.to_lowercase()
.trim()
.to_string();
if !file_name.to_lowercase().contains(search_query.as_str()) {
continue;
}
let mut file_button_text = RichText::new(file_name);
if let Some(current_file) = &self.app_state.selected_file {
if current_file.eq(&entry_path) {
file_button_text = file_button_text.color(Color32::WHITE);
}
}
let file_button = Button::new(file_button_text).frame(false);
let file_button_response = ui.add(file_button);
if file_button_response.clicked() {
ui.input(|i| {
if i.modifiers.ctrl {
self.play_file(&entry_path, true);
} else if i.modifiers.shift
&& let Some(last_track) = self.audio_player_state.tracks.last()
{
self.stop(Some(last_track.id));
self.play_file(&entry_path, true);
} else {
self.play_file(&entry_path, false);
}
});
self.app_state.selected_file = Some(entry_path);
}
}
});
});
});
}
fn draw_footer(&mut self, ui: &mut Ui) {
ui.add_space(5.0);
ui.horizontal(|ui| {
// ---------- Microphone selection ----------
let mut mics: Vec<(&String, &String)> =
self.audio_player_state.all_inputs.iter().collect();
mics.sort_by_key(|(k, _)| *k);
let mut selected_input = self.audio_player_state.current_input.to_owned();
let prev_input = selected_input.to_owned();
ComboBox::from_label("Choose microphone")
.height(30.0)
.selected_text(
self.audio_player_state
.all_inputs
.get(&selected_input)
.unwrap_or(&String::new()),
)
.show_ui(ui, |ui| {
for (name, nick) in mics {
ui.selectable_value(&mut selected_input, name.to_owned(), nick);
}
});
if selected_input != prev_input {
self.set_input(selected_input);
}
// --------------------------------
// ---------- Master Volume Slider ----------
let volume_icon = Self::get_volume_icon(self.audio_player_state.volume);
let volume_label = Label::new(RichText::new(volume_icon).size(18.0));
ui.add_sized([18.0, 18.0], volume_label)
.on_hover_text(format!(
"Master Volume: {:.0}%",
self.audio_player_state.volume * 100.0
));
let should_update_volume = !self.app_state.volume_dragged
&& self
.app_state
.ignore_volume_update_until
.map(|t| Instant::now() > t)
.unwrap_or(true);
if should_update_volume {
self.app_state.volume_slider_value = self.audio_player_state.volume;
}
let volume_slider = Slider::new(&mut self.app_state.volume_slider_value, 0.0..=1.0)
.show_value(false)
.step_by(0.01);
let volume_slider_response = ui.add_sized([150.0, 18.0], volume_slider);
if volume_slider_response.drag_stopped() {
self.app_state.volume_dragged = true;
}
// ------------------------------------------
ui.add_space(ui.available_width() - 18.0 - ui.spacing().item_spacing.x);
// ---------- Settings button ----------
let settings_button =
Button::new(icons::ICON_SETTINGS.atom_size(Vec2::new(18.0, 18.0))).frame(false);
let settings_button_response = ui.add_sized([18.0, 18.0], settings_button);
if settings_button_response.clicked() {
self.app_state.show_settings = true;
}
// --------------------------------
});
}
}
+191 -94
View File
@@ -1,122 +1,219 @@
use crate::gui::SoundpadGui; use crate::gui::SoundpadGui;
use egui::{Context, Key}; use egui::{Context, Id, Key, Modifiers};
use pwsp::types::socket::Request;
use pwsp::utils::gui::make_request_async;
use std::path::PathBuf; /// Convert an egui Key + Modifiers to a normalized chord string like "Ctrl+Shift+A".
fn chord_from_event(modifiers: &Modifiers, key: &Key) -> Option<String> {
let key_name = key.name();
let is_valid = (key_name.len() == 1
&& key_name
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphanumeric()))
|| (key_name.starts_with('F')
&& key_name.len() > 1
&& key_name[1..].chars().all(|c| c.is_ascii_digit()));
if !is_valid {
return None;
}
// Require at least one modifier for hotkey chords (ignoring command/Super due to Wayland/Niri bug)
if !modifiers.ctrl && !modifiers.alt && !modifiers.shift {
return None;
}
let mut parts = vec![];
if modifiers.ctrl {
parts.push("Ctrl");
}
if modifiers.alt {
parts.push("Alt");
}
if modifiers.shift {
parts.push("Shift");
}
// We intentionally ignore modifiers.command (Super) here to bypass a Wayland/Niri bug
// where the Super key modifier is constantly active.
parts.push(key_name);
Some(parts.join("+"))
}
/// Parse a chord string back to (Modifiers, Key) for matching.
pub fn parse_chord(chord: &str) -> Option<(Modifiers, Key)> {
let parts: Vec<&str> = chord.split('+').collect();
if parts.is_empty() {
return None;
}
let mut modifiers = Modifiers::NONE;
for &part in &parts[..parts.len() - 1] {
match part {
"Ctrl" => modifiers.ctrl = true,
"Alt" => modifiers.alt = true,
"Shift" => modifiers.shift = true,
"Super" => modifiers.command = true,
_ => return None,
}
}
let key_name = parts[parts.len() - 1];
let is_valid = (key_name.len() == 1
&& key_name
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphanumeric()))
|| (key_name.starts_with('F')
&& key_name.len() > 1
&& key_name[1..].chars().all(|c| c.is_ascii_digit()));
if !is_valid {
return None;
}
let key = Key::from_name(key_name)?;
Some((modifiers, key))
}
impl SoundpadGui { impl SoundpadGui {
fn key_pressed(&self, ctx: &Context, key: Key) -> bool {
ctx.input(|i| i.key_pressed(key))
}
fn modifiers(&self, ctx: &Context) -> Modifiers {
ctx.input(|i| i.modifiers)
}
fn get_focused(&self, ctx: &Context) -> Option<Id> {
ctx.memory(|m| m.focused())
}
pub fn handle_input(&mut self, ctx: &Context) { pub fn handle_input(&mut self, ctx: &Context) {
if ctx.memory(|reader| { reader.focused() }.is_some()) { let _modifiers = self.modifiers(ctx);
let search_focused = {
if let Some(focused_id) = self.get_focused(ctx)
&& let Some(search_id) = self.app_state.search_field_id
&& focused_id.eq(&search_id)
{
true
} else {
false
}
};
// Handle hotkey capture mode: listen for a key chord to assign
if self.app_state.hotkey_capture_active {
if self.key_pressed(ctx, Key::Escape) {
self.app_state.hotkey_capture_active = false;
self.app_state.assigning_hotkey_slot = None;
self.app_state.assigning_hotkey_for_file = None;
return; return;
} }
ctx.input(|i| { // Try to capture a chord from any key press
// Close app on espace let captured = ctx.input(|i| {
if i.key_pressed(Key::Escape) { for event in &i.events {
std::process::exit(0); if let egui::Event::Key {
key,
pressed: true,
modifiers: mods,
..
} = event
&& let Some(chord) = chord_from_event(mods, key)
{
return Some(chord);
}
}
None
});
if let Some(chord) = captured {
if let Some(slot) = self.app_state.assigning_hotkey_slot.take() {
make_request_async(Request::set_hotkey_key(&slot, &chord));
self.app_state
.hotkey_config
.set_key_chord(&slot, Some(chord));
} else if let Some(file_path) = self.app_state.assigning_hotkey_for_file.take() {
// Auto-create a slot from the file name
let slot_name = file_path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let action = Request::play(&file_path.to_string_lossy(), false);
make_request_async(Request::set_hotkey_action_and_key(
&slot_name, &action, &chord,
));
self.app_state
.hotkey_config
.set_slot(slot_name.clone(), action);
self.app_state
.hotkey_config
.set_key_chord(&slot_name, Some(chord.clone()));
}
self.app_state.hotkey_capture_active = false;
self.app_state.assigning_hotkey_slot = None;
self.app_state.assigning_hotkey_for_file = None;
}
return;
} }
// Open/close settings // Open/close settings
if i.key_pressed(Key::I) { if !search_focused && self.key_pressed(ctx, Key::I) {
self.app_state.show_settings = !self.app_state.show_settings; self.app_state.show_settings = !self.app_state.show_settings;
} }
if i.key_pressed(Key::Enter) && self.app_state.selected_file.is_some() { // Toggle hotkeys view
let path = &self.app_state.selected_file.clone().unwrap(); if !search_focused && self.key_pressed(ctx, Key::H) {
if i.modifiers.ctrl { self.app_state.show_hotkeys = !self.app_state.show_hotkeys;
self.play_file(path, true);
} else if i.modifiers.shift
&& let Some(last_track) = self.audio_player_state.tracks.last()
{
self.stop(Some(last_track.id));
self.play_file(path, true);
} else {
self.play_file(path, false);
}
} }
if !self.app_state.show_settings { if !self.app_state.show_settings && !self.app_state.show_hotkeys {
// Pause / resume audio on space // Pause / resume audio on space
if i.key_pressed(Key::Space) { if !search_focused && self.key_pressed(ctx, Key::Space) {
self.play_toggle(); self.play_toggle();
} }
// Stop all audio tracks on backspace // Stop all audio tracks on backspace
if i.key_pressed(Key::Backspace) { if !search_focused && self.key_pressed(ctx, Key::Backspace) {
self.stop(None); self.stop(None);
} }
// Focus search field // Focus search field
if i.key_pressed(Key::Slash) && self.app_state.search_field_id.is_some() { if self.key_pressed(ctx, Key::Slash) {
self.app_state.force_focus_id = self.app_state.search_field_id; if search_focused {
} ctx.memory_mut(|m| {
m.request_focus(Id::NULL);
// Iterate through dirs if there are some
if i.modifiers.ctrl {
let arrow_up_pressed = i.key_pressed(Key::ArrowUp);
let arrow_down_pressed = i.key_pressed(Key::ArrowDown);
if arrow_up_pressed || arrow_down_pressed {
if i.modifiers.shift && !self.app_state.dirs.is_empty() {
let mut dirs: Vec<PathBuf> =
self.app_state.dirs.iter().cloned().collect();
dirs.sort();
let current_dir_index: i8;
if let Some(current_dir) = &self.app_state.current_dir {
if let Some(index) = dirs.iter().position(|x| x == current_dir) {
current_dir_index = index as i8;
} else {
current_dir_index = -1;
}
} else {
current_dir_index = -1;
}
let mut new_dir_index: i8;
new_dir_index = current_dir_index - arrow_up_pressed as i8
+ arrow_down_pressed as i8;
if new_dir_index < 0 {
new_dir_index = (dirs.len() - 1) as i8;
} else if new_dir_index >= dirs.len() as i8 {
new_dir_index = 0;
}
self.open_dir(&dirs[new_dir_index as usize]);
} else if self.app_state.current_dir.is_some() {
let mut files: Vec<PathBuf> =
self.app_state.files.iter().cloned().collect();
files.sort();
let current_files_index: i64;
if let Some(selected_file) = &self.app_state.selected_file {
if let Some(index) = files.iter().position(|x| x == selected_file) {
current_files_index = index as i64;
} else {
current_files_index = -1;
}
} else {
current_files_index = -1;
}
let mut new_files_index: i64;
new_files_index = current_files_index - arrow_up_pressed as i64
+ arrow_down_pressed as i64;
if new_files_index < 0 {
new_files_index = (files.len() - 1) as i64;
} else if new_files_index >= files.len() as i64 {
new_files_index = 0;
}
self.app_state.selected_file =
Some(files[new_files_index as usize].clone());
}
}
}
}
}); });
} else {
self.app_state.force_focus_search = true;
}
}
// Check for hotkey chord triggers
let slots_to_play: Vec<String> = ctx.input(|i| {
let mut result = vec![];
for slot in &self.app_state.hotkey_config.slots {
if let Some(chord) = &slot.key_chord
&& let Some((mods, key)) = parse_chord(chord)
&& i.modifiers == mods
&& i.key_pressed(key)
{
result.push(slot.slot.clone());
}
}
result
});
for slot in slots_to_play {
self.play_hotkey_slot(&slot);
}
}
// });
} }
} }
+140 -28
View File
@@ -1,13 +1,16 @@
mod draw;
mod input; mod input;
mod update; mod update;
mod views;
use anyhow::{Result, anyhow};
use eframe::{HardwareAcceleration, NativeOptions, icon_data::from_png_bytes, run_native}; use eframe::{HardwareAcceleration, NativeOptions, icon_data::from_png_bytes, run_native};
use egui::{Context, Vec2, ViewportBuilder}; use egui::{Context, FontData, FontDefinitions, FontFamily, FontTweak, Vec2, ViewportBuilder};
use itertools::Itertools;
use pwsp::{ use pwsp::{
types::{ types::{
audio_player::PlayerState, audio_player::PlayerState,
config::GuiConfig, config::GuiConfig,
config::HotkeyConfig,
gui::{AppState, AudioPlayerState}, gui::{AppState, AudioPlayerState},
socket::Request, socket::Request,
}, },
@@ -18,13 +21,15 @@ use pwsp::{
}; };
use rfd::FileDialog; use rfd::FileDialog;
use std::{ use std::{
error::Error, cmp::Ordering,
path::PathBuf, fs,
path::{Path, PathBuf},
sync::{Arc, Mutex}, sync::{Arc, Mutex},
}; };
use system_fonts::{FontStyle, FoundFontSource, find_for_locale};
const SUPPORTED_EXTENSIONS: [&str; 11] = [ const SUPPORTED_EXTENSIONS: [&str; 13] = [
"mp3", "wav", "ogg", "flac", "mp4", "m4a", "aac", "mov", "mkv", "webm", "avi", "mp3", "wav", "ogg", "flac", "mp4", "m4a", "aac", "mov", "mkv", "mka", "webm", "avi", "opus",
]; ];
struct SoundpadGui { struct SoundpadGui {
@@ -51,13 +56,17 @@ impl SoundpadGui {
}; };
soundpad_gui.app_state.dirs = config.dirs; soundpad_gui.app_state.dirs = config.dirs;
soundpad_gui.app_state.hotkey_config = HotkeyConfig::load().unwrap_or_default();
soundpad_gui soundpad_gui
} }
pub fn play_toggle(&mut self) { pub fn play_toggle(&mut self) {
let (new_state, request) = { let (new_state, request) = {
let guard = self.audio_player_state_shared.lock().unwrap(); let guard = self
.audio_player_state_shared
.lock()
.unwrap_or_else(|e| e.into_inner());
match guard.state { match guard.state {
PlayerState::Playing => (Some(PlayerState::Paused), Some(Request::pause(None))), PlayerState::Playing => (Some(PlayerState::Paused), Some(Request::pause(None))),
PlayerState::Paused => (Some(PlayerState::Playing), Some(Request::resume(None))), PlayerState::Paused => (Some(PlayerState::Playing), Some(Request::resume(None))),
@@ -70,7 +79,10 @@ impl SoundpadGui {
} }
if let Some(state) = new_state { if let Some(state) = new_state {
let mut guard = self.audio_player_state_shared.lock().unwrap(); let mut guard = self
.audio_player_state_shared
.lock()
.unwrap_or_else(|e| e.into_inner());
guard.new_state = Some(state.clone()); guard.new_state = Some(state.clone());
guard.state = state; guard.state = state;
} }
@@ -87,37 +99,32 @@ impl SoundpadGui {
let file_dialog = FileDialog::new(); let file_dialog = FileDialog::new();
if let Some(paths) = file_dialog.pick_folders() { if let Some(paths) = file_dialog.pick_folders() {
for path in paths { for path in paths {
self.app_state.dirs.insert(path); self.app_state.dirs.push(path);
} }
self.app_state.dirs = self.app_state.dirs.iter().unique().cloned().collect();
self.config.dirs = self.app_state.dirs.clone(); self.config.dirs = self.app_state.dirs.clone();
self.config.save_to_file().ok(); self.config.save_to_file().ok();
} }
} }
pub fn remove_dir(&mut self, path: &PathBuf) {
self.app_state.dirs.remove(path);
if let Some(current_dir) = &self.app_state.current_dir
&& current_dir == path
{
self.app_state.current_dir = None;
self.app_state.files.clear();
}
self.config.dirs = self.app_state.dirs.clone();
self.config.save_to_file().ok();
}
pub fn open_dir(&mut self, path: &PathBuf) { pub fn open_dir(&mut self, path: &PathBuf) {
self.app_state.current_dir = Some(path.clone()); self.app_state.current_dir = Some(path.clone());
self.app_state.files = path match path.read_dir() {
.read_dir() Ok(read_dir) => {
.unwrap() self.app_state.listed_files = read_dir
.filter_map(|res| res.ok()) .filter_map(|res| res.ok())
.map(|entry| entry.path()) .map(|entry| entry.path())
.collect(); .collect();
} }
Err(e) => {
eprintln!("Failed to read directory {:?}: {}", path, e);
self.app_state.listed_files.clear();
}
}
}
pub fn play_file(&mut self, path: &PathBuf, concurrent: bool) { pub fn play_file(&mut self, path: &Path, concurrent: bool) {
make_request_async(Request::play(path.to_str().unwrap(), concurrent)); make_request_async(Request::play(&path.to_string_lossy(), concurrent));
} }
pub fn set_input(&mut self, name: String) { pub fn set_input(&mut self, name: String) {
@@ -145,9 +152,109 @@ impl SoundpadGui {
pub fn stop(&mut self, id: Option<u32>) { pub fn stop(&mut self, id: Option<u32>) {
make_request_async(Request::stop(id)); make_request_async(Request::stop(id));
} }
pub fn play_hotkey_slot(&mut self, slot: &str) {
make_request_async(Request::play_hotkey(slot));
}
pub fn get_filtered_files(&self) -> Vec<PathBuf> {
let mut files: Vec<PathBuf> = self.app_state.listed_files.iter().cloned().collect();
files.sort_by(|a, b| {
let a_is_dir = a.is_dir();
let b_is_dir = b.is_dir();
if a_is_dir && !b_is_dir {
Ordering::Less
} else if !a_is_dir && b_is_dir {
Ordering::Greater
} else {
a.cmp(b)
}
});
let search_query = self.app_state.search_query.to_lowercase();
let search_query = search_query.trim();
files
.into_iter()
.filter(|entry_path| {
if entry_path.is_dir() {
return true;
}
if !SUPPORTED_EXTENSIONS.contains(
&entry_path
.extension()
.unwrap_or_default()
.to_str()
.unwrap_or_default(),
) {
return false;
}
if !search_query.is_empty() {
let file_name = entry_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
if !file_name.to_lowercase().contains(search_query) {
return false;
}
}
true
})
.collect()
}
} }
pub async fn run() -> Result<(), Box<dyn Error>> { fn add_font(font_name: &str, font_bytes: &[u8], fonts: &mut FontDefinitions) -> Result<()> {
let font_data = FontData::from_owned(font_bytes.to_vec()).tweak(FontTweak {
scale: 1.0,
hinting_override: Some(true),
..Default::default()
});
fonts
.font_data
.insert(font_name.to_owned(), font_data.into());
fonts
.families
.entry(FontFamily::Proportional)
.or_default()
.insert(0, font_name.to_owned());
fonts
.families
.entry(FontFamily::Monospace)
.or_default()
.insert(0, font_name.to_owned());
Ok(())
}
fn load_system_fonts(fonts: &mut FontDefinitions) -> Result<()> {
let (_, en_sans) = find_for_locale("en", FontStyle::Sans);
let (_, en_serif) = find_for_locale("en", FontStyle::Serif);
let (_, ja_sans) = find_for_locale("ja", FontStyle::Sans);
let (_, ar_sans) = find_for_locale("ar", FontStyle::Sans);
let system_fonts = [en_sans, en_serif, ja_sans, ar_sans].concat();
for font in system_fonts.iter().rev() {
let font_bytes = match &font.source {
FoundFontSource::Path(path) => fs::read(path)?,
FoundFontSource::Bytes(bytes) => bytes.to_vec(),
};
add_font(&font.key, &font_bytes, fonts)?;
}
Ok(())
}
pub async fn run() -> Result<()> {
const ICON: &[u8] = include_bytes!("../../assets/icon.png"); const ICON: &[u8] = include_bytes!("../../assets/icon.png");
let options = NativeOptions { let options = NativeOptions {
@@ -169,6 +276,11 @@ pub async fn run() -> Result<(), Box<dyn Error>> {
options, options,
Box::new(|cc| { Box::new(|cc| {
egui_material_icons::initialize(&cc.egui_ctx); egui_material_icons::initialize(&cc.egui_ctx);
let mut fonts = FontDefinitions::default();
load_system_fonts(&mut fonts).ok();
cc.egui_ctx.set_fonts(fonts);
Ok(Box::new(SoundpadGui::new(&cc.egui_ctx))) Ok(Box::new(SoundpadGui::new(&cc.egui_ctx)))
}), }),
) { ) {
@@ -179,6 +291,6 @@ pub async fn run() -> Result<(), Box<dyn Error>> {
} }
Ok(()) Ok(())
} }
Err(e) => Err(e.into()), Err(e) => Err(anyhow!(e.to_string())),
} }
} }
+64 -13
View File
@@ -1,14 +1,49 @@
use crate::gui::SoundpadGui; use crate::gui::SoundpadGui;
use eframe::{App, Frame as EFrame}; use eframe::{App, Frame as EFrame};
use egui::{CentralPanel, Context}; use egui::{CentralPanel, Context, ThemePreference};
use pwsp::{ use pwsp::{
types::socket::Request, types::{config::PreferredTheme, socket::Request},
utils::{daemon::get_daemon_config, gui::make_request_async}, utils::{daemon::get_daemon_config, gui::make_request_async},
}; };
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
impl App for SoundpadGui { impl App for SoundpadGui {
fn update(&mut self, ctx: &Context, _frame: &mut EFrame) { fn logic(&mut self, ctx: &Context, _frame: &mut EFrame) {
// Update theme
let current_theme = match ctx.options(|r| r.theme_preference) {
ThemePreference::System => PreferredTheme::System,
ThemePreference::Light => PreferredTheme::Light,
ThemePreference::Dark => PreferredTheme::Dark,
};
if !self.config.preferred_theme.eq(&current_theme) {
ctx.options_mut(|w| {
w.theme_preference = match self.config.preferred_theme {
PreferredTheme::System => ThemePreference::System,
PreferredTheme::Light => ThemePreference::Light,
PreferredTheme::Dark => ThemePreference::Dark,
}
})
}
// Remove directories
for path in self.app_state.dirs_to_remove.drain() {
self.app_state.dirs.retain(|x| x != &path);
if let Some(current_dir) = &self.app_state.current_dir
&& current_dir == &path
{
self.app_state.current_dir = None;
self.app_state.listed_files.clear();
}
}
// Save directories if changed
if !self.config.dirs.eq(&self.app_state.dirs) {
self.config.dirs = self.app_state.dirs.clone();
self.config.save_to_file().ok();
}
// Seek and volume requests
let mut seek_requests = vec![]; let mut seek_requests = vec![];
let mut volume_requests = vec![]; let mut volume_requests = vec![];
@@ -57,11 +92,19 @@ impl App for SoundpadGui {
} }
} }
// Sync audio player state
{ {
let guard = self.audio_player_state_shared.lock().unwrap(); let mut guard = self
.audio_player_state_shared
.lock()
.unwrap_or_else(|e| e.into_inner());
if let Some(config) = guard.hotkey_config.take() {
self.app_state.hotkey_config = config;
}
self.audio_player_state = guard.clone(); self.audio_player_state = guard.clone();
} }
// Handle scale factor changes
let old_scale_factor = self.config.scale_factor; let old_scale_factor = self.config.scale_factor;
let new_scale_factor = ctx.zoom_factor().clamp(0.5, 2.0); let new_scale_factor = ctx.zoom_factor().clamp(0.5, 2.0);
@@ -72,29 +115,37 @@ impl App for SoundpadGui {
self.config.save_to_file().ok(); self.config.save_to_file().ok();
} }
// Handle input
self.handle_input(ctx); self.handle_input(ctx);
}
CentralPanel::default().show(ctx, |ui| { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut EFrame) {
// Draw UI
CentralPanel::default().show_inside(ui, |ui| {
if !self.audio_player_state.is_daemon_running { if !self.audio_player_state.is_daemon_running {
self.draw_waiting_for_daemon(ui); self.draw_waiting_for_daemon(ui);
return; return;
} }
if self.app_state.hotkey_capture_active {
self.draw_hotkey_capture(ui);
return;
}
if self.app_state.show_settings { if self.app_state.show_settings {
self.draw_settings(ui); self.draw_settings(ui);
return; return;
} }
self.draw(ui).ok(); if self.app_state.show_hotkeys {
self.draw_hotkeys(ui);
if let Some(force_focus_id) = self.app_state.force_focus_id { return;
ui.memory_mut(|reder| {
reder.request_focus(force_focus_id);
});
self.app_state.force_focus_id = None;
} }
self.draw(ui);
}); });
ctx.request_repaint_after_secs(1.0 / 60.0); // Request repaint
ui.request_repaint_after_secs(1.0 / 60.0);
} }
} }
+432
View File
@@ -0,0 +1,432 @@
use crate::gui::SoundpadGui;
use egui::{
Align, AtomExt, Button, CollapsingHeader, Color32, CursorIcon, Layout, RichText, ScrollArea,
Sense, TextEdit, Ui, Vec2,
};
use egui_dnd::dnd;
use egui_material_icons::icons::*;
use pwsp::types::{gui::AppState, gui::AudioPlayerState};
use rust_i18n::t;
use std::{cmp::Ordering, path::Path, path::PathBuf};
pub(crate) enum FileAction {
Play(PathBuf, bool),
StopAndPlay(u32, PathBuf, bool),
AssignHotkey(PathBuf),
}
impl SoundpadGui {
pub fn draw_body(&mut self, ui: &mut Ui) {
let left_panel_width = self
.config
.left_panel_width
.max(100.0)
.min(ui.available_width() - 100.0);
let dirs_size = Vec2::new(left_panel_width, ui.available_height() - 40.0);
ui.horizontal(|ui| {
self.draw_dirs(ui, dirs_size);
let (rect, response) = ui.allocate_at_least(
Vec2::new(ui.spacing().item_spacing.x, ui.available_height()),
Sense::click_and_drag(),
);
if ui.is_rect_visible(rect) {
let stroke = ui.visuals().widgets.noninteractive.bg_stroke;
ui.painter().vline(rect.center().x, rect.y_range(), stroke);
}
let vertical_separator_response =
response.on_hover_and_drag_cursor(CursorIcon::ResizeHorizontal);
if vertical_separator_response.dragged() {
self.config.left_panel_width += vertical_separator_response.drag_delta().x;
self.config.left_panel_width = self.config.left_panel_width.clamp(100.0, 500.0);
}
if vertical_separator_response.drag_stopped() {
self.config.save_to_file().ok();
}
let files_size = Vec2::new(ui.available_width(), ui.available_height() - 40.0);
self.draw_files(ui, files_size);
});
}
fn draw_dirs(&mut self, ui: &mut Ui, area_size: Vec2) {
ui.vertical(|ui| {
ui.set_min_width(area_size.x);
ui.set_min_height(area_size.y);
ScrollArea::vertical().id_salt(0).show(ui, |ui| {
ui.set_min_width(area_size.x);
let mut dirs = std::mem::take(&mut self.app_state.dirs);
let mut dir_to_open = None;
dnd(ui, "dnd_directories").show_vec(&mut dirs, |ui, item, handle, _state| {
let path = item;
ui.horizontal(|ui| {
handle.ui(ui, |ui| {
ui.label(ICON_DRAG_INDICATOR.codepoint);
});
let name = path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| path.to_string_lossy().to_string());
let mut dir_button =
Button::new(RichText::new(name.clone()).atom_max_width(area_size.x))
.frame(false);
if let Some(current_dir) = &self.app_state.current_dir
&& current_dir.eq(&*path)
{
dir_button = dir_button.selected(true);
}
let dir_button_response = ui.add(dir_button);
if dir_button_response.clicked() {
dir_to_open = Some(path.clone());
}
let delete_dir_button = Button::new(ICON_DELETE).frame(false);
let delete_dir_button_response =
ui.add_sized([18.0, 18.0], delete_dir_button);
if delete_dir_button_response.clicked() {
self.app_state.dirs_to_remove.insert(path.clone());
}
// Context menu
dir_button_response.context_menu(|ui| {
if ui
.button(format!(
"{} {}",
ICON_OPEN_IN_NEW.codepoint,
t!("gui.context.dirs.open")
))
.clicked()
{
dir_to_open = Some(path.clone());
}
if ui
.button(format!(
"{} {}",
ICON_OPEN_IN_BROWSER.codepoint,
t!("gui.context.dirs.open_in_fm")
))
.clicked()
&& let Err(e) = opener::open(&path)
{
eprintln!("Failed to open file manager: {}", e);
}
ui.separator();
if ui
.button(format!(
"{} {}",
ICON_DELETE.codepoint,
t!("gui.context.dirs.remove")
))
.clicked()
{
self.app_state.dirs_to_remove.insert(path.clone());
}
});
});
});
self.app_state.dirs = dirs;
if let Some(path) = dir_to_open {
self.open_dir(&path);
}
ui.horizontal(|ui| {
let add_dirs_button = Button::new(ICON_ADD).frame(false);
let add_dirs_button_response = ui.add_sized([18.0, 18.0], add_dirs_button);
if add_dirs_button_response.clicked() {
self.add_dirs();
}
});
ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
let play_file_button = Button::new(t!("gui.play_file_button"));
let play_file_button_response = ui.add(play_file_button);
if play_file_button_response.clicked() {
self.open_file();
}
});
});
});
}
fn draw_files_search_field(&mut self, ui: &mut Ui) {
ui.horizontal(|ui| {
let search_field_response = ui.add_sized(
[ui.available_width(), 22.0],
TextEdit::singleline(&mut self.app_state.search_query)
.hint_text(t!("gui.search_placeholder")),
);
if self.app_state.force_focus_search {
search_field_response.request_focus();
self.app_state.force_focus_search = false;
}
self.app_state.search_field_id = Some(search_field_response.id);
});
}
fn draw_files_list(&mut self, ui: &mut Ui, area_size: Vec2) {
ScrollArea::vertical().id_salt(1).show(ui, |ui| {
ui.set_min_width(area_size.x);
ui.set_min_height(area_size.y);
ui.vertical(|ui| {
let mut actions = Vec::new();
let files = self.get_filtered_files();
for entry_path in files {
Self::draw_tree_node(
ui,
entry_path,
&mut self.app_state,
&self.audio_player_state,
&mut actions,
);
}
for action in actions {
match action {
FileAction::Play(path, concurrent) => self.play_file(&path, concurrent),
FileAction::StopAndPlay(id, path, concurrent) => {
self.stop(Some(id));
self.play_file(&path, concurrent);
}
FileAction::AssignHotkey(path) => {
self.app_state.assigning_hotkey_for_file = Some(path);
self.app_state.hotkey_capture_active = true;
}
}
}
});
});
}
fn draw_files(&mut self, ui: &mut Ui, area_size: Vec2) {
ui.vertical(|ui| {
self.draw_files_search_field(ui);
ui.separator();
self.draw_files_list(ui, area_size);
});
}
fn draw_tree_node_dir(
ui: &mut Ui,
path: std::path::PathBuf,
app_state: &mut AppState,
audio_player_state: &AudioPlayerState,
actions: &mut Vec<FileAction>,
) {
let dir_name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
CollapsingHeader::new(dir_name)
.id_salt(&path)
.show(ui, |ui| {
let children = if let Some(cached) = app_state.dir_cache.get(&path) {
cached.clone()
} else {
let mut read = Vec::new();
if let Ok(entries) = std::fs::read_dir(&path) {
for entry in entries.filter_map(|e| e.ok()) {
read.push(entry.path());
}
}
read.sort_by(|a, b| {
let a_is_dir = a.is_dir();
let b_is_dir = b.is_dir();
if a_is_dir && !b_is_dir {
Ordering::Less
} else if !a_is_dir && b_is_dir {
Ordering::Greater
} else {
a.cmp(b)
}
});
app_state.dir_cache.insert(path.clone(), read.clone());
read
};
let search_query = app_state.search_query.to_lowercase();
let search_query = search_query.trim();
for child in children {
if !child.is_dir() {
if !crate::gui::SUPPORTED_EXTENSIONS.contains(
&child
.extension()
.unwrap_or_default()
.to_str()
.unwrap_or_default(),
) {
continue;
}
if !search_query.is_empty() {
let file_name = child
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
if !file_name.to_lowercase().contains(search_query) {
continue;
}
}
}
Self::draw_tree_node(ui, child, app_state, audio_player_state, actions);
}
});
}
fn draw_tree_node_file(
ui: &mut Ui,
path: std::path::PathBuf,
app_state: &mut AppState,
audio_player_state: &AudioPlayerState,
actions: &mut Vec<FileAction>,
) {
let file_name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
ui.horizontal(|ui| {
// Hotkey badge
let mut hotkey_badge = None;
for slot in &app_state.hotkey_config.slots {
if slot.action.name == "play"
&& let Some(file_path_str) = slot.action.args.get("file_path")
&& Path::new(file_path_str) == path
{
if let Some(chord) = &slot.key_chord {
hotkey_badge = Some(format!("[{}]", chord));
} else {
hotkey_badge = Some(format!("[{}]", slot.slot));
}
break;
}
}
if let Some(badge) = &hotkey_badge {
ui.label(
RichText::new(badge)
.small()
.monospace()
.color(Color32::from_rgb(100, 200, 100)),
);
}
let file_button_text = RichText::new(&file_name);
let file_button = Button::new(file_button_text).frame(false).truncate();
let file_button_response = ui.add(file_button);
if file_button_response.clicked() {
ui.input(|i| {
if i.modifiers.ctrl {
actions.push(FileAction::Play(path.clone(), true));
} else if i.modifiers.shift
&& let Some(last_track) = audio_player_state.tracks.last()
{
actions.push(FileAction::StopAndPlay(last_track.id, path.clone(), true));
} else {
actions.push(FileAction::Play(path.clone(), false));
}
});
}
// Context menu
file_button_response.context_menu(|ui| {
if ui
.button(format!(
"{} {}",
ICON_BOLT.codepoint,
t!("gui.context.files.play_solo")
))
.clicked()
{
actions.push(FileAction::Play(path.clone(), false));
}
if ui
.button(format!(
"{} {}",
ICON_ADD.codepoint,
t!("gui.context.files.add_new")
))
.clicked()
{
actions.push(FileAction::Play(path.clone(), true));
}
if ui
.button(format!(
"{} {}",
ICON_SWAP_HORIZ.codepoint,
t!("gui.context.files.replace_last")
))
.clicked()
&& let Some(last_track) = audio_player_state.tracks.last()
{
actions.push(FileAction::StopAndPlay(last_track.id, path.clone(), true));
}
ui.separator();
if ui
.button(format!(
"{} {}",
ICON_OPEN_IN_BROWSER.codepoint,
t!("gui.context.files.show_in_fm")
))
.clicked()
&& let Err(e) = opener::reveal(&path)
{
eprintln!("Failed to open file manager: {}", e);
}
ui.separator();
if ui
.button(format!(
"{} {}",
ICON_KEYBOARD.codepoint,
t!("gui.context.files.asign_hotkey")
))
.clicked()
{
actions.push(FileAction::AssignHotkey(path.clone()));
ui.close();
}
});
});
}
fn draw_tree_node(
ui: &mut Ui,
path: std::path::PathBuf,
app_state: &mut AppState,
audio_player_state: &AudioPlayerState,
actions: &mut Vec<FileAction>,
) {
if path.is_dir() {
Self::draw_tree_node_dir(ui, path, app_state, audio_player_state, actions);
} else {
Self::draw_tree_node_file(ui, path, app_state, audio_player_state, actions);
}
}
}
+92
View File
@@ -0,0 +1,92 @@
use crate::gui::SoundpadGui;
use egui::{AtomExt, Button, ComboBox, Label, RichText, Slider, Ui, Vec2};
use egui_material_icons::icons::*;
use rust_i18n::t;
use std::time::Instant;
impl SoundpadGui {
pub fn draw_footer(&mut self, ui: &mut Ui) {
ui.add_space(5.0);
ui.horizontal(|ui| {
self.draw_mic_selection(ui);
self.draw_master_volume(ui);
ui.add_space(ui.available_width() - 18.0 * 2.0 - ui.spacing().item_spacing.x * 2.0);
self.draw_hotkeys_button(ui);
self.draw_settings_button(ui);
});
}
fn draw_mic_selection(&mut self, ui: &mut Ui) {
let mics = &self.audio_player_state.all_inputs_sorted;
let mut selected_input = self.audio_player_state.current_input.to_owned();
let prev_input = selected_input.to_owned();
ComboBox::from_label(t!("gui.choose_mic_select"))
.height(30.0)
.selected_text(
self.audio_player_state
.all_inputs
.get(&selected_input)
.unwrap_or(&String::new()),
)
.show_ui(ui, |ui| {
for (name, nick) in mics {
ui.selectable_value(&mut selected_input, name.clone(), nick);
}
});
if selected_input != prev_input {
self.set_input(selected_input);
}
}
fn draw_master_volume(&mut self, ui: &mut Ui) {
let volume_icon = Self::get_volume_icon(self.audio_player_state.volume);
let volume_label = Label::new(RichText::new(volume_icon).size(18.0));
ui.add_sized([18.0, 18.0], volume_label)
.on_hover_text(format!(
"Master Volume: {:.0}%",
self.audio_player_state.volume * 100.0
));
let should_update_volume = !self.app_state.volume_dragged
&& self
.app_state
.ignore_volume_update_until
.map(|t| Instant::now() > t)
.unwrap_or(true);
if should_update_volume {
self.app_state.volume_slider_value = self.audio_player_state.volume;
}
let volume_slider = Slider::new(&mut self.app_state.volume_slider_value, 0.0..=1.0)
.show_value(false)
.step_by(0.01);
let volume_slider_response = ui.add_sized([150.0, 18.0], volume_slider);
if volume_slider_response.drag_stopped() {
self.app_state.volume_dragged = true;
}
}
fn draw_hotkeys_button(&mut self, ui: &mut Ui) {
let hotkeys_button =
Button::new(ICON_KEYBOARD.atom_size(Vec2::new(18.0, 18.0))).frame(false);
let hotkeys_button_response = ui.add_sized([18.0, 18.0], hotkeys_button);
if hotkeys_button_response.clicked() {
self.app_state.show_hotkeys = true;
}
hotkeys_button_response.on_hover_text("Hotkeys (H)");
}
fn draw_settings_button(&mut self, ui: &mut Ui) {
let settings_button =
Button::new(ICON_SETTINGS.atom_size(Vec2::new(18.0, 18.0))).frame(false);
let settings_button_response = ui.add_sized([18.0, 18.0], settings_button);
if settings_button_response.clicked() {
self.app_state.show_settings = true;
}
}
}
+195
View File
@@ -0,0 +1,195 @@
use crate::gui::SoundpadGui;
use egui::{Button, CollapsingHeader, FontFamily, Label, RichText, Slider, Ui};
use egui_material_icons::icons::*;
use pwsp::types::{audio_player::TrackInfo, gui::AppState};
use pwsp::utils::gui::format_time_pair;
use std::time::Instant;
pub(crate) enum TrackAction {
Pause(u32),
Resume(u32),
ToggleLoop(u32),
Stop(u32),
}
impl SoundpadGui {
pub fn draw_header(&mut self, ui: &mut Ui) {
ui.vertical_centered_justified(|ui| {
if self.audio_player_state.tracks.is_empty() {
ui.label("No tracks playing");
return;
}
let mut action = None;
for track in &self.audio_player_state.tracks {
CollapsingHeader::new(
RichText::new(
track
.path
.file_stem()
.unwrap_or_default()
.to_str()
.unwrap_or_default(),
)
.family(FontFamily::Monospace),
)
.default_open(true)
.show(ui, |ui| {
if let Some(act) = Self::draw_track_control(ui, &mut self.app_state, track) {
action = Some(act);
}
});
ui.separator();
}
if let Some(action) = action {
match action {
TrackAction::Pause(id) => self.pause(Some(id)),
TrackAction::Resume(id) => self.resume(Some(id)),
TrackAction::ToggleLoop(id) => self.toggle_loop(Some(id)),
TrackAction::Stop(id) => self.stop(Some(id)),
}
}
});
}
fn draw_playback_controls(ui: &mut Ui, track: &TrackInfo) -> Option<TrackAction> {
let mut action = None;
let play_button = Button::new(if track.paused {
ICON_PLAY_ARROW
} else {
ICON_PAUSE
})
.corner_radius(15.0);
if ui.add_sized([30.0, 30.0], play_button).clicked() {
action = Some(if track.paused {
TrackAction::Resume(track.id)
} else {
TrackAction::Pause(track.id)
});
}
let loop_button = Button::new(
RichText::new(if track.looped {
ICON_REPEAT_ONE
} else {
ICON_REPEAT
})
.size(18.0),
)
.frame(false);
if ui.add_sized([15.0, 30.0], loop_button).clicked() {
action = Some(TrackAction::ToggleLoop(track.id));
}
action
}
fn draw_position_control(
ui: &mut Ui,
ui_state: &mut pwsp::types::gui::TrackUiState,
track: &TrackInfo,
default_slider_width: f32,
) {
let duration = track.duration.unwrap_or(1.0);
let position_slider = Slider::new(&mut ui_state.position_slider_value, 0.0..=duration)
.show_value(false)
.step_by(0.01);
let position_slider_width = ui.available_width()
- (30.0 * 3.0)
- default_slider_width
- (ui.spacing().item_spacing.x * 6.0);
ui.spacing_mut().slider_width = position_slider_width;
if ui.add_sized([30.0, 30.0], position_slider).drag_stopped() {
ui_state.position_dragged = true;
}
let time_label =
Label::new(RichText::new(format_time_pair(track.position, duration)).monospace());
ui.add_sized([30.0, 30.0], time_label);
}
fn draw_volume_control(
ui: &mut Ui,
ui_state: &mut pwsp::types::gui::TrackUiState,
track: &TrackInfo,
default_slider_width: f32,
) {
let volume_icon = Self::get_volume_icon(track.volume);
let volume_label = Label::new(RichText::new(volume_icon).size(18.0));
ui.add_sized([30.0, 30.0], volume_label)
.on_hover_text(format!("Volume: {:.0}%", track.volume * 100.0));
let volume_slider = Slider::new(&mut ui_state.volume_slider_value, 0.0..=1.0)
.show_value(false)
.step_by(0.01);
ui.spacing_mut().slider_width = default_slider_width - 30.0;
ui.spacing_mut().item_spacing.x = 0.0;
if ui.add_sized([30.0, 30.0], volume_slider).drag_stopped() {
ui_state.volume_dragged = true;
}
}
fn draw_stop_control(ui: &mut Ui, track: &TrackInfo) -> Option<TrackAction> {
let stop_button = Button::new(ICON_CLOSE).frame(false);
if ui.add_sized([30.0, 30.0], stop_button).clicked() {
Some(TrackAction::Stop(track.id))
} else {
None
}
}
fn draw_track_control(
ui: &mut Ui,
app_state: &mut AppState,
track: &TrackInfo,
) -> Option<TrackAction> {
let ui_state = app_state.track_ui_states.entry(track.id).or_default();
let should_update_position = !ui_state.position_dragged
&& ui_state
.ignore_position_update_until
.map(|t| Instant::now() > t)
.unwrap_or(true);
if should_update_position {
ui_state.position_slider_value = track.position;
}
let should_update_volume = !ui_state.volume_dragged
&& ui_state
.ignore_volume_update_until
.map(|t| Instant::now() > t)
.unwrap_or(true);
if should_update_volume {
ui_state.volume_slider_value = track.volume;
}
let mut action = None;
ui.horizontal_top(|ui| {
if let Some(act) = Self::draw_playback_controls(ui, track) {
action = Some(act);
}
let default_slider_width = ui.spacing().slider_width;
Self::draw_position_control(ui, ui_state, track, default_slider_width);
Self::draw_volume_control(ui, ui_state, track, default_slider_width);
if let Some(act) = Self::draw_stop_control(ui, track) {
action = Some(act);
}
});
action
}
}
+32
View File
@@ -0,0 +1,32 @@
use crate::gui::SoundpadGui;
use egui::{Color32, RichText, Ui};
use rust_i18n::t;
impl SoundpadGui {
pub fn draw_hotkey_capture(&mut self, ui: &mut Ui) {
ui.vertical_centered(|ui| {
ui.add_space(ui.available_height() / 3.0);
ui.label(
RichText::new(t!("gui.hotkeys.capture.header"))
.size(18.0)
.color(Color32::YELLOW)
.monospace(),
);
ui.add_space(10.0);
let target = if let Some(slot) = &self.app_state.assigning_hotkey_slot {
format!("{} '{}'", t!("gui.hotkeys.capture.for"), slot)
} else if let Some(path) = &self.app_state.assigning_hotkey_for_file {
format!(
"{} '{}'",
t!("gui.hotkeys.capture.for"),
path.file_name().unwrap_or_default().to_string_lossy()
)
} else {
String::new()
};
ui.label(RichText::new(target).size(16.0));
ui.add_space(10.0);
ui.label(t!("gui.hotkeys.capture.cancel"));
});
}
}
+304
View File
@@ -0,0 +1,304 @@
use crate::gui::SoundpadGui;
use egui::{Button, Color32, Label, RichText, TextEdit, Ui};
use egui_extras::{Column, TableBuilder};
use egui_material_icons::icons::*;
use pwsp::types::socket::Request;
use pwsp::utils::gui::make_request_async;
use rust_i18n::t;
use std::path::Path;
pub(crate) enum HotkeyAction {
Remove(String),
Capture(String),
ClearChord(String),
Play(String),
}
impl SoundpadGui {
pub fn draw_hotkeys(&mut self, ui: &mut Ui) {
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 5.0;
self.draw_hotkeys_header(ui);
ui.separator();
self.draw_hotkeys_search(ui);
ui.separator();
ui.add_space(5.0);
let action = self.draw_hotkeys_table(ui);
if let Some(action) = action {
self.handle_hotkey_action(action);
}
});
}
fn draw_hotkeys_header(&mut self, ui: &mut Ui) {
ui.horizontal(|ui| {
let back_button = Button::new(ICON_ARROW_BACK).frame(false);
if ui.add(back_button).clicked() {
self.app_state.show_hotkeys = false;
}
ui.vertical_centered(|ui| {
ui.label(
RichText::new(t!("gui.hotkeys.header"))
.color(Color32::WHITE)
.monospace(),
);
});
});
}
fn draw_hotkeys_search(&mut self, ui: &mut Ui) {
ui.horizontal(|ui| {
ui.menu_button(
format!(
"{} {}",
ICON_ADD.codepoint,
t!("gui.hotkeys.add_command_select")
),
|ui| {
let mut selected_cmd = None;
if ui.button(t!("gui.hotkeys.toggle_pause_command")).clicked() {
selected_cmd = Some(("cmd_toggle_pause", Request::toggle_pause(None)));
}
if ui.button(t!("gui.hotkeys.stop_playback_command")).clicked() {
selected_cmd = Some(("cmd_stop", Request::stop(None)));
}
if ui
.button(t!("gui.hotkeys.pause_playback_command"))
.clicked()
{
selected_cmd = Some(("cmd_pause", Request::pause(None)));
}
if ui
.button(t!("gui.hotkeys.resume_playback_command"))
.clicked()
{
selected_cmd = Some(("cmd_resume", Request::resume(None)));
}
if ui.button(t!("gui.hotkeys.toggle_loop_command")).clicked() {
selected_cmd = Some(("cmd_toggle_loop", Request::toggle_loop(None)));
}
if let Some((slot_name, req)) = selected_cmd {
make_request_async(Request::set_hotkey_action(slot_name, &req));
self.app_state
.hotkey_config
.set_slot(slot_name.to_string(), req);
self.app_state.assigning_hotkey_slot = Some(slot_name.to_string());
self.app_state.hotkey_capture_active = true;
ui.close();
}
},
);
ui.add_space(10.0);
ui.add(
TextEdit::singleline(&mut self.app_state.hotkey_search_query)
.hint_text(t!("gui.hotkeys.search_placeholder"))
.desired_width(f32::INFINITY),
);
});
}
fn draw_hotkeys_table(&mut self, ui: &mut Ui) -> Option<HotkeyAction> {
let conflicts = self.app_state.hotkey_config.find_conflicts();
let conflict_slots: std::collections::HashSet<&str> =
conflicts.into_iter().flat_map(|(a, b)| [a, b]).collect();
let search = self.app_state.hotkey_search_query.to_lowercase();
let mut action: Option<HotkeyAction> = None;
let slots: Vec<_> = self
.app_state
.hotkey_config
.slots
.iter()
.filter(|s| {
if search.is_empty() {
return true;
}
s.slot.to_lowercase().contains(&search)
|| format!("{:?}", s.action).to_lowercase().contains(&search)
|| s.key_chord
.as_deref()
.unwrap_or("")
.to_lowercase()
.contains(&search)
})
.collect();
let available_width = ui.available_width();
let col_width = (available_width / 4.0).max(80.0);
TableBuilder::new(ui)
.striped(true)
.column(Column::exact(col_width).clip(true)) // Slot
.column(Column::exact(col_width).clip(true)) // Sound / Action name
.column(Column::exact(col_width).clip(true)) // Key Chord
.column(Column::exact(col_width).clip(true)) // Actions
.header(30.0, |mut header| {
header.col(|ui| {
ui.label(
RichText::new(t!("gui.hotkeys.column_slot"))
.strong()
.monospace(),
);
});
header.col(|ui| {
ui.label(
RichText::new(t!("gui.hotkeys.column_sound"))
.strong()
.monospace(),
);
});
header.col(|ui| {
ui.label(
RichText::new(t!("gui.hotkeys.column_key_chord"))
.strong()
.monospace(),
);
});
header.col(|ui| {
ui.label(
RichText::new(t!("gui.hotkeys.column_actions"))
.strong()
.monospace(),
);
});
})
.body(|mut body| {
if slots.is_empty() {
body.row(30.0, |mut row| {
row.col(|_| {});
row.col(|ui| {
ui.label(RichText::new(t!("gui.hotkeys.no_hotkeys_configured")));
});
row.col(|_| {});
row.col(|_| {});
});
return;
}
for slot in &slots {
body.row(30.0, |mut row| {
// Column 1: Slot
row.col(|ui| {
ui.horizontal(|ui| {
if conflict_slots.contains(slot.slot.as_str()) {
ui.label(
RichText::new(ICON_WARNING.codepoint)
.color(Color32::from_rgb(255, 165, 0)),
)
.on_hover_text("Key chord conflict");
}
ui.add(
Label::new(RichText::new(&slot.slot).monospace()).truncate(),
);
});
});
// Column 2: Sound / Action name
row.col(|ui| {
let action_name = match slot.action.name.as_str() {
"play" => {
if let Some(file_path_str) = slot.action.args.get("file_path") {
Path::new(file_path_str)
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string()
} else {
"Play".to_string()
}
}
"toggle_pause" => "Toggle Pause".to_string(),
"pause" => "Pause Playback".to_string(),
"resume" => "Resume Playback".to_string(),
"stop" => "Stop Playback".to_string(),
"toggle_loop" => "Toggle Loop".to_string(),
other => other.to_string(),
};
ui.add(Label::new(RichText::new(action_name).monospace()).truncate());
});
// Column 3: Key Chord
row.col(|ui| {
let chord_text = slot.key_chord.as_deref().unwrap_or("(none)");
ui.add(
Label::new(RichText::new(chord_text).monospace().color(
if slot.key_chord.is_some() {
Color32::from_rgb(100, 200, 100)
} else {
Color32::GRAY
},
))
.truncate(),
);
});
// Column 4: Actions
row.col(|ui| {
ui.horizontal(|ui| {
if ui
.add(Button::new(ICON_DELETE).frame(false))
.on_hover_text("Remove slot")
.clicked()
{
action = Some(HotkeyAction::Remove(slot.slot.clone()));
}
if ui
.add(Button::new(ICON_KEYBOARD).frame(false))
.on_hover_text("Set key chord")
.clicked()
{
action = Some(HotkeyAction::Capture(slot.slot.clone()));
}
if slot.key_chord.is_some()
&& ui
.add(Button::new(ICON_BACKSPACE).frame(false))
.on_hover_text("Clear key chord")
.clicked()
{
action = Some(HotkeyAction::ClearChord(slot.slot.clone()));
}
if ui
.add(Button::new(ICON_PLAY_ARROW).frame(false))
.on_hover_text("Play")
.clicked()
{
action = Some(HotkeyAction::Play(slot.slot.clone()));
}
});
});
});
}
});
action
}
fn handle_hotkey_action(&mut self, action: HotkeyAction) {
match action {
HotkeyAction::Remove(slot) => {
make_request_async(Request::clear_hotkey(&slot));
self.app_state.hotkey_config.remove_slot(&slot);
}
HotkeyAction::Capture(slot) => {
self.app_state.assigning_hotkey_slot = Some(slot);
self.app_state.hotkey_capture_active = true;
}
HotkeyAction::ClearChord(slot) => {
make_request_async(Request::clear_hotkey_key(&slot));
self.app_state.hotkey_config.set_key_chord(&slot, None);
}
HotkeyAction::Play(slot) => {
self.play_hotkey_slot(&slot);
}
}
}
}
+32
View File
@@ -0,0 +1,32 @@
use crate::gui::SoundpadGui;
use egui::Ui;
use egui_material_icons::icons::*;
mod body;
mod footer;
mod header;
mod hotkey_capture;
mod hotkeys;
mod settings;
mod waiting_for_daemon;
impl SoundpadGui {
pub(crate) fn get_volume_icon(volume: f32) -> &'static str {
if volume > 0.7 {
ICON_VOLUME_UP.codepoint
} else if volume <= 0.0 {
ICON_VOLUME_OFF.codepoint
} else if volume < 0.3 {
ICON_VOLUME_MUTE.codepoint
} else {
ICON_VOLUME_DOWN.codepoint
}
}
pub fn draw(&mut self, ui: &mut Ui) {
self.draw_header(ui);
self.draw_body(ui);
ui.separator();
self.draw_footer(ui);
}
}
+99
View File
@@ -0,0 +1,99 @@
use crate::gui::SoundpadGui;
use egui::{Align, Button, Color32, ComboBox, Layout, RichText, Ui};
use egui_material_icons::icons::ICON_ARROW_BACK;
use pwsp::types::config::PreferredTheme;
use rust_i18n::t;
impl SoundpadGui {
pub fn draw_settings(&mut self, ui: &mut Ui) {
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 5.0;
// --------- Back Button and Title ----------
ui.horizontal_top(|ui| {
let back_button = Button::new(ICON_ARROW_BACK).frame(false);
let back_button_response = ui.add(back_button);
if back_button_response.clicked() {
self.app_state.show_settings = false;
}
ui.add_space(ui.available_width() / 2.0 - 40.0);
ui.label(
RichText::new(t!("gui.settings.header"))
.color(Color32::WHITE)
.monospace(),
);
});
// --------------------------------
ui.separator();
ui.add_space(20.0);
// --------- Checkboxes ----------
let save_volume_response = ui.checkbox(
&mut self.config.save_volume,
t!("gui.settings.remember_volume"),
);
let save_input_response =
ui.checkbox(&mut self.config.save_input, t!("gui.settings.remember_mic"));
let save_scale_response = ui.checkbox(
&mut self.config.save_scale_factor,
t!("gui.settings.remember_ui_scale"),
);
let pause_on_exit_response = ui.checkbox(
&mut self.config.pause_on_exit,
t!("gui.settings.pause_on_window_close"),
);
if save_volume_response.changed()
|| save_input_response.changed()
|| save_scale_response.changed()
|| pause_on_exit_response.changed()
{
self.config.save_to_file().ok();
}
// --------------------------------
ui.separator();
// ---------- Selectors -----------
let mut selected_theme = self.config.preferred_theme.clone();
ComboBox::from_label(t!("gui.settings.theme.label"))
.selected_text(match self.config.preferred_theme {
PreferredTheme::System => t!("gui.settings.theme.system"),
PreferredTheme::Light => t!("gui.settings.theme.light"),
PreferredTheme::Dark => t!("gui.settings.theme.dark"),
})
.show_ui(ui, |ui| {
ui.selectable_value(
&mut selected_theme,
PreferredTheme::System,
t!("gui.settings.theme.system"),
);
ui.selectable_value(
&mut selected_theme,
PreferredTheme::Light,
t!("gui.settings.theme.light"),
);
ui.selectable_value(
&mut selected_theme,
PreferredTheme::Dark,
t!("gui.settings.theme.dark"),
);
});
if selected_theme != self.config.preferred_theme {
self.config.preferred_theme = selected_theme;
self.config.save_to_file().ok();
}
// --------------------------------
ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
ui.label(t!(
"gui.settings.version",
version = env!("CARGO_PKG_VERSION")
));
});
});
}
}
+14
View File
@@ -0,0 +1,14 @@
use crate::gui::SoundpadGui;
use egui::{RichText, Ui};
impl SoundpadGui {
pub fn draw_waiting_for_daemon(&mut self, ui: &mut Ui) {
ui.centered_and_justified(|ui| {
ui.label(
RichText::new("Waiting for PWSP daemon to start...")
.size(34.0)
.monospace(),
);
});
}
}
+55 -3
View File
@@ -1,8 +1,60 @@
mod gui; mod gui;
use std::error::Error; use anyhow::{Context, Result};
use pwsp::utils::gui::ensure_pwsp_audio_dir;
use rust_i18n::i18n;
use std::{env, path::PathBuf};
i18n!("locales", fallback = "en");
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> { async fn main() -> Result<()> {
gui::run().await let locale = sys_locale::get_locale().unwrap_or(String::from("en-US"));
rust_i18n::set_locale(&locale);
let args = env::args().skip(1).collect::<Vec<String>>();
if let Some(uri) = args.first() {
match download_audio_from_url(uri).await {
Ok(path) => println!("Successfully downloaded to: {:?}", path),
Err(e) => eprintln!("Error downloading file: {}", e),
}
} else {
gui::run().await?;
}
Ok(())
}
async fn download_audio_from_url(uri: &str) -> Result<PathBuf> {
let prefix = "soundpad://sound/url/";
let target_url = uri
.strip_prefix(prefix)
.ok_or_else(|| anyhow::anyhow!("URI does not containt an expected prefix: {}", prefix))?;
let file_name_encoded = target_url
.split('/')
.next_back()
.unwrap_or("downloaded_audio.mp3");
let file_name = percent_encoding::percent_decode_str(file_name_encoded)
.decode_utf8()
.unwrap_or_else(|_| file_name_encoded.into())
.into_owned();
let save_path = ensure_pwsp_audio_dir().join(file_name);
let response = reqwest::get(target_url)
.await?
.error_for_status()
.context("Failed to fetch file")?;
let bytes = response.bytes().await?;
tokio::fs::write(&save_path, bytes)
.await
.context("Failed to save file to disk")?;
Ok(save_path)
} }
+189 -70
View File
@@ -1,11 +1,12 @@
use crate::{ use crate::{
types::pipewire::{AudioDevice, DeviceType, Terminate}, types::pipewire::{DeviceType, Terminate},
utils::{ utils::{
daemon::get_daemon_config, daemon::get_daemon_config,
pipewire::{create_link, get_all_devices, get_device}, pipewire::{create_link, get_device, link_player_to_virtual_mic},
}, },
}; };
use rodio::{Decoder, OutputStream, OutputStreamBuilder, Sink, Source}; use anyhow::{Result, anyhow};
use rodio::{Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{ use std::{
collections::HashMap, collections::HashMap,
@@ -34,9 +35,18 @@ pub struct TrackInfo {
pub paused: bool, pub paused: bool,
} }
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
pub struct FullState {
pub state: PlayerState,
pub tracks: Vec<TrackInfo>,
pub volume: f32,
pub current_input: String,
pub all_inputs: HashMap<String, String>,
}
pub struct PlayingSound { pub struct PlayingSound {
pub id: u32, pub id: u32,
pub sink: Sink, pub sink: Player,
pub path: PathBuf, pub path: PathBuf,
pub duration: Option<f32>, pub duration: Option<f32>,
pub looped: bool, pub looped: bool,
@@ -44,96 +54,135 @@ pub struct PlayingSound {
} }
pub struct AudioPlayer { pub struct AudioPlayer {
pub stream_handle: OutputStream, stream_handle: Option<MixerDeviceSink>,
pub tracks: HashMap<u32, PlayingSound>, pub tracks: HashMap<u32, PlayingSound>,
pub next_id: u32, pub next_id: u32,
input_link_sender: Option<pipewire::channel::Sender<Terminate>>, input_link_sender: Option<pipewire::channel::Sender<Terminate>>,
pub current_input_device: Option<AudioDevice>, player_link_sender: Option<pipewire::channel::Sender<Terminate>>,
pub input_device_name: Option<String>,
pub volume: f32, // Master volume pub volume: f32, // Master volume
} }
impl AudioPlayer { impl AudioPlayer {
pub async fn new() -> Result<Self, Box<dyn Error>> { pub async fn new() -> Result<Self> {
let daemon_config = get_daemon_config(); let daemon_config = get_daemon_config();
let default_volume = daemon_config.default_volume.unwrap_or(1.0); let default_volume = daemon_config.default_volume.unwrap_or(1.0);
let mut default_input_device: Option<AudioDevice> = None;
if let Some(name) = daemon_config.default_input_name
&& let Ok(device) = get_device(&name).await
&& device.device_type == DeviceType::Input
{
default_input_device = Some(device);
}
let stream_handle = OutputStreamBuilder::open_default_stream()?;
let mut audio_player = AudioPlayer { let mut audio_player = AudioPlayer {
stream_handle, stream_handle: None,
tracks: HashMap::new(), tracks: HashMap::new(),
next_id: 1, next_id: 1,
input_link_sender: None, input_link_sender: None,
current_input_device: default_input_device.clone(), player_link_sender: None,
input_device_name: daemon_config.default_input_name.clone(),
volume: default_volume, volume: default_volume,
}; };
if default_input_device.is_some() { if audio_player.input_device_name.is_some() {
audio_player.link_devices().await?; audio_player.link_devices().await?;
} }
Ok(audio_player) Ok(audio_player)
} }
fn ensure_stream(&mut self) -> Result<&MixerDeviceSink> {
if self.stream_handle.is_none() {
let mut sink = DeviceSinkBuilder::open_default_sink()?;
sink.log_on_drop(false);
self.stream_handle = Some(sink);
}
self.stream_handle
.as_ref()
.ok_or_else(|| anyhow!("Failed to initialize stream_handle"))
}
fn drop_stream(&mut self) {
if self.stream_handle.is_some() {
self.stream_handle = None;
self.abort_player_link_thread();
}
}
fn abort_link_thread(&mut self) { fn abort_link_thread(&mut self) {
if let Some(sender) = &self.input_link_sender { if let Some(sender) = &self.input_link_sender {
match sender.send(Terminate {}) { if sender.send(Terminate {}).is_ok() {
Ok(_) => println!("Sent terminate signal to link thread"), println!("Sent terminate signal to input link thread");
Err(_) => eprintln!("Failed to send terminate signal to link thread"), self.input_link_sender = None;
} else {
eprintln!("Failed to send terminate signal to input link thread");
} }
} }
} }
async fn link_devices(&mut self) -> Result<(), Box<dyn Error>> { fn abort_player_link_thread(&mut self) {
if let Some(sender) = &self.player_link_sender {
if sender.send(Terminate {}).is_ok() {
println!("Sent terminate signal to player link thread");
self.player_link_sender = None;
} else {
eprintln!("Failed to send terminate signal to player link thread");
}
}
}
async fn link_player(&mut self) -> Result<()> {
if self.player_link_sender.is_some() {
return Ok(());
}
match link_player_to_virtual_mic().await {
Ok(sender) => {
self.player_link_sender = Some(sender);
Ok(())
}
Err(_) => Ok(()),
}
}
async fn link_devices(&mut self) -> Result<()> {
self.abort_link_thread(); self.abort_link_thread();
if self.current_input_device.is_none() { let input_device;
if let Some(input_device_name) = &self.input_device_name {
if let Ok(device) = get_device(input_device_name).await {
input_device = device;
} else {
eprintln!(
"Could not find selected input device {}, skipping device linking",
input_device_name
);
return Ok(());
}
} else {
eprintln!("No input device selected, skipping device linking"); eprintln!("No input device selected, skipping device linking");
return Ok(()); return Ok(());
} }
let (input_devices, _) = get_all_devices().await?; let daemon_input;
if let Ok(device) = get_device("pwsp-virtual-mic").await {
let mut pwsp_daemon_input: Option<AudioDevice> = None; daemon_input = device;
for input_device in input_devices { } else {
if input_device.name == "pwsp-virtual-mic" { eprintln!("Could not find pwsp-virtual-mic device, skipping device linking");
pwsp_daemon_input = Some(input_device);
break;
}
}
if pwsp_daemon_input.is_none() {
eprintln!("Could not find pwsp-daemon input device, skipping device linking");
return Ok(()); return Ok(());
} }
let pwsp_daemon_input = pwsp_daemon_input.unwrap(); let Some(output_fl) = input_device.output_fl.clone() else {
let current_input_device = self.current_input_device.clone().unwrap();
let Some(output_fl) = current_input_device.output_fl.clone() else {
eprintln!("Failed to get pwsp-daemon output_fl"); eprintln!("Failed to get pwsp-daemon output_fl");
return Ok(()); return Ok(());
}; };
let Some(output_fr) = current_input_device.output_fr.clone() else { let Some(output_fr) = input_device.output_fr.clone() else {
eprintln!("Failed to get pwsp-daemon output_fr"); eprintln!("Failed to get pwsp-daemon output_fr");
return Ok(()); return Ok(());
}; };
let Some(input_fl) = pwsp_daemon_input.input_fl.clone() else { let Some(input_fl) = daemon_input.input_fl.clone() else {
eprintln!("Failed to get pwsp-daemon input_fl"); eprintln!("Failed to get pwsp-daemon input_fl");
return Ok(()); return Ok(());
}; };
let Some(input_fr) = pwsp_daemon_input.input_fr.clone() else { let Some(input_fr) = daemon_input.input_fr.clone() else {
eprintln!("Failed to get pwsp-daemon input_fr"); eprintln!("Failed to get pwsp-daemon input_fr");
return Ok(()); return Ok(());
}; };
@@ -173,6 +222,9 @@ impl AudioPlayer {
} else { } else {
self.tracks.clear(); self.tracks.clear();
} }
if self.tracks.is_empty() {
self.drop_stream();
}
} }
pub fn is_paused(&self) -> bool { pub fn is_paused(&self) -> bool {
@@ -202,6 +254,18 @@ impl AudioPlayer {
PlayerState::Stopped PlayerState::Stopped
} }
pub fn get_volume(&mut self, id: Option<u32>) -> Option<f32> {
if let Some(id) = id {
if let Some(sound) = self.tracks.get_mut(&id) {
Some(sound.sink.volume())
} else {
None
}
} else {
Some(self.volume)
}
}
pub fn set_volume(&mut self, volume: f32, id: Option<u32>) { pub fn set_volume(&mut self, volume: f32, id: Option<u32>) {
if let Some(id) = id { if let Some(id) = id {
if let Some(sound) = self.tracks.get_mut(&id) { if let Some(sound) = self.tracks.get_mut(&id) {
@@ -228,7 +292,7 @@ impl AudioPlayer {
0.0 0.0
} }
pub fn seek(&mut self, position: f32, id: Option<u32>) -> Result<(), Box<dyn Error>> { pub fn seek(&mut self, position: f32, id: Option<u32>) -> Result<()> {
let position = if position < 0.0 { 0.0 } else { position }; let position = if position < 0.0 { 0.0 } else { position };
if let Some(id) = id { if let Some(id) = id {
@@ -244,39 +308,53 @@ impl AudioPlayer {
Ok(()) Ok(())
} }
pub fn get_duration(&mut self, id: Option<u32>) -> Result<f32, Box<dyn Error>> { pub fn get_duration(&mut self, id: Option<u32>) -> Result<f32> {
if let Some(id) = id { if let Some(id) = id {
if let Some(sound) = self.tracks.get(&id) { if let Some(sound) = self.tracks.get(&id) {
return sound.duration.ok_or("Unknown duration".into()); return sound.duration.ok_or(anyhow!("Unknown duration"));
} }
} else if let Some(sound) = self.tracks.values().last() { } else if let Some(sound) = self.tracks.values().last() {
return sound.duration.ok_or("Unknown duration".into()); return sound.duration.ok_or(anyhow!("Unknown duration"));
} }
Err("No track playing".into()) Err(anyhow!("No track playing"))
} }
pub async fn play( pub async fn play(&mut self, file_path: &Path, concurrent: bool) -> Result<u32> {
&mut self, let path_buf = file_path.to_path_buf();
file_path: &Path,
concurrent: bool, let decoder_result =
) -> Result<u32, Box<dyn Error>> { tokio::task::spawn_blocking(move || -> Result<_, Box<dyn Error + Send + Sync>> {
if !file_path.exists() { if !path_buf.exists() {
return Err(format!("File does not exist: {}", file_path.display()).into()); return Err(format!("File does not exist: {}", path_buf.display()).into());
} }
let file = fs::File::open(file_path)?; let file = fs::File::open(&path_buf)?;
match Decoder::try_from(file) { let decoder = Decoder::try_from(file)
.map_err(|e| Box::new(e) as Box<dyn Error + Send + Sync>)?;
Ok(decoder)
})
.await?;
match decoder_result {
Ok(source) => { Ok(source) => {
if !concurrent { if !concurrent {
self.tracks.clear(); self.tracks.clear();
} }
self.ensure_stream()?;
self.link_player().await.ok();
let id = self.next_id; let id = self.next_id;
self.next_id += 1; self.next_id += 1;
let duration = source.total_duration().map(|d| d.as_secs_f32()); let duration = source.total_duration().map(|d| d.as_secs_f32());
let sink = Sink::connect_new(self.stream_handle.mixer()); let mixer = self
.stream_handle
.as_ref()
.ok_or_else(|| anyhow::anyhow!("stream_handle is unexpectedly missing"))?
.mixer();
let sink = Player::connect_new(mixer);
sink.set_volume(self.volume); // Default volume is 1.0 * master sink.set_volume(self.volume); // Default volume is 1.0 * master
sink.append(source); sink.append(source);
sink.play(); sink.play();
@@ -292,11 +370,9 @@ impl AudioPlayer {
self.tracks.insert(id, sound); self.tracks.insert(id, sound);
self.link_devices().await?;
Ok(id) Ok(id)
} }
Err(err) => Err(err.into()), Err(err) => Err(anyhow!(err)),
} }
} }
@@ -332,7 +408,30 @@ impl AudioPlayer {
tracks tracks
} }
pub async fn update(&mut self) { pub async fn update(&mut self, check_devices: bool) {
if check_devices {
if let Some(input_device_name) = &self.input_device_name {
// Unlink devices if selected input device was removed
if self.input_link_sender.is_some() && get_device(input_device_name).await.is_err()
{
eprintln!(
"Selected input device {} was removed, unlinking devices",
input_device_name
);
self.abort_link_thread();
}
// Link devices if not linked
else if self.input_link_sender.is_none() {
self.link_devices().await.ok();
}
}
if self.stream_handle.is_some() && self.player_link_sender.is_none() {
self.link_player().await.ok();
}
}
// Handle looped sounds
let mut restarts = vec![]; let mut restarts = vec![];
for (id, sound) in &self.tracks { for (id, sound) in &self.tracks {
@@ -341,29 +440,49 @@ impl AudioPlayer {
} }
} }
let mut restart_futures = vec![];
for id in restarts { for id in restarts {
if let Some(sound) = self.tracks.get_mut(&id) { if let Some(sound) = self.tracks.get(&id) {
if let Ok(file) = fs::File::open(&sound.path) { let path = sound.path.clone();
if let Ok(source) = Decoder::try_from(file) { let handle = tokio::task::spawn_blocking(move || {
if let Ok(file) = fs::File::open(&path)
&& let Ok(source) = Decoder::try_from(file)
{
return Some((id, source));
}
None
});
restart_futures.push(handle);
}
}
for handle in restart_futures {
if let Ok(res) = handle.await
&& let Some((id, source)) = res
&& let Some(sound) = self.tracks.get_mut(&id)
{
sound.sink.append(source); sound.sink.append(source);
sound.sink.play(); sound.sink.play();
} }
} }
}
}
self.tracks self.tracks
.retain(|_, sound| !sound.sink.empty() || sound.looped); .retain(|_, sound| !sound.sink.empty() || sound.looped);
if self.tracks.is_empty() {
self.drop_stream();
}
} }
pub async fn set_current_input_device(&mut self, name: &str) -> Result<(), Box<dyn Error>> { pub async fn set_current_input_device(&mut self, name: &str) -> Result<()> {
let input_device = get_device(name).await?; let input_device = get_device(name).await?;
if input_device.device_type != DeviceType::Input { if input_device.device_type != DeviceType::Input {
return Err("Selected device is not an input device".into()); return Err(anyhow!("Selected device is not an input device"));
} }
self.current_input_device = Some(input_device); self.input_device_name = Some(name.to_string());
self.link_devices().await?; self.link_devices().await?;
+415 -26
View File
@@ -1,9 +1,17 @@
use crate::{ use crate::{
types::{audio_player::PlayerState, socket::Response}, types::{
utils::{daemon::get_audio_player, pipewire::get_all_devices}, audio_player::{FullState, PlayerState},
config::HotkeyConfig,
socket::{Request, Response},
},
utils::{
commands::parse_command,
daemon::get_audio_player,
pipewire::{get_all_devices, get_device},
},
}; };
use async_trait::async_trait; use async_trait::async_trait;
use std::path::PathBuf; use std::{collections::HashMap, path::PathBuf};
#[async_trait] #[async_trait]
pub trait Executable { pub trait Executable {
@@ -12,6 +20,8 @@ pub trait Executable {
pub struct PingCommand {} pub struct PingCommand {}
pub struct KillCommand {}
pub struct PauseCommand { pub struct PauseCommand {
pub id: Option<u32>, pub id: Option<u32>,
} }
@@ -32,7 +42,9 @@ pub struct IsPausedCommand {}
pub struct GetStateCommand {} pub struct GetStateCommand {}
pub struct GetVolumeCommand {} pub struct GetVolumeCommand {
pub id: Option<u32>,
}
pub struct SetVolumeCommand { pub struct SetVolumeCommand {
pub volume: Option<f32>, pub volume: Option<f32>,
@@ -76,6 +88,45 @@ pub struct ToggleLoopCommand {
pub id: Option<u32>, pub id: Option<u32>,
} }
pub struct GetDaemonVersionCommand {}
pub struct GetFullStateCommand {}
pub struct GetHotkeysCommand {}
pub struct SetHotkeyCommand {
pub slot: Option<String>,
pub file_path: Option<PathBuf>,
}
pub struct SetHotkeyActionCommand {
pub slot: Option<String>,
pub action: Option<Request>,
}
pub struct SetHotkeyKeyCommand {
pub slot: Option<String>,
pub key_chord: Option<String>,
}
pub struct SetHotkeyActionAndKeyCommand {
pub slot: Option<String>,
pub action: Option<Request>,
pub key_chord: Option<String>,
}
pub struct PlayHotkeyCommand {
pub slot: Option<String>,
}
pub struct ClearHotkeyCommand {
pub slot: Option<String>,
}
pub struct ClearHotkeyKeyCommand {
pub slot: Option<String>,
}
#[async_trait] #[async_trait]
impl Executable for PingCommand { impl Executable for PingCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
@@ -83,10 +134,20 @@ impl Executable for PingCommand {
} }
} }
#[async_trait]
impl Executable for KillCommand {
async fn execute(&self) -> Response {
Response::new(true, "killed")
}
}
#[async_trait] #[async_trait]
impl Executable for PauseCommand { impl Executable for PauseCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let mut audio_player = get_audio_player().await.lock().await; let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
audio_player.pause(self.id); audio_player.pause(self.id);
Response::new(true, "Audio was paused") Response::new(true, "Audio was paused")
} }
@@ -95,7 +156,10 @@ impl Executable for PauseCommand {
#[async_trait] #[async_trait]
impl Executable for ResumeCommand { impl Executable for ResumeCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let mut audio_player = get_audio_player().await.lock().await; let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
audio_player.resume(self.id); audio_player.resume(self.id);
Response::new(true, "Audio was resumed") Response::new(true, "Audio was resumed")
} }
@@ -104,7 +168,10 @@ impl Executable for ResumeCommand {
#[async_trait] #[async_trait]
impl Executable for TogglePauseCommand { impl Executable for TogglePauseCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let mut audio_player = get_audio_player().await.lock().await; let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
if audio_player.get_state() == PlayerState::Stopped { if audio_player.get_state() == PlayerState::Stopped {
return Response::new(false, "Audio is not playing"); return Response::new(false, "Audio is not playing");
@@ -142,7 +209,10 @@ impl Executable for TogglePauseCommand {
#[async_trait] #[async_trait]
impl Executable for StopCommand { impl Executable for StopCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let mut audio_player = get_audio_player().await.lock().await; let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
audio_player.stop(self.id); audio_player.stop(self.id);
Response::new(true, "Audio was stopped") Response::new(true, "Audio was stopped")
} }
@@ -151,7 +221,10 @@ impl Executable for StopCommand {
#[async_trait] #[async_trait]
impl Executable for IsPausedCommand { impl Executable for IsPausedCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let audio_player = get_audio_player().await.lock().await; let audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
let is_paused = audio_player.is_paused().to_string(); let is_paused = audio_player.is_paused().to_string();
Response::new(true, is_paused) Response::new(true, is_paused)
} }
@@ -160,18 +233,32 @@ impl Executable for IsPausedCommand {
#[async_trait] #[async_trait]
impl Executable for GetStateCommand { impl Executable for GetStateCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let audio_player = get_audio_player().await.lock().await; let audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
let state = audio_player.get_state(); let state = audio_player.get_state();
Response::new(true, serde_json::to_string(&state).unwrap()) match serde_json::to_string(&state) {
Ok(json) => Response::new(true, json),
Err(err) => Response::new(false, format!("Failed to serialize state: {}", err)),
}
} }
} }
#[async_trait] #[async_trait]
impl Executable for GetVolumeCommand { impl Executable for GetVolumeCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let audio_player = get_audio_player().await.lock().await; let mut audio_player = match get_audio_player().await {
let volume = audio_player.volume; Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
let volume = audio_player.get_volume(self.id);
if let Some(volume) = volume {
Response::new(true, volume.to_string()) Response::new(true, volume.to_string())
} else {
Response::new(false, "Failed to get volume")
}
} }
} }
@@ -179,7 +266,10 @@ impl Executable for GetVolumeCommand {
impl Executable for SetVolumeCommand { impl Executable for SetVolumeCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
if let Some(volume) = self.volume { if let Some(volume) = self.volume {
let mut audio_player = get_audio_player().await.lock().await; let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
audio_player.set_volume(volume, self.id); audio_player.set_volume(volume, self.id);
Response::new(true, format!("Audio volume was set to {}", volume)) Response::new(true, format!("Audio volume was set to {}", volume))
} else { } else {
@@ -191,7 +281,10 @@ impl Executable for SetVolumeCommand {
#[async_trait] #[async_trait]
impl Executable for GetPositionCommand { impl Executable for GetPositionCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let audio_player = get_audio_player().await.lock().await; let audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
let position = audio_player.get_position(self.id); let position = audio_player.get_position(self.id);
Response::new(true, position.to_string()) Response::new(true, position.to_string())
} }
@@ -201,7 +294,10 @@ impl Executable for GetPositionCommand {
impl Executable for SeekCommand { impl Executable for SeekCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
if let Some(position) = self.position { if let Some(position) = self.position {
let mut audio_player = get_audio_player().await.lock().await; let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
match audio_player.seek(position, self.id) { match audio_player.seek(position, self.id) {
Ok(_) => Response::new(true, format!("Audio position was set to {}", position)), Ok(_) => Response::new(true, format!("Audio position was set to {}", position)),
Err(err) => Response::new(false, err.to_string()), Err(err) => Response::new(false, err.to_string()),
@@ -215,7 +311,10 @@ impl Executable for SeekCommand {
#[async_trait] #[async_trait]
impl Executable for GetDurationCommand { impl Executable for GetDurationCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let mut audio_player = get_audio_player().await.lock().await; let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
match audio_player.get_duration(self.id) { match audio_player.get_duration(self.id) {
Ok(duration) => Response::new(true, duration.to_string()), Ok(duration) => Response::new(true, duration.to_string()),
Err(err) => Response::new(false, err.to_string()), Err(err) => Response::new(false, err.to_string()),
@@ -227,7 +326,10 @@ impl Executable for GetDurationCommand {
impl Executable for PlayCommand { impl Executable for PlayCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
if let Some(file_path) = &self.file_path { if let Some(file_path) = &self.file_path {
let mut audio_player = get_audio_player().await.lock().await; let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
match audio_player match audio_player
.play(file_path, self.concurrent.unwrap_or(false)) .play(file_path, self.concurrent.unwrap_or(false))
.await .await
@@ -244,21 +346,34 @@ impl Executable for PlayCommand {
#[async_trait] #[async_trait]
impl Executable for GetTracksCommand { impl Executable for GetTracksCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let audio_player = get_audio_player().await.lock().await; let audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
let tracks = audio_player.get_tracks(); let tracks = audio_player.get_tracks();
Response::new(true, serde_json::to_string(&tracks).unwrap()) match serde_json::to_string(&tracks) {
Ok(json) => Response::new(true, json),
Err(err) => Response::new(false, format!("Failed to serialize tracks: {}", err)),
}
} }
} }
#[async_trait] #[async_trait]
impl Executable for GetCurrentInputCommand { impl Executable for GetCurrentInputCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let audio_player = get_audio_player().await.lock().await; let audio_player = match get_audio_player().await {
if let Some(input_device) = &audio_player.current_input_device { Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
if let Some(input_device_name) = &audio_player.input_device_name {
if let Ok(input_device) = get_device(input_device_name).await {
Response::new( Response::new(
true, true,
format!("{} - {}", input_device.name, input_device.nick), format!("{} - {}", input_device.name, input_device.nick),
) )
} else {
Response::new(false, "Failed to get current input device")
}
} else { } else {
Response::new(false, "No input device selected") Response::new(false, "No input device selected")
} }
@@ -268,7 +383,10 @@ impl Executable for GetCurrentInputCommand {
#[async_trait] #[async_trait]
impl Executable for GetAllInputsCommand { impl Executable for GetAllInputsCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let (input_devices, _output_devices) = get_all_devices().await.unwrap(); let (input_devices, _output_devices) = match get_all_devices().await {
Ok(devices) => devices,
Err(err) => return Response::new(false, format!("Failed to get devices: {}", err)),
};
let mut input_devices_strings = vec![]; let mut input_devices_strings = vec![];
for device in input_devices { for device in input_devices {
if device.name == "pwsp-virtual-mic" { if device.name == "pwsp-virtual-mic" {
@@ -288,7 +406,10 @@ impl Executable for GetAllInputsCommand {
impl Executable for SetCurrentInputCommand { impl Executable for SetCurrentInputCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
if let Some(name) = &self.name { if let Some(name) = &self.name {
let mut audio_player = get_audio_player().await.lock().await; let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
match audio_player.set_current_input_device(name).await { match audio_player.set_current_input_device(name).await {
Ok(_) => Response::new(true, "Input device was set"), Ok(_) => Response::new(true, "Input device was set"),
Err(err) => Response::new(false, err.to_string()), Err(err) => Response::new(false, err.to_string()),
@@ -302,7 +423,10 @@ impl Executable for SetCurrentInputCommand {
#[async_trait] #[async_trait]
impl Executable for SetLoopCommand { impl Executable for SetLoopCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let mut audio_player = get_audio_player().await.lock().await; let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
match self.enabled { match self.enabled {
Some(enabled) => { Some(enabled) => {
@@ -317,7 +441,10 @@ impl Executable for SetLoopCommand {
#[async_trait] #[async_trait]
impl Executable for ToggleLoopCommand { impl Executable for ToggleLoopCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let mut audio_player = get_audio_player().await.lock().await; let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
if let Some(id) = self.id { if let Some(id) = self.id {
if let Some(track) = audio_player.tracks.get_mut(&id) { if let Some(track) = audio_player.tracks.get_mut(&id) {
track.looped = !track.looped; track.looped = !track.looped;
@@ -334,3 +461,265 @@ impl Executable for ToggleLoopCommand {
} }
} }
} }
#[async_trait]
impl Executable for GetDaemonVersionCommand {
async fn execute(&self) -> Response {
Response::new(true, env!("CARGO_PKG_VERSION"))
}
}
#[async_trait]
impl Executable for GetFullStateCommand {
async fn execute(&self) -> Response {
let (input_devices, _output_devices) = match get_all_devices().await {
Ok(devices) => devices,
Err(err) => return Response::new(false, format!("Failed to get devices: {}", err)),
};
let mut all_inputs = HashMap::new();
let mut current_input_nick = String::new();
let audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
if let Some(current_input_name) = &audio_player.input_device_name {
for device in input_devices {
if device.name == "pwsp-virtual-mic" {
continue;
}
if device.name == *current_input_name {
current_input_nick = format!("{} - {}", device.name, device.nick);
}
all_inputs.insert(device.name, device.nick);
}
} else {
for device in input_devices {
if device.name == "pwsp-virtual-mic" {
continue;
}
all_inputs.insert(device.name, device.nick);
}
}
let full_state = FullState {
state: audio_player.get_state(),
tracks: audio_player.get_tracks(),
volume: audio_player.volume,
current_input: current_input_nick,
all_inputs,
};
match serde_json::to_string(&full_state) {
Ok(json) => Response::new(true, json),
Err(err) => Response::new(false, format!("Failed to serialize full state: {}", err)),
}
}
}
#[async_trait]
impl Executable for GetHotkeysCommand {
async fn execute(&self) -> Response {
match HotkeyConfig::load() {
Ok(config) => match serde_json::to_string(&config) {
Ok(json) => Response::new(true, json),
Err(err) => Response::new(false, format!("Failed to serialize hotkeys: {}", err)),
},
Err(err) => Response::new(false, format!("Failed to load hotkeys: {}", err)),
}
}
}
#[async_trait]
impl Executable for SetHotkeyCommand {
async fn execute(&self) -> Response {
let Some(slot) = &self.slot else {
return Response::new(false, "Missing slot name");
};
let Some(file_path) = &self.file_path else {
return Response::new(false, "Missing file path");
};
let mut config = match HotkeyConfig::load() {
Ok(c) => c,
Err(err) => return Response::new(false, format!("Failed to load hotkeys: {}", err)),
};
config.set_slot(
slot.clone(),
Request::play(&file_path.to_string_lossy(), false),
);
match config.save() {
Ok(_) => Response::new(true, format!("Hotkey slot '{}' set", slot)),
Err(err) => Response::new(false, format!("Failed to save hotkeys: {}", err)),
}
}
}
#[async_trait]
impl Executable for SetHotkeyActionCommand {
async fn execute(&self) -> Response {
let Some(slot) = &self.slot else {
return Response::new(false, "Missing slot name");
};
let Some(action) = &self.action else {
return Response::new(false, "Missing or invalid action");
};
let mut config = match HotkeyConfig::load() {
Ok(c) => c,
Err(err) => return Response::new(false, format!("Failed to load hotkeys: {}", err)),
};
config.set_slot(slot.clone(), action.clone());
match config.save() {
Ok(_) => Response::new(true, format!("Hotkey slot '{}' set", slot)),
Err(err) => Response::new(false, format!("Failed to save hotkeys: {}", err)),
}
}
}
#[async_trait]
impl Executable for SetHotkeyKeyCommand {
async fn execute(&self) -> Response {
let Some(slot) = &self.slot else {
return Response::new(false, "Missing slot name");
};
let Some(key_chord) = &self.key_chord else {
return Response::new(false, "Missing key chord");
};
let mut config = match HotkeyConfig::load() {
Ok(c) => c,
Err(err) => return Response::new(false, format!("Failed to load hotkeys: {}", err)),
};
if !config.set_key_chord(slot, Some(key_chord.clone())) {
return Response::new(false, format!("Slot '{}' not found", slot));
}
match config.save() {
Ok(_) => Response::new(
true,
format!("Key chord for slot '{}' set to '{}'", slot, key_chord),
),
Err(err) => Response::new(false, format!("Failed to save hotkeys: {}", err)),
}
}
}
#[async_trait]
impl Executable for SetHotkeyActionAndKeyCommand {
async fn execute(&self) -> Response {
let Some(slot) = &self.slot else {
return Response::new(false, "Missing slot name");
};
let Some(action) = &self.action else {
return Response::new(false, "Missing or invalid action");
};
let Some(key_chord) = &self.key_chord else {
return Response::new(false, "Missing key chord");
};
let mut config = match HotkeyConfig::load() {
Ok(c) => c,
Err(err) => return Response::new(false, format!("Failed to load hotkeys: {}", err)),
};
// Set the action and then the key chord
config.set_slot(slot.clone(), action.clone());
if !config.set_key_chord(slot, Some(key_chord.clone())) {
return Response::new(
false,
format!("Slot '{}' not found after setting action", slot),
);
}
match config.save() {
Ok(_) => Response::new(
true,
format!(
"Hotkey slot '{}' set with action and key chord '{}'",
slot, key_chord
),
),
Err(err) => Response::new(false, format!("Failed to save hotkeys: {}", err)),
}
}
}
#[async_trait]
impl Executable for PlayHotkeyCommand {
async fn execute(&self) -> Response {
let Some(slot) = &self.slot else {
return Response::new(false, "Missing slot name");
};
let config = match HotkeyConfig::load() {
Ok(c) => c,
Err(err) => return Response::new(false, format!("Failed to load hotkeys: {}", err)),
};
let Some(hotkey_slot) = config.find_slot(slot) else {
return Response::new(false, format!("Slot '{}' not found", slot));
};
let action = hotkey_slot.action.clone();
if let Some(cmd) = parse_command(&action) {
cmd.execute().await
} else {
Response::new(false, "Unknown command in hotkey slot")
}
}
}
#[async_trait]
impl Executable for ClearHotkeyCommand {
async fn execute(&self) -> Response {
let Some(slot) = &self.slot else {
return Response::new(false, "Missing slot name");
};
let mut config = match HotkeyConfig::load() {
Ok(c) => c,
Err(err) => return Response::new(false, format!("Failed to load hotkeys: {}", err)),
};
if config.remove_slot(slot) {
match config.save() {
Ok(_) => Response::new(true, format!("Hotkey slot '{}' cleared", slot)),
Err(err) => Response::new(false, format!("Failed to save hotkeys: {}", err)),
}
} else {
Response::new(false, format!("Slot '{}' not found", slot))
}
}
}
#[async_trait]
impl Executable for ClearHotkeyKeyCommand {
async fn execute(&self) -> Response {
let Some(slot) = &self.slot else {
return Response::new(false, "Missing slot name");
};
let mut config = match HotkeyConfig::load() {
Ok(c) => c,
Err(err) => return Response::new(false, format!("Failed to load hotkeys: {}", err)),
};
if !config.set_key_chord(slot, None) {
return Response::new(false, format!("Slot '{}' not found", slot));
}
match config.save() {
Ok(_) => Response::new(true, format!("Key chord for slot '{}' cleared", slot)),
Err(err) => Response::new(false, format!("Failed to save hotkeys: {}", err)),
}
}
}
+151 -14
View File
@@ -1,19 +1,25 @@
use crate::utils::config::get_config_path; use crate::{
types::socket::Request,
utils::{config::get_config_path, gui::ensure_pwsp_audio_dir},
};
use anyhow::Result;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{collections::HashSet, error::Error, fs, path::PathBuf}; use std::{collections::HashMap, fs, path::PathBuf};
#[derive(Default, Clone, Serialize, Deserialize)] #[derive(Default, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DaemonConfig { pub struct DaemonConfig {
pub default_input_name: Option<String>, pub default_input_name: Option<String>,
pub default_volume: Option<f32>, pub default_volume: Option<f32>,
} }
impl DaemonConfig { impl DaemonConfig {
pub fn save_to_file(&self) -> Result<(), Box<dyn Error>> { pub fn save_to_file(&self) -> Result<()> {
let config_path = get_config_path()?.join("daemon.json"); let config_path = get_config_path()?.join("daemon.json");
let config_dir = config_path.parent().unwrap();
if !config_path.exists() { if let Some(config_dir) = config_path.parent()
&& !config_path.exists()
{
fs::create_dir_all(config_dir)?; fs::create_dir_all(config_dir)?;
} }
@@ -22,46 +28,64 @@ impl DaemonConfig {
Ok(()) Ok(())
} }
pub fn load_from_file() -> Result<DaemonConfig, Box<dyn Error>> { pub fn load_from_file() -> Result<DaemonConfig> {
let config_path = get_config_path()?.join("daemon.json"); let config_path = get_config_path()?.join("daemon.json");
let bytes = fs::read(config_path)?; let bytes = fs::read(config_path)?;
Ok(serde_json::from_slice::<DaemonConfig>(&bytes)?) match serde_json::from_slice::<DaemonConfig>(&bytes) {
Ok(config) => Ok(config),
Err(_) => Ok(DaemonConfig::default()),
}
} }
} }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum PreferredTheme {
System,
Light,
Dark,
}
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct GuiConfig { pub struct GuiConfig {
pub scale_factor: f32, pub scale_factor: f32,
pub left_panel_width: f32,
pub save_volume: bool, pub save_volume: bool,
pub save_input: bool, pub save_input: bool,
pub save_scale_factor: bool, pub save_scale_factor: bool,
pub pause_on_exit: bool, pub pause_on_exit: bool,
pub dirs: HashSet<PathBuf>, pub dirs: Vec<PathBuf>,
pub preferred_theme: PreferredTheme,
} }
impl Default for GuiConfig { impl Default for GuiConfig {
fn default() -> Self { fn default() -> Self {
GuiConfig { GuiConfig {
scale_factor: 1.0, scale_factor: 1.0,
left_panel_width: 280.0,
save_volume: false, save_volume: false,
save_input: false, save_input: false,
save_scale_factor: false, save_scale_factor: false,
pause_on_exit: false, pause_on_exit: false,
dirs: HashSet::default(), dirs: vec![ensure_pwsp_audio_dir()],
preferred_theme: PreferredTheme::System,
} }
} }
} }
impl GuiConfig { impl GuiConfig {
pub fn save_to_file(&mut self) -> Result<(), Box<dyn Error>> { pub fn save_to_file(&mut self) -> Result<()> {
let config_path = get_config_path()?.join("gui.json"); let config_path = get_config_path()?.join("gui.json");
let config_dir = config_path.parent().unwrap();
if !config_path.exists() { if let Some(config_dir) = config_path.parent()
&& !config_path.exists()
{
fs::create_dir_all(config_dir)?; fs::create_dir_all(config_dir)?;
} }
@@ -75,9 +99,122 @@ impl GuiConfig {
Ok(()) Ok(())
} }
pub fn load_from_file() -> Result<GuiConfig, Box<dyn Error>> { pub fn load_from_file() -> Result<GuiConfig> {
let config_path = get_config_path()?.join("gui.json"); let config_path = get_config_path()?.join("gui.json");
let bytes = fs::read(config_path)?; let bytes = fs::read(config_path)?;
Ok(serde_json::from_slice::<GuiConfig>(&bytes)?) match serde_json::from_slice::<GuiConfig>(&bytes) {
Ok(config) => Ok(config),
Err(_) => Ok(GuiConfig::default()),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HotkeySlot {
pub slot: String,
pub action: Request,
pub key_chord: Option<String>,
}
#[derive(Default, Clone, Debug, Serialize, Deserialize)]
pub struct HotkeyConfig {
#[serde(default)]
pub slots: Vec<HotkeySlot>,
}
impl HotkeyConfig {
pub fn config_path() -> Result<PathBuf> {
Ok(get_config_path()?.join("hotkeys.json"))
}
pub fn load() -> Result<HotkeyConfig> {
let path = Self::config_path()?;
if !path.exists() {
return Ok(HotkeyConfig::default());
}
let bytes = fs::read(&path)?;
match serde_json::from_slice::<HotkeyConfig>(&bytes) {
Ok(config) => Ok(config),
Err(e) => Err(e.into()),
}
}
pub fn save(&self) -> Result<()> {
let path = Self::config_path()?;
if let Some(dir) = path.parent()
&& !dir.exists()
{
fs::create_dir_all(dir)?;
}
let json = serde_json::to_string_pretty(self)?;
fs::write(path, json.as_bytes())?;
Ok(())
}
pub fn find_slot(&self, slot: &str) -> Option<&HotkeySlot> {
self.slots.iter().find(|s| s.slot == slot)
}
pub fn find_slot_mut(&mut self, slot: &str) -> Option<&mut HotkeySlot> {
self.slots.iter_mut().find(|s| s.slot == slot)
}
pub fn set_slot(&mut self, slot: String, action: Request) {
if let Some(existing) = self.find_slot_mut(&slot) {
existing.action = action;
} else {
self.slots.push(HotkeySlot {
slot,
action,
key_chord: None,
});
}
}
pub fn set_key_chord(&mut self, slot: &str, key_chord: Option<String>) -> bool {
if let Some(existing) = self.find_slot_mut(slot) {
existing.key_chord = key_chord;
true
} else {
false
}
}
pub fn remove_slot(&mut self, slot: &str) -> bool {
let len = self.slots.len();
self.slots.retain(|s| s.slot != slot);
self.slots.len() != len
}
/// Returns pairs of slot names that share the same key chord.
pub fn find_conflicts(&self) -> Vec<(&str, &str)> {
let mut conflicts = vec![];
let mut chord_map: HashMap<&str, Vec<&str>> = HashMap::new();
for s in &self.slots {
if let Some(chord) = &s.key_chord {
chord_map.entry(chord.as_str()).or_default().push(&s.slot);
}
}
for slots in chord_map.values() {
if slots.len() > 1 {
for i in 0..slots.len() {
for j in (i + 1)..slots.len() {
conflicts.push((slots[i], slots[j]));
}
}
}
}
conflicts
}
/// Find which slot(s) have the given key chord.
pub fn slots_for_chord(&self, chord: &str) -> Vec<&HotkeySlot> {
self.slots
.iter()
.filter(|s| s.key_chord.as_deref() == Some(chord))
.collect()
} }
} }
+23 -6
View File
@@ -1,4 +1,7 @@
use crate::types::audio_player::{PlayerState, TrackInfo}; use crate::types::{
audio_player::{PlayerState, TrackInfo},
config::HotkeyConfig,
};
use egui::Id; use egui::Id;
@@ -28,19 +31,30 @@ pub struct AppState {
pub show_settings: bool, pub show_settings: bool,
pub volume_dragged: bool, pub volume_dragged: bool,
pub force_focus_search: bool,
pub volume_slider_value: f32, pub volume_slider_value: f32,
pub search_field_id: Option<Id>,
pub ignore_volume_update_until: Option<Instant>, pub ignore_volume_update_until: Option<Instant>,
pub current_dir: Option<PathBuf>, pub current_dir: Option<PathBuf>,
pub dirs: HashSet<PathBuf>, pub dirs: Vec<PathBuf>,
pub dirs_to_remove: HashSet<PathBuf>,
pub selected_file: Option<PathBuf>, pub listed_files: HashSet<PathBuf>,
pub files: HashSet<PathBuf>, pub listed_dirs: HashSet<PathBuf>,
pub dir_cache: HashMap<PathBuf, Vec<PathBuf>>,
pub search_field_id: Option<Id>, pub show_hotkeys: bool,
pub force_focus_id: Option<Id>, pub hotkey_capture_active: bool,
pub hotkey_config: HotkeyConfig,
pub hotkey_search_query: String,
pub assigning_hotkey_slot: Option<String>,
pub assigning_hotkey_for_file: Option<PathBuf>,
} }
#[derive(Default, Debug, Clone)] #[derive(Default, Debug, Clone)]
@@ -54,6 +68,9 @@ pub struct AudioPlayerState {
pub current_input: String, pub current_input: String,
pub all_inputs: HashMap<String, String>, pub all_inputs: HashMap<String, String>,
pub all_inputs_sorted: Vec<(String, String)>,
pub is_daemon_running: bool, pub is_daemon_running: bool,
pub hotkey_config: Option<HotkeyConfig>,
} }
+43
View File
@@ -29,3 +29,46 @@ pub struct AudioDevice {
pub output_fl: Option<Port>, pub output_fl: Option<Port>,
pub output_fr: Option<Port>, pub output_fr: Option<Port>,
} }
impl AudioDevice {
pub fn new(
id: u32,
nick: Option<&str>,
description: Option<&str>,
name: Option<&str>,
device_type: DeviceType,
) -> Self {
Self {
id,
nick: nick
.or(description)
.or(name)
.unwrap_or_default()
.to_string(),
name: name.unwrap_or_default().to_string(),
device_type,
input_fl: None,
input_fr: None,
output_fl: None,
output_fr: None,
}
}
pub fn add_port(&mut self, port: Port) {
match port.name.as_str() {
"input_FL" => self.input_fl = Some(port),
"input_FR" => self.input_fr = Some(port),
"output_FL" | "capture_FL" => self.output_fl = Some(port),
"output_FR" | "capture_FR" => self.output_fr = Some(port),
"input_MONO" => {
self.input_fl = Some(port.clone());
self.input_fr = Some(port);
}
"output_MONO" | "capture_MONO" => {
self.output_fl = Some(port.clone());
self.output_fr = Some(port);
}
_ => {}
}
}
}
+70 -3
View File
@@ -1,7 +1,9 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
#[derive(Default, Debug, Clone, Serialize, Deserialize)] pub const MAX_MESSAGE_SIZE: usize = 128 * 1024;
#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Request { pub struct Request {
pub name: String, pub name: String,
pub args: HashMap<String, String>, pub args: HashMap<String, String>,
@@ -24,6 +26,10 @@ impl Request {
Request::new("ping", vec![]) Request::new("ping", vec![])
} }
pub fn kill() -> Self {
Request::new("kill", vec![])
}
pub fn pause(id: Option<u32>) -> Self { pub fn pause(id: Option<u32>) -> Self {
let mut args = vec![]; let mut args = vec![];
let id_str; let id_str;
@@ -78,8 +84,14 @@ impl Request {
Request::new("is_paused", vec![]) Request::new("is_paused", vec![])
} }
pub fn get_volume() -> Self { pub fn get_volume(id: Option<u32>) -> Self {
Request::new("get_volume", vec![]) let mut args = vec![];
let id_str;
if let Some(id) = id {
id_str = id.to_string();
args.push(("id", id_str.as_str()));
}
Request::new("get_volume", args)
} }
pub fn get_position(id: Option<u32>) -> Self { pub fn get_position(id: Option<u32>) -> Self {
@@ -155,6 +167,61 @@ impl Request {
} }
Request::new("toggle_loop", args) Request::new("toggle_loop", args)
} }
pub fn get_daemon_version() -> Self {
Request::new("get_daemon_version", vec![])
}
pub fn get_full_state() -> Self {
Request::new("get_full_state", vec![])
}
pub fn get_hotkeys() -> Self {
Request::new("get_hotkeys", vec![])
}
pub fn set_hotkey(slot: &str, file_path: &str) -> Self {
Request::new("set_hotkey", vec![("slot", slot), ("file_path", file_path)])
}
pub fn set_hotkey_key(slot: &str, key_chord: &str) -> Self {
Request::new(
"set_hotkey_key",
vec![("slot", slot), ("key_chord", key_chord)],
)
}
pub fn clear_hotkey(slot: &str) -> Self {
Request::new("clear_hotkey", vec![("slot", slot)])
}
pub fn play_hotkey(slot: &str) -> Self {
Request::new("play_hotkey", vec![("slot", slot)])
}
pub fn set_hotkey_action(slot: &str, action: &Request) -> Self {
let action_json = serde_json::to_string(action).unwrap_or_default();
Request::new(
"set_hotkey_action",
vec![("slot", slot), ("action", &action_json)],
)
}
pub fn clear_hotkey_key(slot: &str) -> Self {
Request::new("clear_hotkey_key", vec![("slot", slot)])
}
pub fn set_hotkey_action_and_key(slot: &str, action: &Request, key_chord: &str) -> Self {
let action_json = serde_json::to_string(action).unwrap_or_default();
Request::new(
"set_hotkey_action_and_key",
vec![
("slot", slot),
("action", &action_json),
("key_chord", key_chord),
],
)
}
} }
#[derive(Default, Debug, Clone, Serialize, Deserialize)] #[derive(Default, Debug, Clone, Serialize, Deserialize)]
+136 -1
View File
@@ -7,13 +7,14 @@ pub fn parse_command(request: &Request) -> Option<Box<dyn Executable + Send>> {
match request.name.as_str() { match request.name.as_str() {
"ping" => Some(Box::new(PingCommand {})), "ping" => Some(Box::new(PingCommand {})),
"kill" => Some(Box::new(KillCommand {})),
"pause" => Some(Box::new(PauseCommand { id })), "pause" => Some(Box::new(PauseCommand { id })),
"resume" => Some(Box::new(ResumeCommand { id })), "resume" => Some(Box::new(ResumeCommand { id })),
"toggle_pause" => Some(Box::new(TogglePauseCommand { id })), "toggle_pause" => Some(Box::new(TogglePauseCommand { id })),
"stop" => Some(Box::new(StopCommand { id })), "stop" => Some(Box::new(StopCommand { id })),
"is_paused" => Some(Box::new(IsPausedCommand {})), "is_paused" => Some(Box::new(IsPausedCommand {})),
"get_state" => Some(Box::new(GetStateCommand {})), "get_state" => Some(Box::new(GetStateCommand {})),
"get_volume" => Some(Box::new(GetVolumeCommand {})), "get_volume" => Some(Box::new(GetVolumeCommand { id })),
"set_volume" => { "set_volume" => {
let volume = request let volume = request
.args .args
@@ -69,6 +70,140 @@ pub fn parse_command(request: &Request) -> Option<Box<dyn Executable + Send>> {
Some(Box::new(SetLoopCommand { enabled, id })) Some(Box::new(SetLoopCommand { enabled, id }))
} }
"toggle_loop" => Some(Box::new(ToggleLoopCommand { id })), "toggle_loop" => Some(Box::new(ToggleLoopCommand { id })),
"get_daemon_version" => Some(Box::new(GetDaemonVersionCommand {})),
"get_full_state" => Some(Box::new(GetFullStateCommand {})),
"get_hotkeys" => Some(Box::new(GetHotkeysCommand {})),
"set_hotkey" => {
let slot = request.args.get("slot").cloned();
let file_path = request
.args
.get("file_path")
.and_then(|s| s.parse::<PathBuf>().ok());
Some(Box::new(SetHotkeyCommand { slot, file_path }))
}
"set_hotkey_key" => {
let slot = request.args.get("slot").cloned();
let key_chord = request.args.get("key_chord").cloned();
Some(Box::new(SetHotkeyKeyCommand { slot, key_chord }))
}
"clear_hotkey" => {
let slot = request.args.get("slot").cloned();
Some(Box::new(ClearHotkeyCommand { slot }))
}
"play_hotkey" => {
let slot = request.args.get("slot").cloned();
Some(Box::new(PlayHotkeyCommand { slot }))
}
"set_hotkey_action" => {
let slot = request.args.get("slot").cloned();
let action = request
.args
.get("action")
.and_then(|s| serde_json::from_str::<Request>(s).ok());
Some(Box::new(SetHotkeyActionCommand { slot, action }))
}
"clear_hotkey_key" => {
let slot = request.args.get("slot").cloned();
Some(Box::new(ClearHotkeyKeyCommand { slot }))
}
"set_hotkey_action_and_key" => {
let slot = request.args.get("slot").cloned();
let action = request
.args
.get("action")
.and_then(|s| serde_json::from_str::<Request>(s).ok());
let key_chord = request.args.get("key_chord").cloned();
Some(Box::new(SetHotkeyActionAndKeyCommand {
slot,
action,
key_chord,
}))
}
_ => None, _ => None,
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::types::socket::Request;
use std::collections::HashMap;
#[test]
fn test_parse_set_volume_valid() {
let mut args = HashMap::new();
args.insert("volume".to_string(), "0.5".to_string());
args.insert("id".to_string(), "1".to_string());
let request = Request {
name: "set_volume".to_string(),
args,
};
let cmd = parse_command(&request);
assert!(cmd.is_some());
}
#[test]
fn test_parse_set_volume_missing_volume() {
let mut args = HashMap::new();
args.insert("id".to_string(), "1".to_string());
let request = Request {
name: "set_volume".to_string(),
args,
};
let cmd = parse_command(&request);
assert!(cmd.is_some());
}
#[test]
fn test_parse_set_volume_invalid_volume() {
let mut args = HashMap::new();
args.insert("volume".to_string(), "not-a-float".to_string());
let request = Request {
name: "set_volume".to_string(),
args,
};
let cmd = parse_command(&request);
assert!(cmd.is_some());
}
#[test]
fn test_parse_set_volume_missing_id() {
let mut args = HashMap::new();
args.insert("volume".to_string(), "0.5".to_string());
let request = Request {
name: "set_volume".to_string(),
args,
};
let cmd = parse_command(&request);
assert!(cmd.is_some());
}
#[test]
fn test_parse_set_volume_invalid_id() {
let mut args = HashMap::new();
args.insert("id".to_string(), "not-an-int".to_string());
args.insert("volume".to_string(), "0.5".to_string());
let request = Request {
name: "set_volume".to_string(),
args,
};
let cmd = parse_command(&request);
assert!(cmd.is_some());
}
#[test]
fn test_parse_set_volume_empty_args() {
let request = Request {
name: "set_volume".to_string(),
args: HashMap::new(),
};
let cmd = parse_command(&request);
assert!(cmd.is_some());
}
}
+3 -2
View File
@@ -1,6 +1,7 @@
use std::{error::Error, path::PathBuf}; use anyhow::Result;
use std::path::PathBuf;
pub fn get_config_path() -> Result<PathBuf, Box<dyn Error>> { pub fn get_config_path() -> Result<PathBuf> {
let config_path = dirs::config_dir().expect("Failed to obtain config dir"); let config_path = dirs::config_dir().expect("Failed to obtain config dir");
Ok(config_path.join("pwsp")) Ok(config_path.join("pwsp"))
} }
+56 -68
View File
@@ -1,14 +1,13 @@
use crate::{ use crate::types::{
types::{
audio_player::AudioPlayer, audio_player::AudioPlayer,
config::DaemonConfig, config::DaemonConfig,
pipewire::AudioDevice, socket::{MAX_MESSAGE_SIZE, Request, Response},
socket::{Request, Response},
},
utils::pipewire::{create_link, get_all_devices},
}; };
use anyhow::Result;
use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt};
use std::path::PathBuf; use std::path::PathBuf;
use std::{error::Error, fs}; use std::{env, error::Error, fs};
use tokio::{ use tokio::{
io::{AsyncReadExt, AsyncWriteExt}, io::{AsyncReadExt, AsyncWriteExt},
net::UnixStream, net::UnixStream,
@@ -18,11 +17,14 @@ use tokio::{
static AUDIO_PLAYER: OnceCell<Mutex<AudioPlayer>> = OnceCell::const_new(); static AUDIO_PLAYER: OnceCell<Mutex<AudioPlayer>> = OnceCell::const_new();
pub async fn get_audio_player() -> &'static Mutex<AudioPlayer> { pub async fn get_audio_player() -> Result<&'static Mutex<AudioPlayer>, String> {
AUDIO_PLAYER AUDIO_PLAYER
.get_or_init(|| async { .get_or_try_init(|| async {
println!("Initializing audio player"); println!("Initializing audio player");
Mutex::new(AudioPlayer::new().await.unwrap()) match AudioPlayer::new().await {
Ok(player) => Ok(Mutex::new(player)),
Err(err) => Err(err.to_string()),
}
}) })
.await .await
} }
@@ -35,81 +37,59 @@ pub fn get_daemon_config() -> DaemonConfig {
}) })
} }
pub async fn link_player_to_virtual_mic() -> Result<(), Box<dyn Error>> { fn get_current_uid() -> u32 {
let (input_devices, output_devices) = get_all_devices().await?; rustix::process::geteuid().as_raw()
let mut pwsp_daemon_output: Option<AudioDevice> = None;
for output_device in output_devices {
if output_device.name == "alsa_playback.pwsp-daemon" {
pwsp_daemon_output = Some(output_device);
break;
}
}
if pwsp_daemon_output.is_none() {
eprintln!("Could not find pwsp-daemon output device, skipping device linking");
return Ok(());
}
let mut pwsp_daemon_input: Option<AudioDevice> = None;
for input_device in input_devices {
if input_device.name == "pwsp-virtual-mic" {
pwsp_daemon_input = Some(input_device);
break;
}
}
if pwsp_daemon_input.is_none() {
eprintln!("Could not find pwsp-daemon input device, skipping device linking");
return Ok(());
}
let pwsp_daemon_output = pwsp_daemon_output.unwrap();
let pwsp_daemon_input = pwsp_daemon_input.unwrap();
let output_fl = pwsp_daemon_output
.clone()
.output_fl
.expect("Failed to get pwsp-daemon output_fl");
let output_fr = pwsp_daemon_output
.clone()
.output_fr
.expect("Failed to get pwsp-daemon output_fl");
let input_fl = pwsp_daemon_input
.clone()
.input_fl
.expect("Failed to get pwsp-daemon input_fl");
let input_fr = pwsp_daemon_input
.clone()
.input_fr
.expect("Failed to get pwsp-daemon input_fr");
create_link(output_fl, output_fr, input_fl, input_fr)?;
Ok(())
} }
pub fn get_runtime_dir() -> PathBuf { pub fn get_runtime_dir() -> PathBuf {
dirs::runtime_dir().unwrap_or(PathBuf::from("/run/pwsp")) dirs::runtime_dir().unwrap_or_else(|| {
let uid = get_current_uid();
env::temp_dir().join(format!("pwsp-{}", uid))
})
} }
pub fn create_runtime_dir() -> Result<(), Box<dyn Error>> { pub fn create_runtime_dir() -> Result<()> {
let runtime_dir = get_runtime_dir(); let runtime_dir = get_runtime_dir();
if !runtime_dir.exists() {
fs::create_dir_all(&runtime_dir)?; if runtime_dir.exists() {
let meta = fs::symlink_metadata(&runtime_dir)?;
if meta.is_symlink() {
return Err(anyhow::anyhow!("Runtime directory is a symlink"));
}
let uid = get_current_uid();
if meta.uid() != uid {
return Err(anyhow::anyhow!(
"Runtime directory is owned by another user"
));
}
if meta.permissions().mode() & 0o777 != 0o700 {
return Err(anyhow::anyhow!(
"Runtime directory has incorrect permissions"
));
}
} else {
fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(&runtime_dir)?;
} }
Ok(()) Ok(())
} }
pub fn is_daemon_running() -> Result<bool, Box<dyn Error>> { pub fn is_daemon_running() -> Result<bool> {
let lock_file = fs::File::create(get_runtime_dir().join("daemon.lock"))?; let lock_file = fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(get_runtime_dir().join("daemon.lock"))?;
match lock_file.try_lock() { match lock_file.try_lock() {
Ok(_) => Ok(false), Ok(_) => Ok(false),
Err(_) => Ok(true), Err(_) => Ok(true),
} }
} }
pub async fn wait_for_daemon() -> Result<(), Box<dyn Error>> { pub async fn wait_for_daemon() -> Result<()> {
if is_daemon_running()? { if is_daemon_running()? {
return Ok(()); return Ok(());
} }
@@ -146,6 +126,14 @@ pub async fn make_request(request: Request) -> Result<Response, Box<dyn Error +
} }
let response_len = u32::from_le_bytes(len_bytes) as usize; let response_len = u32::from_le_bytes(len_bytes) as usize;
if response_len > MAX_MESSAGE_SIZE {
eprintln!(
"Failed to read response from daemon: response too large ({} bytes)!",
response_len
);
return Err("Response too large".into());
}
let mut buffer = vec![0u8; response_len]; let mut buffer = vec![0u8; response_len];
if stream.read_exact(&mut buffer).await.is_err() { if stream.read_exact(&mut buffer).await.is_err() {
return Err("Failed to read response".into()); return Err("Failed to read response".into());
+201
View File
@@ -0,0 +1,201 @@
use crate::{types::config::HotkeyConfig, utils::commands::parse_command};
use evdev::{Device, EventStream, EventSummary, KeyCode};
struct ModifierState {
ctrl: bool,
alt: bool,
shift: bool,
meta: bool,
}
impl ModifierState {
fn new() -> Self {
Self {
ctrl: false,
alt: false,
shift: false,
meta: false,
}
}
fn update(&mut self, key: KeyCode, pressed: bool) {
match key {
KeyCode::KEY_LEFTCTRL | KeyCode::KEY_RIGHTCTRL => self.ctrl = pressed,
KeyCode::KEY_LEFTALT | KeyCode::KEY_RIGHTALT => self.alt = pressed,
KeyCode::KEY_LEFTSHIFT | KeyCode::KEY_RIGHTSHIFT => self.shift = pressed,
KeyCode::KEY_LEFTMETA | KeyCode::KEY_RIGHTMETA => self.meta = pressed,
_ => {}
}
}
fn any_active(&self) -> bool {
self.ctrl || self.alt || self.shift || self.meta
}
fn is_modifier(key: KeyCode) -> bool {
matches!(
key,
KeyCode::KEY_LEFTCTRL
| KeyCode::KEY_RIGHTCTRL
| KeyCode::KEY_LEFTALT
| KeyCode::KEY_RIGHTALT
| KeyCode::KEY_LEFTSHIFT
| KeyCode::KEY_RIGHTSHIFT
| KeyCode::KEY_LEFTMETA
| KeyCode::KEY_RIGHTMETA
)
}
}
fn evdev_key_name(key: KeyCode) -> Option<&'static str> {
match key {
KeyCode::KEY_A => Some("A"),
KeyCode::KEY_B => Some("B"),
KeyCode::KEY_C => Some("C"),
KeyCode::KEY_D => Some("D"),
KeyCode::KEY_E => Some("E"),
KeyCode::KEY_F => Some("F"),
KeyCode::KEY_G => Some("G"),
KeyCode::KEY_H => Some("H"),
KeyCode::KEY_I => Some("I"),
KeyCode::KEY_J => Some("J"),
KeyCode::KEY_K => Some("K"),
KeyCode::KEY_L => Some("L"),
KeyCode::KEY_M => Some("M"),
KeyCode::KEY_N => Some("N"),
KeyCode::KEY_O => Some("O"),
KeyCode::KEY_P => Some("P"),
KeyCode::KEY_Q => Some("Q"),
KeyCode::KEY_R => Some("R"),
KeyCode::KEY_S => Some("S"),
KeyCode::KEY_T => Some("T"),
KeyCode::KEY_U => Some("U"),
KeyCode::KEY_V => Some("V"),
KeyCode::KEY_W => Some("W"),
KeyCode::KEY_X => Some("X"),
KeyCode::KEY_Y => Some("Y"),
KeyCode::KEY_Z => Some("Z"),
KeyCode::KEY_1 => Some("1"),
KeyCode::KEY_2 => Some("2"),
KeyCode::KEY_3 => Some("3"),
KeyCode::KEY_4 => Some("4"),
KeyCode::KEY_5 => Some("5"),
KeyCode::KEY_6 => Some("6"),
KeyCode::KEY_7 => Some("7"),
KeyCode::KEY_8 => Some("8"),
KeyCode::KEY_9 => Some("9"),
KeyCode::KEY_0 => Some("0"),
KeyCode::KEY_F1 => Some("F1"),
KeyCode::KEY_F2 => Some("F2"),
KeyCode::KEY_F3 => Some("F3"),
KeyCode::KEY_F4 => Some("F4"),
KeyCode::KEY_F5 => Some("F5"),
KeyCode::KEY_F6 => Some("F6"),
KeyCode::KEY_F7 => Some("F7"),
KeyCode::KEY_F8 => Some("F8"),
KeyCode::KEY_F9 => Some("F9"),
KeyCode::KEY_F10 => Some("F10"),
KeyCode::KEY_F11 => Some("F11"),
KeyCode::KEY_F12 => Some("F12"),
_ => None,
}
}
fn build_chord(modifiers: &ModifierState, key_name: &str) -> String {
let mut parts = Vec::with_capacity(5);
if modifiers.ctrl {
parts.push("Ctrl");
}
if modifiers.alt {
parts.push("Alt");
}
if modifiers.shift {
parts.push("Shift");
}
if modifiers.meta {
parts.push("Super");
}
parts.push(key_name);
parts.join("+")
}
fn is_keyboard(device: &Device) -> bool {
device
.supported_keys()
.is_some_and(|keys| keys.contains(KeyCode::KEY_A) && keys.contains(KeyCode::KEY_Z))
}
async fn handle_device_events(mut stream: EventStream) {
let mut modifiers = ModifierState::new();
loop {
match stream.next_event().await {
Ok(event) => {
if let EventSummary::Key(_, key, value) = event.destructure() {
// 0 = released, 1 = pressed, 2 = repeat
if value == 0 || value == 1 {
modifiers.update(key, value == 1);
}
// Only trigger on press, skip modifiers and bare keys
if value != 1 || ModifierState::is_modifier(key) || !modifiers.any_active() {
continue;
}
let Some(key_name) = evdev_key_name(key) else {
continue;
};
let chord = build_chord(&modifiers, key_name);
let config = match HotkeyConfig::load() {
Ok(c) => c,
Err(_) => continue,
};
let slots = config.slots_for_chord(&chord);
for slot in slots {
if let Some(cmd) = parse_command(&slot.action) {
cmd.execute().await;
}
}
}
}
Err(e) => {
eprintln!("Global hotkeys: device read error: {e}");
break;
}
}
}
}
pub async fn start_global_hotkey_listener() {
let keyboards: Vec<_> = evdev::enumerate()
.filter(|(_, dev)| is_keyboard(dev))
.collect();
if keyboards.is_empty() {
eprintln!(
"Global hotkeys: no keyboard devices found. \
Make sure your user is in the 'input' group."
);
return;
}
println!(
"Global hotkeys: found {} keyboard device(s)",
keyboards.len()
);
for (path, device) in keyboards {
match device.into_event_stream() {
Ok(stream) => {
println!("Global hotkeys: listening on {}", path.display());
tokio::spawn(handle_device_events(stream));
}
Err(e) => {
eprintln!("Global hotkeys: failed to open {}: {}", path.display(), e);
}
}
}
}
+67 -78
View File
@@ -1,16 +1,17 @@
use crate::{ use crate::{
types::{ types::{
audio_player::{PlayerState, TrackInfo}, audio_player::FullState,
config::GuiConfig, config::{GuiConfig, HotkeyConfig},
gui::AudioPlayerState, gui::AudioPlayerState,
socket::{Request, Response}, socket::{Request, Response},
}, },
utils::daemon::{is_daemon_running, make_request}, utils::daemon::{is_daemon_running, make_request},
}; };
use anyhow::{Result, anyhow};
use std::{ use std::{
collections::HashMap, path::PathBuf,
error::Error,
sync::{Arc, Mutex}, sync::{Arc, Mutex},
time::Instant,
}; };
use tokio::time::{Duration, sleep}; use tokio::time::{Duration, sleep};
@@ -22,11 +23,11 @@ pub fn get_gui_config() -> GuiConfig {
}) })
} }
pub fn make_request_sync(request: Request) -> Result<Response, Box<dyn Error>> { pub fn make_request_sync(request: Request) -> Result<Response> {
tokio::task::block_in_place(|| { tokio::task::block_in_place(|| {
tokio::runtime::Handle::current() tokio::runtime::Handle::current()
.block_on(make_request(request)) .block_on(make_request(request))
.map_err(|e| e as Box<dyn Error>) .map_err(|e| anyhow!(e))
}) })
} }
@@ -36,6 +37,17 @@ pub fn make_request_async(request: Request) {
}); });
} }
pub fn ensure_pwsp_audio_dir() -> PathBuf {
let audio_dir = dirs::audio_dir().unwrap_or("~/Music".into());
let pwsp_audio_dir = audio_dir.join("PWSP");
if !pwsp_audio_dir.exists() {
std::fs::create_dir_all(&pwsp_audio_dir).ok();
}
pwsp_audio_dir
}
pub fn format_time_pair(position: f32, duration: f32) -> String { pub fn format_time_pair(position: f32, duration: f32) -> String {
fn format_time(seconds: f32) -> String { fn format_time(seconds: f32) -> String {
let total_seconds = seconds.round() as u32; let total_seconds = seconds.round() as u32;
@@ -50,102 +62,79 @@ pub fn format_time_pair(position: f32, duration: f32) -> String {
pub fn start_app_state_thread(audio_player_state_shared: Arc<Mutex<AudioPlayerState>>) { pub fn start_app_state_thread(audio_player_state_shared: Arc<Mutex<AudioPlayerState>>) {
tokio::spawn(async move { tokio::spawn(async move {
let sleep_duration = Duration::from_secs_f32(1.0 / 60.0); let sleep_duration = Duration::from_secs_f32(1.0 / 60.0);
let mut last_hotkey_poll = Instant::now();
loop { loop {
let is_running = is_daemon_running().unwrap_or(false); let is_running = is_daemon_running().unwrap_or(false);
if !is_running { if !is_running {
{ {
let mut guard = audio_player_state_shared.lock().unwrap(); let mut guard = audio_player_state_shared
.lock()
.unwrap_or_else(|e| e.into_inner());
guard.is_daemon_running = false; guard.is_daemon_running = false;
} }
sleep(Duration::from_millis(500)).await; sleep(Duration::from_millis(500)).await;
continue; continue;
} }
let state_req = Request::get_state(); let full_state_req = Request::get_full_state();
let tracks_req = Request::get_tracks(); let full_state_res = make_request(full_state_req).await.unwrap_or_default();
let volume_req = Request::get_volume();
let current_input_req = Request::get_input();
let all_inputs_req = Request::get_inputs();
let (state_res, tracks_res, volume_res, current_input_res, all_inputs_res) = tokio::join!( if full_state_res.status {
make_request(state_req), let full_state: FullState =
make_request(tracks_req), serde_json::from_str(&full_state_res.message).unwrap_or_default();
make_request(volume_req),
make_request(current_input_req),
make_request(all_inputs_req),
);
let state_res = state_res.unwrap_or_default(); let mut guard = audio_player_state_shared
let tracks_res = tracks_res.unwrap_or_default(); .lock()
let volume_res = volume_res.unwrap_or_default(); .unwrap_or_else(|e| e.into_inner());
let current_input_res = current_input_res.unwrap_or_default();
let all_inputs_res = all_inputs_res.unwrap_or_default();
let state = match state_res.status {
true => serde_json::from_str::<PlayerState>(&state_res.message).unwrap(),
false => PlayerState::default(),
};
let tracks = match tracks_res.status {
true => {
serde_json::from_str::<Vec<TrackInfo>>(&tracks_res.message).unwrap_or_default()
}
false => vec![],
};
let volume = match volume_res.status {
true => volume_res.message.parse::<f32>().unwrap(),
false => 0.0,
};
let current_input = match current_input_res.status {
true => current_input_res
.message
.as_str()
.split(" - ")
.collect::<Vec<&str>>()
.first()
.unwrap()
.to_string(),
false => String::new(),
};
let all_inputs = match all_inputs_res.status {
true => all_inputs_res
.message
.as_str()
.split(';')
.filter_map(|entry| {
let entry = entry.trim();
if entry.is_empty() {
return None;
}
entry
.split_once(" - ")
.map(|(k, v)| (k.trim().to_string(), v.trim().to_string()))
})
.collect::<HashMap<String, String>>(),
false => HashMap::new(),
};
{
let mut guard = audio_player_state_shared.lock().unwrap();
guard.state = match guard.new_state.clone() { guard.state = match guard.new_state.clone() {
Some(new_state) => { Some(new_state) => {
guard.new_state = None; guard.new_state = None;
new_state new_state
} }
None => state, None => full_state.state,
}; };
guard.tracks = tracks.clone(); guard.tracks = full_state.tracks;
guard.volume = volume; guard.volume = full_state.volume;
guard.current_input = current_input; guard.current_input = full_state
guard.all_inputs = all_inputs; .current_input
.split(" - ")
.next()
.unwrap_or_default()
.to_string();
if guard.all_inputs != full_state.all_inputs {
guard.all_inputs = full_state.all_inputs;
let mut sorted: Vec<(String, String)> = guard
.all_inputs
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
sorted.sort_by(|a, b| a.0.cmp(&b.0));
guard.all_inputs_sorted = sorted;
}
guard.is_daemon_running = true; guard.is_daemon_running = true;
} }
// Poll hotkey config at a lower frequency (~every 2 seconds)
if last_hotkey_poll.elapsed() >= Duration::from_secs(2) {
let hotkey_res = make_request(Request::get_hotkeys())
.await
.unwrap_or_default();
if hotkey_res.status
&& let Ok(config) = serde_json::from_str::<HotkeyConfig>(&hotkey_res.message)
{
let mut guard = audio_player_state_shared
.lock()
.unwrap_or_else(|e| e.into_inner());
guard.hotkey_config = Some(config);
}
last_hotkey_poll = Instant::now();
}
sleep(sleep_duration).await; sleep(sleep_duration).await;
} }
}); });
+1
View File
@@ -1,5 +1,6 @@
pub mod commands; pub mod commands;
pub mod config; pub mod config;
pub mod daemon; pub mod daemon;
pub mod global_hotkeys;
pub mod gui; pub mod gui;
pub mod pipewire; pub mod pipewire;
+212 -138
View File
@@ -1,85 +1,88 @@
use crate::types::pipewire::{AudioDevice, DeviceType, Port, Terminate}; use crate::types::pipewire::{AudioDevice, DeviceType, Port, Terminate};
use anyhow::{Result, anyhow};
use pipewire::{ use pipewire::{
context::ContextRc, link::Link, main_loop::MainLoopRc, properties::properties, context::ContextRc, link::Link, main_loop::MainLoopRc, properties::properties,
registry::GlobalObject, spa::utils::dict::DictRef, registry::GlobalObject, spa::utils::dict::DictRef,
}; };
use std::{collections::HashMap, error::Error, thread}; use std::{collections::HashMap, thread};
use tokio::{ use tokio::{
sync::mpsc, sync::mpsc,
time::{Duration, timeout}, time::{Duration, timeout},
}; };
pub fn setup_pipewire_context() -> Result<(MainLoopRc, ContextRc), String> {
pipewire::init();
let main_loop = MainLoopRc::new(None).map_err(|e| e.to_string())?;
let context = ContextRc::new(&main_loop, None).map_err(|e| e.to_string())?;
Ok((main_loop, context))
}
fn parse_global_object( fn parse_global_object(
global_object: &GlobalObject<&DictRef>, global_object: &GlobalObject<&DictRef>,
) -> (Option<AudioDevice>, Option<Port>) { ) -> (Option<AudioDevice>, Option<Port>) {
// Only objects with props can be devices/ports let props = match global_object.props {
if let Some(props) = global_object.props { Some(p) => p,
// Only objects with media.class can be devices None => return (None, None),
};
if let Some(media_class) = props.get("media.class") { if let Some(media_class) = props.get("media.class") {
let node_id = global_object.id; let node_id = global_object.id;
let node_nick = props.get("node.nick"); let node_nick = props.get("node.nick");
let node_name = props.get("node.name"); let node_name = props.get("node.name");
let node_description = props.get("node.description"); let node_description = props.get("node.description");
// Check if the device is an input or output if media_class.starts_with("Audio/Source") {
return if media_class.starts_with("Audio/Source") { let input_device = AudioDevice::new(
let input_device = AudioDevice { node_id,
id: node_id, node_nick,
nick: node_nick node_description,
.unwrap_or(node_description.unwrap_or(node_name.unwrap_or_default())) node_name,
.to_string(), DeviceType::Input,
name: node_name.unwrap_or_default().to_string(), );
device_type: DeviceType::Input, return (Some(input_device), None);
input_fl: None,
input_fr: None,
output_fl: None,
output_fr: None,
};
(Some(input_device), None)
} else if media_class.starts_with("Stream/Output/Audio") { } else if media_class.starts_with("Stream/Output/Audio") {
let output_device = AudioDevice { let output_device = AudioDevice::new(
id: node_id, node_id,
nick: node_nick node_nick,
.unwrap_or(node_description.unwrap_or(node_name.unwrap_or_default())) node_description,
.to_string(), node_name,
name: node_name.unwrap_or_default().to_string(), DeviceType::Output,
device_type: DeviceType::Output, );
return (Some(output_device), None);
input_fl: None, }
input_fr: None, return (None, None);
output_fl: None, }
output_fr: None,
};
(Some(output_device), None)
} else {
(None, None)
};
// Check if the object is a port
} else if props.get("port.direction").is_some() {
let node_id = props.get("node.id").unwrap().parse::<u32>().unwrap();
let port_id = props.get("port.id").unwrap().parse::<u32>().unwrap();
let port_name = props.get("port.name").unwrap();
if props.get("port.direction").is_some()
&& let (Some(node_id), Some(port_id), Some(port_name)) = (
props.get("node.id").and_then(|id| id.parse::<u32>().ok()),
props.get("port.id").and_then(|id| id.parse::<u32>().ok()),
props.get("port.name"),
)
{
let port = Port { let port = Port {
node_id, node_id,
port_id, port_id,
name: port_name.to_string(), name: port_name.to_string(),
}; };
return (None, Some(port)); return (None, Some(port));
} }
}
(None, None) (None, None)
} }
async fn pw_get_global_objects_thread( async fn pw_get_global_objects_thread(
main_sender: mpsc::Sender<(Option<AudioDevice>, Option<Port>)>, main_sender: mpsc::Sender<(Option<AudioDevice>, Option<Port>)>,
pw_receiver: pipewire::channel::Receiver<Terminate>, pw_receiver: pipewire::channel::Receiver<Terminate>,
init_sender: tokio::sync::oneshot::Sender<Result<(), String>>,
) { ) {
pipewire::init(); let (main_loop, context) = match setup_pipewire_context() {
Ok(res) => res,
let main_loop = MainLoopRc::new(None).expect("Failed to initialize pipewire main loop"); Err(e) => {
let _ = init_sender.send(Err(e));
return;
}
};
// Stop main loop on Terminate message // Stop main loop on Terminate message
let _receiver = pw_receiver.attach(main_loop.loop_(), { let _receiver = pw_receiver.attach(main_loop.loop_(), {
@@ -87,13 +90,24 @@ async fn pw_get_global_objects_thread(
move |_| _main_loop.quit() move |_| _main_loop.quit()
}); });
let context = ContextRc::new(&main_loop, None).expect("Failed to create pipewire context"); let core = match context.connect(None) {
let core = context Ok(core) => core,
.connect(None) Err(e) => {
.expect("Failed to connect to pipewire context"); let _ = init_sender.send(Err(format!("Failed to connect to pipewire context: {}", e)));
let registry = core return;
.get_registry() }
.expect("Failed to get registry from pipewire context"); };
let registry = match core.get_registry() {
Ok(registry) => registry,
Err(e) => {
let _ = init_sender.send(Err(format!(
"Failed to get registry from pipewire context: {}",
e
)));
return;
}
};
let _listener = registry let _listener = registry
.add_listener_local() .add_listener_local()
@@ -109,17 +123,29 @@ async fn pw_get_global_objects_thread(
}) })
.register(); .register();
// Signal successful initialization
if init_sender.send(Ok(())).is_err() {
return;
}
main_loop.run(); main_loop.run();
} }
pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>), Box<dyn Error>> { pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
// Channels to communicate with pipewire thread // Channels to communicate with pipewire thread
let (main_sender, mut main_receiver) = mpsc::channel(10); let (main_sender, mut main_receiver) = mpsc::channel(10);
let (pw_sender, pw_receiver) = pipewire::channel::channel(); let (pw_sender, pw_receiver) = pipewire::channel::channel();
let (init_sender, init_receiver) = tokio::sync::oneshot::channel();
// Spawn pipewire thread in background // Spawn pipewire thread in background
let _pw_thread = let _pw_thread = tokio::spawn(async move {
tokio::spawn(async move { pw_get_global_objects_thread(main_sender, pw_receiver).await }); pw_get_global_objects_thread(main_sender, pw_receiver, init_sender).await
});
// Wait for initialization to complete
if let Err(e) = init_receiver.await {
return Err(anyhow!(e));
}
let mut input_devices: HashMap<u32, AudioDevice> = HashMap::new(); let mut input_devices: HashMap<u32, AudioDevice> = HashMap::new();
let mut output_devices: HashMap<u32, AudioDevice> = HashMap::new(); let mut output_devices: HashMap<u32, AudioDevice> = HashMap::new();
@@ -144,60 +170,23 @@ pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>), B
} }
Ok(None) | Err(_) => { Ok(None) | Err(_) => {
// Pipewire thread is finished and we can collect our devices // Pipewire thread is finished and we can collect our devices
pw_sender let _ = pw_sender.send(Terminate {});
.send(Terminate {})
.expect("Failed to terminate pipewire thread");
for port in ports { for port in ports {
let node_id = port.node_id; let node_id = port.node_id;
if input_devices.contains_key(&node_id) { if let Some(input_device) = input_devices.get_mut(&node_id) {
let input_device = input_devices.get_mut(&node_id).unwrap(); input_device.add_port(port);
match port.name.as_str() { } else if let Some(output_device) = output_devices.get_mut(&node_id) {
"input_FL" => input_device.input_fl = Some(port), output_device.add_port(port);
"input_FR" => input_device.input_fr = Some(port),
"output_FL" => input_device.output_fl = Some(port),
"output_FR" => input_device.output_fr = Some(port),
"capture_FL" => input_device.output_fl = Some(port),
"capture_FR" => input_device.output_fr = Some(port),
"input_MONO" => {
input_device.input_fl = Some(port.clone());
input_device.input_fr = Some(port)
}
"capture_MONO" => {
input_device.output_fl = Some(port.clone());
input_device.output_fr = Some(port);
}
_ => {}
}
} else if output_devices.contains_key(&node_id) {
let output_device = output_devices.get_mut(&node_id).unwrap();
match port.name.as_str() {
"input_FL" => output_device.input_fl = Some(port),
"input_FR" => output_device.input_fr = Some(port),
"output_FL" => output_device.output_fl = Some(port),
"output_FR" => output_device.output_fr = Some(port),
"capture_FL" => output_device.output_fl = Some(port),
"capture_FR" => output_device.output_fr = Some(port),
"output_MONO" => {
output_device.output_fl = Some(port.clone());
output_device.output_fr = Some(port)
}
"capture_MONO" => {
output_device.output_fl = Some(port.clone());
output_device.output_fr = Some(port)
}
_ => {}
}
} }
} }
let mut input_devices: Vec<AudioDevice> = input_devices.values().cloned().collect(); let mut input_devices: Vec<AudioDevice> = input_devices.into_values().collect();
let mut output_devices: Vec<AudioDevice> = let mut output_devices: Vec<AudioDevice> = output_devices.into_values().collect();
output_devices.values().cloned().collect();
input_devices.sort_by(|a, b| a.id.cmp(&b.id)); input_devices.sort_by_key(|a| a.id);
output_devices.sort_by(|a, b| a.id.cmp(&b.id)); output_devices.sort_by_key(|a| a.id);
return Ok((input_devices, output_devices)); return Ok((input_devices, output_devices));
} }
@@ -205,30 +194,41 @@ pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>), B
} }
} }
pub async fn get_device(device_name: &str) -> Result<AudioDevice, Box<dyn Error>> { pub async fn get_device(device_name: &str) -> Result<AudioDevice> {
let (mut input_devices, output_devices) = get_all_devices().await?; let (input_devices, output_devices) = get_all_devices().await?;
input_devices.extend(output_devices);
for device in input_devices { input_devices
if device.name == device_name { .into_iter()
return Ok(device); .chain(output_devices)
} .find(|device| {
} device.name == device_name
|| device.nick == device_name
Err("Device not found".into()) || device.name.contains(device_name)
|| device.nick.contains(device_name)
})
.ok_or_else(|| anyhow!("Device not found"))
} }
pub fn create_virtual_mic() -> Result<pipewire::channel::Sender<Terminate>, Box<dyn Error>> { pub fn create_virtual_mic() -> Result<pipewire::channel::Sender<Terminate>> {
let (pw_sender, pw_receiver) = pipewire::channel::channel::<Terminate>(); let (pw_sender, pw_receiver) = pipewire::channel::channel::<Terminate>();
let (init_sender, init_receiver) = std::sync::mpsc::sync_channel(0);
let _pw_thread = thread::spawn(move || { let _pw_thread = thread::spawn(move || {
pipewire::init(); let (main_loop, context) = match setup_pipewire_context() {
Ok(res) => res,
let main_loop = MainLoopRc::new(None).expect("Failed to initialize pipewire main loop"); Err(e) => {
let context = ContextRc::new(&main_loop, None).expect("Failed to create pipewire context"); let _ = init_sender.send(Err(e));
let core = context return;
.connect(None) }
.expect("Failed to connect to pipewire context"); };
let core = match context.connect(None) {
Ok(core) => core,
Err(e) => {
let _ =
init_sender.send(Err(format!("Failed to connect to pipewire context: {}", e)));
return;
}
};
let props = properties!( let props = properties!(
"factory.name" => "support.null-audio-sink", "factory.name" => "support.null-audio-sink",
@@ -240,9 +240,13 @@ pub fn create_virtual_mic() -> Result<pipewire::channel::Sender<Terminate>, Box<
"object.linger" => "false", // Destroy the node on app exit "object.linger" => "false", // Destroy the node on app exit
); );
let _node = core let _node = match core.create_object::<pipewire::node::Node>("adapter", &props) {
.create_object::<pipewire::node::Node>("adapter", &props) Ok(node) => node,
.expect("Failed to create virtual mic"); Err(e) => {
let _ = init_sender.send(Err(format!("Failed to create virtual mic: {}", e)));
return;
}
};
let _receiver = pw_receiver.attach(main_loop.loop_(), { let _receiver = pw_receiver.attach(main_loop.loop_(), {
let _main_loop = main_loop.clone(); let _main_loop = main_loop.clone();
@@ -250,28 +254,83 @@ pub fn create_virtual_mic() -> Result<pipewire::channel::Sender<Terminate>, Box<
}); });
println!("Virtual mic created"); println!("Virtual mic created");
if init_sender.send(Ok(())).is_err() {
return;
}
main_loop.run(); main_loop.run();
}); });
if let Err(e) = init_receiver.recv()? {
return Err(anyhow!(e));
}
Ok(pw_sender) Ok(pw_sender)
} }
pub async fn link_player_to_virtual_mic() -> Result<pipewire::channel::Sender<Terminate>> {
let pwsp_daemon_output = match get_device("pwsp-daemon").await {
Ok(device) => device,
Err(_) => {
return Err(anyhow!(
"Could not find alsa_playback.pwsp-daemon device, skipping device linking"
));
}
};
let pwsp_daemon_input = match get_device("pwsp-virtual-mic").await {
Ok(device) => device,
Err(_) => {
return Err(anyhow!(
"Could not find pwsp-virtual-mic device, skipping device linking"
));
}
};
let output_fl = match pwsp_daemon_output.output_fl {
Some(port) => port,
None => return Err(anyhow!("Failed to get pwsp-daemon output_fl")),
};
let output_fr = match pwsp_daemon_output.output_fr {
Some(port) => port,
None => return Err(anyhow!("Failed to get pwsp-daemon output_fr")),
};
let input_fl = match pwsp_daemon_input.input_fl {
Some(port) => port,
None => return Err(anyhow!("Failed to get pwsp-virtual-mic input_fl")),
};
let input_fr = match pwsp_daemon_input.input_fr {
Some(port) => port,
None => return Err(anyhow!("Failed to get pwsp-virtual-mic input_fr")),
};
create_link(output_fl, output_fr, input_fl, input_fr)
}
pub fn create_link( pub fn create_link(
output_fl: Port, output_fl: Port,
output_fr: Port, output_fr: Port,
input_fl: Port, input_fl: Port,
input_fr: Port, input_fr: Port,
) -> Result<pipewire::channel::Sender<Terminate>, Box<dyn Error>> { ) -> Result<pipewire::channel::Sender<Terminate>> {
let (pw_sender, pw_receiver) = pipewire::channel::channel::<Terminate>(); let (pw_sender, pw_receiver) = pipewire::channel::channel::<Terminate>();
let (init_sender, init_receiver) = std::sync::mpsc::sync_channel(0);
let _pw_thread = thread::spawn(move || { let _pw_thread = thread::spawn(move || {
pipewire::init(); let (main_loop, context) = match setup_pipewire_context() {
Ok(res) => res,
let main_loop = MainLoopRc::new(None).expect("Failed to initialize pipewire main loop"); Err(e) => {
let context = ContextRc::new(&main_loop, None).expect("Failed to create pipewire context"); let _ = init_sender.send(Err(e));
let core = context return;
.connect(None) }
.expect("Failed to connect to pipewire context"); };
let core = match context.connect(None) {
Ok(core) => core,
Err(e) => {
let _ =
init_sender.send(Err(format!("Failed to connect to pipewire context: {}", e)));
return;
}
};
let props_fl = properties! { let props_fl = properties! {
"link.output.node" => format!("{}", output_fl.node_id).as_str(), "link.output.node" => format!("{}", output_fl.node_id).as_str(),
@@ -286,12 +345,20 @@ pub fn create_link(
"link.input.port" => format!("{}", input_fr.port_id).as_str(), "link.input.port" => format!("{}", input_fr.port_id).as_str(),
}; };
let _link_fl = core let _link_fl = match core.create_object::<Link>("link-factory", &props_fl) {
.create_object::<Link>("link-factory", &props_fl) Ok(link) => link,
.expect("Failed to create link FL"); Err(e) => {
let _link_fr = core let _ = init_sender.send(Err(format!("Failed to create link FL: {}", e)));
.create_object::<Link>("link-factory", &props_fr) return;
.expect("Failed to create link FR"); }
};
let _link_fr = match core.create_object::<Link>("link-factory", &props_fr) {
Ok(link) => link,
Err(e) => {
let _ = init_sender.send(Err(format!("Failed to create link FR: {}", e)));
return;
}
};
let _receiver = pw_receiver.attach(main_loop.loop_(), { let _receiver = pw_receiver.attach(main_loop.loop_(), {
let _main_loop = main_loop.clone(); let _main_loop = main_loop.clone();
@@ -302,8 +369,15 @@ pub fn create_link(
"Link created: FL: {}-{} FR: {}-{}", "Link created: FL: {}-{} FR: {}-{}",
output_fl.node_id, input_fl.node_id, output_fr.node_id, input_fr.node_id output_fl.node_id, input_fl.node_id, output_fr.node_id, input_fr.node_id
); );
if init_sender.send(Ok(())).is_err() {
return;
}
main_loop.run(); main_loop.run();
}); });
if let Err(e) = init_receiver.recv()? {
return Err(anyhow!(e));
}
Ok(pw_sender) Ok(pw_sender)
} }