Compare commits

..

96 Commits

Author SHA1 Message Date
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
40 changed files with 4689 additions and 1042 deletions
+1
View File
@@ -0,0 +1 @@
custom: ['https://boosty.to/arabian']
+119
View File
@@ -0,0 +1,119 @@
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
- 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 release binaries
run: cargo build --release --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/release/$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
-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:
contents: write
@@ -10,29 +10,20 @@ on:
workflow_dispatch:
inputs:
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
jobs:
build-and-release:
prepare:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.tag.outputs.tag }}
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
# приоритет 1: входной параметр workflow_dispatch
INPUT_TAG="${{ github.event.inputs.tag || '' }}"
if [ -n "$INPUT_TAG" ]; then
echo "Using input tag: $INPUT_TAG"
@@ -40,7 +31,6 @@ jobs:
exit 0
fi
# приоритет 2: если запущено событием release
EVENT_TAG="${{ github.event.release.tag_name || '' }}"
if [ -n "$EVENT_TAG" ]; then
echo "Using event tag: $EVENT_TAG"
@@ -48,16 +38,14 @@ jobs:
exit 0
fi
# приоритет 3: если GITHUB_REF — refs/tags/...
if [[ "${GITHUB_REF:-}" =~ ^refs/tags/(.+)$ ]]; then
echo "Using GITHUB_REF tag: ${BASH_REMATCH[1]}"
echo "tag=${BASH_REMATCH[1]}" >> $GITHUB_OUTPUT
exit 0
fi
# приоритет 4: пробуем получить последний релиз через 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')
if [ -n "$TAG_NAME" ]; then
echo "Found latest release tag: $TAG_NAME"
@@ -74,16 +62,30 @@ jobs:
echo "ERROR: No tag determined. Provide a tag when running manually or ensure a release exists."
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
- name: Checkout code at tag
uses: actions/checkout@v4
with:
ref: ${{ steps.tag.outputs.tag || github.ref }}
ref: ${{ needs.prepare.outputs.tag }}
fetch-depth: 0
- name: Setup Rust toolchain
uses: actions-rs/toolchain@v1
with:
toolchain: stable
toolchain: 1.94.1
- name: Extract all binary names
id: cargo-meta
@@ -91,7 +93,6 @@ jobs:
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
@@ -103,11 +104,10 @@ jobs:
shell: bash
run: |
set -euo pipefail
TAG="${{ steps.tag.outputs.tag }}"
TAG="${{ needs.prepare.outputs.tag }}"
ARCHIVE_NAME="pwsp-${TAG}-linux-x64.zip"
echo "Creating archive: $ARCHIVE_NAME"
# читаем построчно список бинарников и формируем массив файлов
FILES=()
while IFS= read -r BIN; do
[ -z "$BIN" ] && continue
@@ -119,7 +119,6 @@ jobs:
exit 1
fi
# проверим, что все бинарники действительно есть
for f in "${FILES[@]}"; do
if [ ! -f "$f" ]; then
echo "Error: expected binary not found: $f" >&2
@@ -128,13 +127,57 @@ jobs:
echo "Will add: $f"
done
# создаём архив с бинарниками внутри как просто pwsp-gui, pwsp-daemon, pwsp-cli
zip -j "$ARCHIVE_NAME" "${FILES[@]}"
- name: Upload release archive
uses: softprops/action-gh-release@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
tag_name: ${{ steps.tag.outputs.tag }}
tag_name: ${{ needs.prepare.outputs.tag }}
files: |
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
- name: Upload Flatpak to release
uses: softprops/action-gh-release@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
tag_name: ${{ needs.prepare.outputs.tag }}
files: ru.arabianq.pwsp.flatpak
+2
View File
@@ -1,2 +1,4 @@
/target
.idea
packages/aur/bin/.git
packages/aur/standart/.git
View File
Generated
+1612 -397
View File
File diff suppressed because it is too large Load Diff
+63 -15
View File
@@ -1,6 +1,6 @@
[package]
name = "pwsp"
version = "1.4.0"
version = "1.7.0"
edition = "2024"
authors = ["arabian"]
description = "PWSP lets you play audio files through your microphone. Has both CLI and GUI clients."
@@ -12,22 +12,46 @@ keywords = ["soundpad", "pipewire", "linux", "cli", "gui"]
[dependencies]
tokio = { version = "1.49.0", features = ["full"] }
tokio = { version = "1.51.1", features = ["full"] }
async-trait = "0.1.89"
serde = { version = "1.0.228", features = ["derive"] }
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.0", default-features = false, features = [
"std",
"suggestions",
"help",
"usage",
"error-context",
"derive",
] }
dirs = "6.0.0"
itertools = "0.14.0"
rodio = { version = "0.21.1", default-features = false, features = ["symphonia-all", "playback"] }
rodio = { version = "0.22.2", default-features = false, features = [
"symphonia-all",
"playback",
] }
pipewire = "0.9.2"
rfd = { version = "0.17.2", default-features = false, features = ["xdg-portal"]}
evdev = { version = "0.13.2", features = ["tokio"] }
rfd = { version = "0.17.2", default-features = false, features = [
"xdg-portal",
] }
opener = { version = "0.8.4", features = ["reveal"] }
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"] }
egui_material_icons = "0.5.0"
egui = { version = "0.34.1", default-features = false, features = [
"default_fonts",
"rayon",
] }
eframe = { version = "0.34.1", default-features = false, features = [
"default_fonts",
"glow",
"x11",
"wayland",
] }
egui_material_icons = "0.6.0"
egui_dnd = "0.15.0"
[[bin]]
name = "pwsp-daemon"
@@ -50,10 +74,34 @@ panic = "abort"
[package.metadata.deb]
assets = [
["target/release/pwsp-daemon", "usr/bin/", "755"],
["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"],
]
[
"target/release/pwsp-daemon",
"usr/bin/",
"755",
],
[
"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",
],
]
+7 -2
View File
@@ -24,6 +24,9 @@ chats on platforms like **Discord, Zoom, or Teamspeak**.
* **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.
* **Collapsible Audio Tracks**: You can collapse every audio track to save space.
* **Drag and Drop Directories**: Reorder your sound directories easily using drag and drop.
* **Automatic Device Detection**: PWSP automatically detects when an input device is connected or disconnected and handles linking/unlinking.
# **⚙️ How It Works**
@@ -73,7 +76,7 @@ sudo dnf install pwsp
There is pwsp package in AUR.
You can install it using yay, paru or any other AUR helper.
```bash
paru pwsp
paru pwsp-bin # or paru pwsp to build it locally
```
## **Installing using cargo**
@@ -179,7 +182,6 @@ pwsp-cli --help
| Key | Action |
| :----------------------- | :--------------------------------------------------- |
| **Esc** | Close application |
| **Space** | Pause / Resume audio |
| **Backspace** | Stop all audio tracks |
| **Enter** | Play selected file (stops all other tracks) |
@@ -206,3 +208,6 @@ a [pull request](https://github.com/arabianq/pipewire-soundpad/pulls).
This project is licensed under
the [MIT License](https://github.com/arabianq/pipewire-soundpad/blob/main/LICENSE).
# **🤖 AI Wiki**
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/arabianq/pipewire-soundpad)
-1
View File
@@ -3,7 +3,6 @@ Description=Pipewire Soundpad Daemon
After=pipewire.service
[Service]
ExecStartPre=/usr/bin/sleep 10
ExecStart=/usr/bin/pwsp-daemon
Restart=no
RuntimeDirectory=pwsp
Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 84 KiB

+17
View File
@@ -0,0 +1,17 @@
pkgbase = pwsp-bin
pkgdesc = Lets you play audio files through your microphone (Pre-built binaries)
pkgver = 1.7.0
pkgrel = 2
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.7.0.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.7.0/pwsp-v1.7.0-linux-x64.zip
source = pipewire-soundpad-1.7.0.tar.gz :: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.7.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.7.0
pkgrel=2
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/icon.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"
}
+16
View File
@@ -0,0 +1,16 @@
pkgbase = pwsp
pkgdesc = Lets you play audio files through your microphone
pkgver = 1.7.0
pkgrel = 1
url = https://github.com/arabianq/pipewire-soundpad
arch = any
license = MIT
makedepends = clang
makedepends = rust
makedepends = cargo
makedepends = pipewire
makedepends = alsa-lib
source = https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.7.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.7.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 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/icon.png"
install -Dm644 "assets/pwsp-daemon.service" "${pkgdir}/usr/lib/systemd/user/pwsp-daemon.service"
}
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env python3
import argparse
import subprocess
if __name__ == "__main__":
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 pwps-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"])
@@ -0,0 +1,9 @@
[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=Audio;Utility;
Keywords=soundpad;pipewire;audio;
@@ -0,0 +1,25 @@
<?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>
<developer_name>arabian</developer_name>
<content_rating type="oars-1.1" />
</component>
+47
View File
@@ -0,0 +1,47 @@
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=host
- --device=all
- --device=dri
- --share=network
- --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-options:
build-args:
- --share=network
build-commands:
- cargo build --release
- 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: ../../
+2 -2
View File
@@ -4,7 +4,7 @@
%global cargo_install_lib 0
Name: pwsp
Version: 1.4.0
Version: 1.7.0
Release: %autorelease
Summary: Lets you play audio files through your microphone
@@ -52,4 +52,4 @@ install -Dm644 assets/pwsp-daemon.service %{buildroot}/usr/lib/systemd/user/pwsp
/usr/lib/systemd/user/pwsp-daemon.service
%changelog
%autochangelog
%autochangelog
+34 -3
View File
@@ -35,6 +35,8 @@ enum Commands {
enum Actions {
/// Ping the daemon
Ping,
/// Kill the daemon
Kill,
/// Pause audio playback
Pause {
#[clap(short, long)]
@@ -66,6 +68,10 @@ enum Actions {
#[clap(short, long)]
id: Option<u32>,
},
/// Play a sound by hotkey slot name
PlayHotkey { slot: String },
/// Remove a hotkey slot
ClearHotkey { slot: String },
}
#[derive(Subcommand, Debug)]
@@ -73,7 +79,10 @@ enum GetCommands {
/// Check if the player is paused
IsPaused,
/// Playback volume
Volume,
Volume {
#[clap(short, long)]
id: Option<u32>,
},
/// Playback position (in seconds)
Position {
#[clap(short, long)]
@@ -92,6 +101,12 @@ enum GetCommands {
Input,
/// All audio inputs
Inputs,
/// Version of the daemon
DaemonVersion,
/// Full player state
FullState,
/// All hotkey slots
Hotkeys,
}
#[derive(Subcommand, Debug)]
@@ -116,6 +131,10 @@ enum SetCommands {
#[clap(short, long)]
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 },
}
#[tokio::main]
@@ -127,6 +146,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
let request = match cli.command {
Commands::Action { action } => match action {
Actions::Ping => Request::ping(),
Actions::Kill => Request::kill(),
Actions::Pause { id } => Request::pause(id),
Actions::Resume { id } => Request::resume(id),
Actions::TogglePause { id } => Request::toggle_pause(id),
@@ -134,24 +154,35 @@ async fn main() -> Result<(), Box<dyn Error>> {
Actions::Play {
file_path,
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::PlayHotkey { slot } => Request::play_hotkey(&slot),
Actions::ClearHotkey { slot } => Request::clear_hotkey(&slot),
},
Commands::Get { parameter } => match parameter {
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::Duration { id } => Request::get_duration(id),
GetCommands::State => Request::get_state(),
GetCommands::Tracks => Request::get_tracks(),
GetCommands::Input => Request::get_input(),
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 {
SetCommands::Volume { volume, id } => Request::set_volume(volume, id),
SetCommands::Position { position, id } => Request::seek(position, id),
SetCommands::Input { name } => Request::set_input(&name),
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)
}
},
};
+75 -9
View File
@@ -6,9 +6,11 @@ use pwsp::{
create_runtime_dir, get_audio_player, get_daemon_config, get_runtime_dir,
is_daemon_running, link_player_to_virtual_mic,
},
global_hotkeys::start_global_hotkey_listener,
pipewire::create_virtual_mic,
},
};
use std::os::unix::fs::PermissionsExt;
use std::{error::Error, fs, time::Duration};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
@@ -26,8 +28,28 @@ async fn main() -> Result<(), Box<dyn Error>> {
get_daemon_config(); // Initialize daemon config
create_virtual_mic()?;
get_audio_player().await; // Initialize audio player
link_player_to_virtual_mic().await?;
if let Err(err) = get_audio_player().await {
eprintln!("Failed to initialize audio player: {}", err);
} // Initialize audio player
tokio::spawn(async {
let max_retries = 60;
for i in 0..=max_retries {
match link_player_to_virtual_mic().await {
Ok(_) => {
println!("Successfully linked player to virtual mic.");
break;
}
Err(e) => println!("{e}\t{i}/{max_retries}"),
}
sleep(Duration::from_millis(1000)).await;
}
});
tokio::spawn(async {
start_global_hotkey_listener().await;
});
let runtime_dir = get_runtime_dir();
@@ -35,11 +57,15 @@ async fn main() -> Result<(), Box<dyn Error>> {
lock_file.lock()?;
let socket_path = runtime_dir.join("daemon.sock");
if fs::metadata(&socket_path).is_ok() {
fs::remove_file(&socket_path)?;
if let Err(e) = fs::remove_file(&socket_path) {
if e.kind() != std::io::ErrorKind::NotFound {
return Err(e.into());
}
}
let listener = UnixListener::bind(&socket_path)?;
fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600))?;
println!(
"Daemon started. Listening on {}",
socket_path.to_str().unwrap_or_default()
@@ -79,13 +105,35 @@ async fn commands_loop(listener: UnixListener) -> Result<(), Box<dyn Error>> {
let request_len = u32::from_le_bytes(len_bytes) as usize;
if request_len > 10 * 1024 * 1024 {
eprintln!(
"Failed to read message from client: request too large ({} bytes)!",
request_len
);
return;
}
let mut buffer = vec![0u8; request_len];
if stream.read_exact(&mut buffer).await.is_err() {
eprintln!("Failed to read message from client!");
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) ----------
// ---------- Generate response (start) ----------
@@ -99,7 +147,13 @@ async fn commands_loop(listener: UnixListener) -> Result<(), Box<dyn Error>> {
// ---------- Generate response (end) ----------
// ---------- 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;
if stream.write_all(&response_len.to_le_bytes()).await.is_err() {
@@ -111,15 +165,27 @@ async fn commands_loop(listener: UnixListener) -> Result<(), Box<dyn Error>> {
return;
}
// ---------- Send response (end) ----------
if response.status && response.message.eq("killed") {
std::process::exit(0);
}
});
}
}
async fn player_loop() {
loop {
let mut audio_player = get_audio_player().await.lock().await;
audio_player.update().await;
match get_audio_player().await {
Ok(player_mutex) => {
let mut audio_player = player_mutex.lock().await;
audio_player.update().await;
}
Err(_err) => {
// To avoid spamming logs every 100ms when audio player fails to init
// we can just sleep, or you might prefer to print the error.
// Assuming it failed to initialize, no player update is possible.
}
}
sleep(Duration::from_millis(100)).await;
}
+552 -117
View File
@@ -1,14 +1,17 @@
use crate::gui::{SUPPORTED_EXTENSIONS, SoundpadGui};
use crate::gui::SoundpadGui;
use egui::{
Align, AtomExt, Button, Color32, ComboBox, FontFamily, Label, Layout, RichText, ScrollArea,
Slider, TextEdit, Ui, Vec2,
Align, AtomExt, Button, CollapsingHeader, Color32, ComboBox, CursorIcon, FontFamily, Grid,
Label, Layout, RichText, ScrollArea, Sense, Slider, TextEdit, Ui, Vec2,
};
use egui_dnd::dnd;
use egui_material_icons::icons::*;
use pwsp::types::socket::Request;
use pwsp::types::{audio_player::TrackInfo, gui::AppState};
use pwsp::utils::gui::{format_time_pair, make_request_async};
use std::{
path::{Path, PathBuf},
time::Instant,
};
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),
@@ -17,19 +20,33 @@ enum TrackAction {
Stop(u32),
}
enum HotkeyAction {
Remove(String),
Capture(String),
ClearChord(String),
Play(String),
}
impl SoundpadGui {
fn get_volume_icon(volume: f32) -> &'static str {
if volume > 0.7 {
icons::ICON_VOLUME_UP
ICON_VOLUME_UP.codepoint
} else if volume <= 0.0 {
icons::ICON_VOLUME_OFF
ICON_VOLUME_OFF.codepoint
} else if volume < 0.3 {
icons::ICON_VOLUME_MUTE
ICON_VOLUME_MUTE.codepoint
} else {
icons::ICON_VOLUME_DOWN
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);
}
pub fn draw_waiting_for_daemon(&mut self, ui: &mut Ui) {
ui.centered_and_justified(|ui| {
ui.label(
@@ -40,12 +57,38 @@ 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("Press a key combination (e.g. Ctrl+Alt+1)")
.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!("for slot '{}'", slot)
} else if let Some(path) = &self.app_state.assigning_hotkey_for_file {
format!(
"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("Press Escape to cancel");
});
}
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 = 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;
@@ -82,59 +125,306 @@ impl SoundpadGui {
self.config.save_to_file().ok();
}
// --------------------------------
ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
ui.label(format!("GUI version: {}", env!("CARGO_PKG_VERSION")));
});
});
}
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(())
pub fn draw_hotkeys(&mut self, ui: &mut Ui) {
let area_size = ui.available_size();
ui.vertical(|ui| {
ui.set_min_width(area_size.x);
ui.set_min_height(area_size.y);
ui.spacing_mut().item_spacing.y = 5.0;
// Header
ui.horizontal_top(|ui| {
let back_button = Button::new(ICON_ARROW_BACK).frame(false);
if ui.add(back_button).clicked() {
self.app_state.show_hotkeys = false;
}
ui.add_space(ui.available_width() / 2.0 - 40.0);
ui.label(RichText::new("Hotkeys").color(Color32::WHITE).monospace());
});
ui.separator();
// Search and Add Command
ui.horizontal(|ui| {
ui.menu_button(format!("{} Add Command", ICON_ADD.codepoint), |ui| {
let mut selected_cmd = None;
if ui.button("Toggle Pause").clicked() {
selected_cmd = Some(("cmd_toggle_pause", Request::toggle_pause(None)));
}
if ui.button("Stop Playback").clicked() {
selected_cmd = Some(("cmd_stop", Request::stop(None)));
}
if ui.button("Pause Playback").clicked() {
selected_cmd = Some(("cmd_pause", Request::pause(None)));
}
if ui.button("Resume Playback").clicked() {
selected_cmd = Some(("cmd_resume", Request::resume(None)));
}
if ui.button("Toggle Loop").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_sized(
[ui.available_width(), 22.0],
TextEdit::singleline(&mut self.app_state.hotkey_search_query)
.hint_text("Search hotkeys..."),
);
});
ui.separator();
ui.add_space(5.0);
let conflicts = self.app_state.hotkey_config.find_conflicts();
let conflict_slots: std::collections::HashSet<String> = conflicts
.iter()
.flat_map(|(a, b)| vec![a.clone(), b.clone()])
.collect();
let search = self.app_state.hotkey_search_query.to_lowercase();
// Slots table
let mut action: Option<HotkeyAction> = None;
let area_size = ui.available_size();
ScrollArea::vertical().show(ui, |ui| {
ui.set_min_width(area_size.x);
Grid::new("hotkeys_grid")
.striped(true)
.num_columns(4)
.max_col_width(area_size.x)
.min_col_width(area_size.x / 4.0)
.spacing([40.0, 10.0])
.show(ui, |ui| {
// Table header
ui.label(
RichText::new("Slot")
.strong()
.monospace()
.color(Color32::LIGHT_GRAY),
);
ui.label(
RichText::new("Sound")
.strong()
.monospace()
.color(Color32::LIGHT_GRAY),
);
ui.label(
RichText::new("Key Chord")
.strong()
.monospace()
.color(Color32::LIGHT_GRAY),
);
ui.label(
RichText::new("Actions")
.strong()
.monospace()
.color(Color32::LIGHT_GRAY),
);
ui.end_row();
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)
})
.cloned()
.collect();
for slot in &slots {
ui.horizontal(|ui| {
// Conflict badge
if conflict_slots.contains(&slot.slot) {
ui.label(
RichText::new(ICON_WARNING.codepoint)
.color(Color32::from_rgb(255, 165, 0)),
)
.on_hover_text("Key chord conflict");
}
// Slot name
let slot_text = RichText::new(&slot.slot).monospace();
ui.label(slot_text);
});
// Action description
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());
// Key chord
let chord_text = slot.key_chord.as_deref().unwrap_or("(none)");
ui.label(RichText::new(chord_text).monospace().color(
if slot.key_chord.is_some() {
Color32::from_rgb(100, 200, 100)
} else {
Color32::GRAY
},
));
ui.horizontal(|ui| {
// Delete button
if ui
.add(Button::new(ICON_DELETE).frame(false))
.on_hover_text("Remove slot")
.clicked()
{
action = Some(HotkeyAction::Remove(slot.slot.clone()));
}
// Set key chord button
if ui
.add(Button::new(ICON_KEYBOARD).frame(false))
.on_hover_text("Set key chord")
.clicked()
{
action = Some(HotkeyAction::Capture(slot.slot.clone()));
}
// Clear key chord
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()));
}
// Play button
if ui
.add(Button::new(ICON_PLAY_ARROW).frame(false))
.on_hover_text("Play")
.clicked()
{
action = Some(HotkeyAction::Play(slot.slot.clone()));
}
});
ui.end_row();
}
if slots.is_empty() {
ui.label("No hotkey slots configured.");
ui.label("");
ui.label("");
ui.label("");
ui.end_row();
}
});
});
if let Some(action) = action {
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);
}
}
}
});
}
fn draw_header(&mut self, ui: &mut Ui) {
ui.vertical_centered_justified(|ui| {
self.draw_controls(ui);
});
}
if self.audio_player_state.tracks.is_empty() {
ui.label("No tracks playing");
return;
}
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;
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(),
for track in tracks {
CollapsingHeader::new(
RichText::new(
track
.path
.file_stem()
.unwrap_or_default()
.to_str()
.unwrap_or_default(),
)
.color(Color32::WHITE)
.family(FontFamily::Monospace),
)
.color(Color32::WHITE)
.family(FontFamily::Monospace),
);
if let Some(act) = Self::draw_track_control(ui, &mut self.app_state, &track) {
action = Some(act);
.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();
}
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)),
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(
@@ -169,9 +459,9 @@ impl SoundpadGui {
ui.horizontal_top(|ui| {
// ---------- Play Button ----------
let play_button = Button::new(if track.paused {
icons::ICON_PLAY_ARROW
ICON_PLAY_ARROW
} else {
icons::ICON_PAUSE
ICON_PAUSE
})
.corner_radius(15.0);
@@ -188,9 +478,9 @@ impl SoundpadGui {
// ---------- Loop Button ----------
let loop_button = Button::new(
RichText::new(if track.looped {
icons::ICON_REPEAT_ONE
ICON_REPEAT_ONE
} else {
icons::ICON_REPEAT
ICON_REPEAT
})
.size(18.0),
)
@@ -248,7 +538,7 @@ impl SoundpadGui {
// --------------------------------
// ---------- Stop Button ---------
let stop_button = Button::new(icons::ICON_CLOSE).frame(false);
let stop_button = Button::new(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));
@@ -260,11 +550,37 @@ impl SoundpadGui {
}
fn draw_body(&mut self, ui: &mut Ui) {
let dirs_size = Vec2::new(ui.available_width() / 4.0, ui.available_height() - 40.0);
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);
ui.separator();
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);
@@ -279,10 +595,14 @@ impl SoundpadGui {
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() {
let mut dirs = self.app_state.dirs.clone();
dnd(ui, "dnd_directories").show_vec(&mut dirs, |ui, item, handle, _state| {
let path = item.clone();
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())
@@ -290,7 +610,7 @@ impl SoundpadGui {
let mut dir_button_text = RichText::new(name.clone());
if let Some(current_dir) = &self.app_state.current_dir {
if current_dir.eq(path) {
if current_dir.eq(&path) {
dir_button_text = dir_button_text.color(Color32::WHITE);
}
}
@@ -300,20 +620,52 @@ impl SoundpadGui {
let dir_button_response = ui.add(dir_button);
if dir_button_response.clicked() {
self.open_dir(path);
self.open_dir(&path);
}
let delete_dir_button = Button::new(icons::ICON_DELETE).frame(false);
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.remove_dir(&path.clone());
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, "Show"))
.clicked()
{
self.open_dir(&path);
}
if ui
.button(format!(
"{} {}",
ICON_OPEN_IN_BROWSER.codepoint, "Open in File Manager"
))
.clicked()
{
if let Err(e) = opener::open(&path) {
eprintln!("Failed to open file manager: {}", e);
}
}
ui.separator();
if ui
.button(format!("{} {}", ICON_DELETE.codepoint, "Remove"))
.clicked()
{
self.app_state.dirs_to_remove.insert(path.clone());
}
});
});
}
});
self.app_state.dirs = dirs;
ui.horizontal(|ui| {
let add_dirs_button = Button::new(icons::ICON_ADD).frame(false);
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();
@@ -334,12 +686,17 @@ impl SoundpadGui {
fn draw_files(&mut self, ui: &mut Ui, area_size: Vec2) {
ui.vertical(|ui| {
ui.horizontal(|ui| {
let search_field = ui.add_sized(
let search_field_response = 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);
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);
});
ui.separator();
@@ -349,74 +706,142 @@ impl SoundpadGui {
ui.set_min_height(area_size.y);
ui.vertical(|ui| {
let mut files: Vec<PathBuf> = self.app_state.files.iter().cloned().collect();
files.sort();
let files = self.get_filtered_files();
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()
.unwrap_or_default()
.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);
ui.horizontal(|ui| {
// Hotkey badge
let hotkey_badge = self.get_hotkey_badge(&entry_path);
if let Some(badge) = &hotkey_badge {
ui.label(
RichText::new(badge)
.small()
.monospace()
.color(Color32::from_rgb(100, 200, 100)),
);
}
}
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 {
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.clone());
}
// Context menu
file_button_response.context_menu(|ui| {
if ui
.button(format!("{} {}", ICON_BOLT.codepoint, "Play Solo"))
.clicked()
{
self.play_file(&entry_path, false);
self.app_state.selected_file = Some(entry_path.clone());
}
if ui
.button(format!("{} {}", ICON_ADD.codepoint, "Add New"))
.clicked()
{
self.play_file(&entry_path, true);
} else if i.modifiers.shift
self.app_state.selected_file = Some(entry_path.clone());
}
if ui
.button(format!(
"{} {}",
ICON_SWAP_HORIZ.codepoint, "Replace Last"
))
.clicked()
&& 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.clone());
}
ui.separator();
if ui
.button(format!(
"{} {}",
ICON_OPEN_IN_BROWSER.codepoint, "Show in File Manager"
))
.clicked()
{
if let Err(e) = opener::reveal(&entry_path) {
eprintln!("Failed to open file manager: {}", e);
}
}
ui.separator();
if ui
.button(format!(
"{} {}",
ICON_KEYBOARD.codepoint, "Assign Hotkey"
))
.clicked()
{
self.app_state.assigning_hotkey_for_file =
Some(entry_path.clone());
self.app_state.hotkey_capture_active = true;
ui.close();
}
});
self.app_state.selected_file = Some(entry_path);
}
});
}
});
});
});
}
fn get_hotkey_badge(&self, path: &PathBuf) -> Option<String> {
for slot in &self.app_state.hotkey_config.slots {
if slot.action.name == "play" {
if let Some(file_path_str) = slot.action.args.get("file_path") {
if Path::new(file_path_str) == path.as_path() {
if let Some(chord) = &slot.key_chord {
return Some(format!("[{}]", chord));
} else {
return Some(format!("[{}]", slot.slot));
}
}
}
}
}
None
}
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 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();
@@ -430,7 +855,7 @@ impl SoundpadGui {
)
.show_ui(ui, |ui| {
for (name, nick) in mics {
ui.selectable_value(&mut selected_input, name.to_owned(), nick);
ui.selectable_value(&mut selected_input, name.clone(), nick);
}
});
@@ -468,11 +893,21 @@ impl SoundpadGui {
}
// ------------------------------------------
ui.add_space(ui.available_width() - 18.0 - ui.spacing().item_spacing.x);
ui.add_space(ui.available_width() - 18.0 * 2.0 - ui.spacing().item_spacing.x * 2.0);
// ---------- Hotkeys button ----------
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)");
// --------------------------------
// ---------- Settings button ----------
let settings_button =
Button::new(icons::ICON_SETTINGS.atom_size(Vec2::new(18.0, 18.0))).frame(false);
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;
+337 -100
View File
@@ -1,122 +1,359 @@
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 = match key {
Key::A => "A",
Key::B => "B",
Key::C => "C",
Key::D => "D",
Key::E => "E",
Key::F => "F",
Key::G => "G",
Key::H => "H",
Key::I => "I",
Key::J => "J",
Key::K => "K",
Key::L => "L",
Key::M => "M",
Key::N => "N",
Key::O => "O",
Key::P => "P",
Key::Q => "Q",
Key::R => "R",
Key::S => "S",
Key::T => "T",
Key::U => "U",
Key::V => "V",
Key::W => "W",
Key::X => "X",
Key::Y => "Y",
Key::Z => "Z",
Key::Num0 => "0",
Key::Num1 => "1",
Key::Num2 => "2",
Key::Num3 => "3",
Key::Num4 => "4",
Key::Num5 => "5",
Key::Num6 => "6",
Key::Num7 => "7",
Key::Num8 => "8",
Key::Num9 => "9",
Key::F1 => "F1",
Key::F2 => "F2",
Key::F3 => "F3",
Key::F4 => "F4",
Key::F5 => "F5",
Key::F6 => "F6",
Key::F7 => "F7",
Key::F8 => "F8",
Key::F9 => "F9",
Key::F10 => "F10",
Key::F11 => "F11",
Key::F12 => "F12",
_ => 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 = match parts[parts.len() - 1] {
"A" => Key::A,
"B" => Key::B,
"C" => Key::C,
"D" => Key::D,
"E" => Key::E,
"F" => Key::F,
"G" => Key::G,
"H" => Key::H,
"I" => Key::I,
"J" => Key::J,
"K" => Key::K,
"L" => Key::L,
"M" => Key::M,
"N" => Key::N,
"O" => Key::O,
"P" => Key::P,
"Q" => Key::Q,
"R" => Key::R,
"S" => Key::S,
"T" => Key::T,
"U" => Key::U,
"V" => Key::V,
"W" => Key::W,
"X" => Key::X,
"Y" => Key::Y,
"Z" => Key::Z,
"0" => Key::Num0,
"1" => Key::Num1,
"2" => Key::Num2,
"3" => Key::Num3,
"4" => Key::Num4,
"5" => Key::Num5,
"6" => Key::Num6,
"7" => Key::Num7,
"8" => Key::Num8,
"9" => Key::Num9,
"F1" => Key::F1,
"F2" => Key::F2,
"F3" => Key::F3,
"F4" => Key::F4,
"F5" => Key::F5,
"F6" => Key::F6,
"F7" => Key::F7,
"F8" => Key::F8,
"F9" => Key::F9,
"F10" => Key::F10,
"F11" => Key::F11,
"F12" => Key::F12,
_ => return None,
};
Some((modifiers, key))
}
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) {
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;
}
// Try to capture a chord from any key press
let captured = ctx.input(|i| {
for event in &i.events {
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(&slot_name, &action));
make_request_async(Request::set_hotkey_key(&slot_name, &chord));
self.app_state
.hotkey_config
.set_slot(slot_name.clone(), action);
self.app_state
.hotkey_config
.set_key_chord(&slot_name, Some(chord));
}
self.app_state.hotkey_capture_active = false;
}
return;
}
ctx.input(|i| {
// Close app on espace
if i.key_pressed(Key::Escape) {
std::process::exit(0);
// Open/close settings
if !search_focused && self.key_pressed(ctx, Key::I) {
self.app_state.show_settings = !self.app_state.show_settings;
}
// Toggle hotkeys view
if !search_focused && self.key_pressed(ctx, Key::H) {
self.app_state.show_hotkeys = !self.app_state.show_hotkeys;
}
if !self.app_state.show_settings && !self.app_state.show_hotkeys {
// Pause / resume audio on space
if !search_focused && self.key_pressed(ctx, Key::Space) {
self.play_toggle();
}
// Open/close settings
if i.key_pressed(Key::I) {
self.app_state.show_settings = !self.app_state.show_settings;
// Stop all audio tracks on backspace
if !search_focused && self.key_pressed(ctx, Key::Backspace) {
self.stop(None);
}
if i.key_pressed(Key::Enter) && self.app_state.selected_file.is_some() {
let path = &self.app_state.selected_file.clone().unwrap();
if i.modifiers.ctrl {
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);
// Focus search field
if self.key_pressed(ctx, Key::Slash) {
if search_focused {
ctx.memory_mut(|m| {
m.request_focus(Id::NULL);
});
} else {
self.play_file(path, false);
self.app_state.force_focus_search = true;
}
}
if !self.app_state.show_settings {
// Pause / resume audio on space
if i.key_pressed(Key::Space) {
self.play_toggle();
}
// Stop all audio tracks on backspace
if i.key_pressed(Key::Backspace) {
self.stop(None);
}
// Focus search field
if i.key_pressed(Key::Slash) && self.app_state.search_field_id.is_some() {
self.app_state.force_focus_id = self.app_state.search_field_id;
}
// 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());
}
// Play selected file on Enter
if self.key_pressed(ctx, Key::Enter) {
if let Some(path) = self.app_state.selected_file.clone() {
if modifiers.ctrl {
self.play_file(&path, true);
} else if 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);
}
}
}
});
// Iterate through dirs and files with Ctrl + Up/Down
let arrow_up_pressed = self.key_pressed(ctx, Key::ArrowUp);
let arrow_down_pressed = self.key_pressed(ctx, Key::ArrowDown);
if modifiers.ctrl && (arrow_up_pressed || arrow_down_pressed) {
if 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 = self
.app_state
.current_dir
.as_ref()
.and_then(|cd| dirs.iter().position(|x| x == cd));
let new_dir_index =
match (current_dir_index, arrow_up_pressed, arrow_down_pressed) {
(Some(i), true, false) => (i + dirs.len() - 1) % dirs.len(),
(Some(i), false, true) => (i + 1) % dirs.len(),
(Some(i), true, true) => i,
(None, true, _) => dirs.len() - 1,
(None, false, true) => 0,
_ => return,
};
self.open_dir(&dirs[new_dir_index]);
} else if self.app_state.current_dir.is_some() {
let files = self.get_filtered_files();
if files.is_empty() {
return;
}
let current_files_index = self
.app_state
.selected_file
.as_ref()
.and_then(|f| files.iter().position(|x| x == f));
let new_files_index =
match (current_files_index, arrow_up_pressed, arrow_down_pressed) {
(Some(i), true, false) => (i + files.len() - 1) % files.len(),
(Some(i), false, true) => (i + 1) % files.len(),
(Some(i), true, true) => i,
(None, true, _) => files.len() - 1,
(None, false, true) => 0,
_ => return,
};
self.app_state.selected_file = Some(files[new_files_index].clone());
}
}
// 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);
}
}
// });
}
}
+73 -24
View File
@@ -4,10 +4,12 @@ mod update;
use eframe::{HardwareAcceleration, NativeOptions, icon_data::from_png_bytes, run_native};
use egui::{Context, Vec2, ViewportBuilder};
use itertools::Itertools;
use pwsp::{
types::{
audio_player::PlayerState,
config::GuiConfig,
config::HotkeyConfig,
gui::{AppState, AudioPlayerState},
socket::Request,
},
@@ -23,8 +25,8 @@ use std::{
sync::{Arc, Mutex},
};
const SUPPORTED_EXTENSIONS: [&str; 11] = [
"mp3", "wav", "ogg", "flac", "mp4", "m4a", "aac", "mov", "mkv", "webm", "avi",
const SUPPORTED_EXTENSIONS: [&str; 12] = [
"mp3", "wav", "ogg", "flac", "mp4", "m4a", "aac", "mov", "mkv", "mka", "webm", "avi",
];
struct SoundpadGui {
@@ -51,13 +53,17 @@ impl SoundpadGui {
};
soundpad_gui.app_state.dirs = config.dirs;
soundpad_gui.app_state.hotkey_config = HotkeyConfig::load().unwrap_or_default();
soundpad_gui
}
pub fn play_toggle(&mut self) {
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 {
PlayerState::Playing => (Some(PlayerState::Paused), Some(Request::pause(None))),
PlayerState::Paused => (Some(PlayerState::Playing), Some(Request::resume(None))),
@@ -70,7 +76,10 @@ impl SoundpadGui {
}
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.state = state;
}
@@ -87,37 +96,32 @@ impl SoundpadGui {
let file_dialog = FileDialog::new();
if let Some(paths) = file_dialog.pick_folders() {
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.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) {
self.app_state.current_dir = Some(path.clone());
self.app_state.files = path
.read_dir()
.unwrap()
.filter_map(|res| res.ok())
.map(|entry| entry.path())
.collect();
match path.read_dir() {
Ok(read_dir) => {
self.app_state.files = read_dir
.filter_map(|res| res.ok())
.map(|entry| entry.path())
.collect();
}
Err(e) => {
eprintln!("Failed to read directory {:?}: {}", path, e);
self.app_state.files.clear();
}
}
}
pub fn play_file(&mut self, path: &PathBuf, 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) {
@@ -145,6 +149,51 @@ impl SoundpadGui {
pub fn stop(&mut self, id: Option<u32>) {
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.files.iter().cloned().collect();
files.sort();
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 false;
}
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>> {
+45 -11
View File
@@ -8,7 +8,25 @@ use pwsp::{
use std::time::{Duration, Instant};
impl App for SoundpadGui {
fn update(&mut self, ctx: &Context, _frame: &mut EFrame) {
fn logic(&mut self, ctx: &Context, _frame: &mut EFrame) {
// 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.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 volume_requests = vec![];
@@ -57,11 +75,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();
}
// Handle scale factor changes
let old_scale_factor = self.config.scale_factor;
let new_scale_factor = ctx.zoom_factor().clamp(0.5, 2.0);
@@ -72,29 +98,37 @@ impl App for SoundpadGui {
self.config.save_to_file().ok();
}
// Handle input
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 {
self.draw_waiting_for_daemon(ui);
return;
}
if self.app_state.hotkey_capture_active {
self.draw_hotkey_capture(ui);
return;
}
if self.app_state.show_settings {
self.draw_settings(ui);
return;
}
self.draw(ui).ok();
if let Some(force_focus_id) = self.app_state.force_focus_id {
ui.memory_mut(|reder| {
reder.request_focus(force_focus_id);
});
self.app_state.force_focus_id = None;
if self.app_state.show_hotkeys {
self.draw_hotkeys(ui);
return;
}
self.draw(ui);
});
ctx.request_repaint_after_secs(1.0 / 60.0);
// Request repaint
ui.request_repaint_after_secs(1.0 / 60.0);
}
}
+110 -52
View File
@@ -1,11 +1,11 @@
use crate::{
types::pipewire::{AudioDevice, DeviceType, Terminate},
types::pipewire::{DeviceType, Terminate},
utils::{
daemon::get_daemon_config,
pipewire::{create_link, get_all_devices, get_device},
pipewire::{create_link, get_device},
},
};
use rodio::{Decoder, OutputStream, OutputStreamBuilder, Sink, Source};
use rodio::{Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source};
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
@@ -34,9 +34,18 @@ pub struct TrackInfo {
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 id: u32,
pub sink: Sink,
pub sink: Player,
pub path: PathBuf,
pub duration: Option<f32>,
pub looped: bool,
@@ -44,12 +53,12 @@ pub struct PlayingSound {
}
pub struct AudioPlayer {
pub stream_handle: OutputStream,
pub stream_handle: MixerDeviceSink,
pub tracks: HashMap<u32, PlayingSound>,
pub next_id: u32,
input_link_sender: Option<pipewire::channel::Sender<Terminate>>,
pub current_input_device: Option<AudioDevice>,
pub input_device_name: Option<String>,
pub volume: f32, // Master volume
}
@@ -58,15 +67,8 @@ impl AudioPlayer {
pub async fn new() -> Result<Self, Box<dyn Error>> {
let daemon_config = get_daemon_config();
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 stream_handle = DeviceSinkBuilder::open_default_sink()?;
let mut audio_player = AudioPlayer {
stream_handle,
@@ -74,12 +76,12 @@ impl AudioPlayer {
next_id: 1,
input_link_sender: None,
current_input_device: default_input_device.clone(),
input_device_name: daemon_config.default_input_name.clone(),
volume: default_volume,
};
if default_input_device.is_some() {
if audio_player.input_device_name.is_some() {
audio_player.link_devices().await?;
}
@@ -89,7 +91,10 @@ impl AudioPlayer {
fn abort_link_thread(&mut self) {
if let Some(sender) = &self.input_link_sender {
match sender.send(Terminate {}) {
Ok(_) => println!("Sent terminate signal to link thread"),
Ok(_) => {
println!("Sent terminate signal to link thread");
self.input_link_sender = None;
}
Err(_) => eprintln!("Failed to send terminate signal to link thread"),
}
}
@@ -98,42 +103,43 @@ impl AudioPlayer {
async fn link_devices(&mut self) -> Result<(), Box<dyn Error>> {
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");
return Ok(());
}
let (input_devices, _) = get_all_devices().await?;
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");
let daemon_input;
if let Ok(device) = get_device("pwsp-virtual-mic").await {
daemon_input = device;
} else {
eprintln!("Could not find pwsp-virtual-mic device, skipping device linking");
return Ok(());
}
let pwsp_daemon_input = pwsp_daemon_input.unwrap();
let current_input_device = self.current_input_device.clone().unwrap();
let Some(output_fl) = current_input_device.output_fl.clone() else {
let Some(output_fl) = input_device.output_fl.clone() else {
eprintln!("Failed to get pwsp-daemon output_fl");
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");
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");
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");
return Ok(());
};
@@ -202,6 +208,18 @@ impl AudioPlayer {
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) {
return Some(sound.sink.volume());
} else {
return None;
}
} else {
return Some(self.volume);
}
}
pub fn set_volume(&mut self, volume: f32, id: Option<u32>) {
if let Some(id) = id {
if let Some(sound) = self.tracks.get_mut(&id) {
@@ -260,12 +278,22 @@ impl AudioPlayer {
file_path: &Path,
concurrent: bool,
) -> Result<u32, Box<dyn Error>> {
if !file_path.exists() {
return Err(format!("File does not exist: {}", file_path.display()).into());
}
let path_buf = file_path.to_path_buf();
let file = fs::File::open(file_path)?;
match Decoder::try_from(file) {
let decoder_result =
tokio::task::spawn_blocking(move || -> Result<_, Box<dyn Error + Send + Sync>> {
if !path_buf.exists() {
return Err(format!("File does not exist: {}", path_buf.display()).into());
}
let file = fs::File::open(&path_buf)?;
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) => {
if !concurrent {
self.tracks.clear();
@@ -276,7 +304,7 @@ impl AudioPlayer {
let duration = source.total_duration().map(|d| d.as_secs_f32());
let sink = Sink::connect_new(self.stream_handle.mixer());
let sink = Player::connect_new(self.stream_handle.mixer());
sink.set_volume(self.volume); // Default volume is 1.0 * master
sink.append(source);
sink.play();
@@ -292,11 +320,9 @@ impl AudioPlayer {
self.tracks.insert(id, sound);
self.link_devices().await?;
Ok(id)
}
Err(err) => Err(err.into()),
Err(err) => Err(err as Box<dyn Error>),
}
}
@@ -333,6 +359,23 @@ impl AudioPlayer {
}
pub async fn update(&mut self) {
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() {
// Selected input device was removed
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();
}
}
// Handle looped sounds
let mut restarts = vec![];
for (id, sound) in &self.tracks {
@@ -341,13 +384,28 @@ impl AudioPlayer {
}
}
let mut restart_futures = vec![];
for id in restarts {
if let Some(sound) = self.tracks.get_mut(&id) {
if let Ok(file) = fs::File::open(&sound.path) {
if let Ok(source) = Decoder::try_from(file) {
sound.sink.append(source);
sound.sink.play();
if let Some(sound) = self.tracks.get(&id) {
let path = sound.path.clone();
let handle = tokio::task::spawn_blocking(move || {
if let Ok(file) = fs::File::open(&path) {
if let Ok(source) = Decoder::try_from(file) {
return Some((id, source));
}
}
None
});
restart_futures.push(handle);
}
}
for handle in restart_futures {
if let Ok(Some((id, source))) = handle.await {
if let Some(sound) = self.tracks.get_mut(&id) {
sound.sink.append(source);
sound.sink.play();
}
}
}
@@ -363,7 +421,7 @@ impl AudioPlayer {
return Err("Selected device is not an input device".into());
}
self.current_input_device = Some(input_device);
self.input_device_name = Some(name.to_string());
self.link_devices().await?;
+374 -31
View File
@@ -1,9 +1,17 @@
use crate::{
types::{audio_player::PlayerState, socket::Response},
utils::{daemon::get_audio_player, pipewire::get_all_devices},
types::{
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 std::path::PathBuf;
use std::{collections::HashMap, path::PathBuf};
#[async_trait]
pub trait Executable {
@@ -12,6 +20,8 @@ pub trait Executable {
pub struct PingCommand {}
pub struct KillCommand {}
pub struct PauseCommand {
pub id: Option<u32>,
}
@@ -32,7 +42,9 @@ pub struct IsPausedCommand {}
pub struct GetStateCommand {}
pub struct GetVolumeCommand {}
pub struct GetVolumeCommand {
pub id: Option<u32>,
}
pub struct SetVolumeCommand {
pub volume: Option<f32>,
@@ -76,6 +88,39 @@ pub struct ToggleLoopCommand {
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 SetHotkeyKeyCommand {
pub slot: Option<String>,
pub key_chord: Option<String>,
}
pub struct ClearHotkeyCommand {
pub slot: Option<String>,
}
pub struct PlayHotkeyCommand {
pub slot: Option<String>,
}
pub struct SetHotkeyActionCommand {
pub slot: Option<String>,
pub action: Option<Request>,
}
pub struct ClearHotkeyKeyCommand {
pub slot: Option<String>,
}
#[async_trait]
impl Executable for PingCommand {
async fn execute(&self) -> Response {
@@ -83,10 +128,20 @@ impl Executable for PingCommand {
}
}
#[async_trait]
impl Executable for KillCommand {
async fn execute(&self) -> Response {
Response::new(true, "killed")
}
}
#[async_trait]
impl Executable for PauseCommand {
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);
Response::new(true, "Audio was paused")
}
@@ -95,7 +150,10 @@ impl Executable for PauseCommand {
#[async_trait]
impl Executable for ResumeCommand {
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);
Response::new(true, "Audio was resumed")
}
@@ -104,7 +162,10 @@ impl Executable for ResumeCommand {
#[async_trait]
impl Executable for TogglePauseCommand {
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 {
return Response::new(false, "Audio is not playing");
@@ -142,7 +203,10 @@ impl Executable for TogglePauseCommand {
#[async_trait]
impl Executable for StopCommand {
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);
Response::new(true, "Audio was stopped")
}
@@ -151,7 +215,10 @@ impl Executable for StopCommand {
#[async_trait]
impl Executable for IsPausedCommand {
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();
Response::new(true, is_paused)
}
@@ -160,18 +227,32 @@ impl Executable for IsPausedCommand {
#[async_trait]
impl Executable for GetStateCommand {
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();
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]
impl Executable for GetVolumeCommand {
async fn execute(&self) -> Response {
let audio_player = get_audio_player().await.lock().await;
let volume = audio_player.volume;
Response::new(true, volume.to_string())
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)),
};
let volume = audio_player.get_volume(self.id);
if let Some(volume) = volume {
Response::new(true, volume.to_string())
} else {
Response::new(false, "Failed to get volume")
}
}
}
@@ -179,7 +260,10 @@ impl Executable for GetVolumeCommand {
impl Executable for SetVolumeCommand {
async fn execute(&self) -> Response {
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);
Response::new(true, format!("Audio volume was set to {}", volume))
} else {
@@ -191,7 +275,10 @@ impl Executable for SetVolumeCommand {
#[async_trait]
impl Executable for GetPositionCommand {
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);
Response::new(true, position.to_string())
}
@@ -201,7 +288,10 @@ impl Executable for GetPositionCommand {
impl Executable for SeekCommand {
async fn execute(&self) -> Response {
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) {
Ok(_) => Response::new(true, format!("Audio position was set to {}", position)),
Err(err) => Response::new(false, err.to_string()),
@@ -215,7 +305,10 @@ impl Executable for SeekCommand {
#[async_trait]
impl Executable for GetDurationCommand {
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) {
Ok(duration) => Response::new(true, duration.to_string()),
Err(err) => Response::new(false, err.to_string()),
@@ -227,7 +320,10 @@ impl Executable for GetDurationCommand {
impl Executable for PlayCommand {
async fn execute(&self) -> Response {
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
.play(file_path, self.concurrent.unwrap_or(false))
.await
@@ -244,21 +340,34 @@ impl Executable for PlayCommand {
#[async_trait]
impl Executable for GetTracksCommand {
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();
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]
impl Executable for GetCurrentInputCommand {
async fn execute(&self) -> Response {
let audio_player = get_audio_player().await.lock().await;
if let Some(input_device) = &audio_player.current_input_device {
Response::new(
true,
format!("{} - {}", input_device.name, input_device.nick),
)
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(input_device_name) = &audio_player.input_device_name {
if let Ok(input_device) = get_device(input_device_name).await {
Response::new(
true,
format!("{} - {}", input_device.name, input_device.nick),
)
} else {
Response::new(false, "Failed to get current input device")
}
} else {
Response::new(false, "No input device selected")
}
@@ -268,7 +377,10 @@ impl Executable for GetCurrentInputCommand {
#[async_trait]
impl Executable for GetAllInputsCommand {
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![];
for device in input_devices {
if device.name == "pwsp-virtual-mic" {
@@ -288,7 +400,10 @@ impl Executable for GetAllInputsCommand {
impl Executable for SetCurrentInputCommand {
async fn execute(&self) -> Response {
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 {
Ok(_) => Response::new(true, "Input device was set"),
Err(err) => Response::new(false, err.to_string()),
@@ -302,7 +417,10 @@ impl Executable for SetCurrentInputCommand {
#[async_trait]
impl Executable for SetLoopCommand {
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 {
Some(enabled) => {
@@ -317,7 +435,10 @@ impl Executable for SetLoopCommand {
#[async_trait]
impl Executable for ToggleLoopCommand {
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(track) = audio_player.tracks.get_mut(&id) {
track.looped = !track.looped;
@@ -334,3 +455,225 @@ 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 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 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 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".to_string())
}
}
}
#[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 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)),
}
}
}
+135 -12
View File
@@ -1,8 +1,9 @@
use crate::utils::config::get_config_path;
use crate::{types::socket::Request, utils::config::get_config_path};
use serde::{Deserialize, Serialize};
use std::{collections::HashSet, error::Error, fs, path::PathBuf};
use std::{collections::HashMap, error::Error, fs, path::PathBuf};
#[derive(Default, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DaemonConfig {
pub default_input_name: Option<String>,
pub default_volume: Option<f32>,
@@ -11,10 +12,11 @@ pub struct DaemonConfig {
impl DaemonConfig {
pub fn save_to_file(&self) -> Result<(), Box<dyn Error>> {
let config_path = get_config_path()?.join("daemon.json");
let config_dir = config_path.parent().unwrap();
if !config_path.exists() {
fs::create_dir_all(config_dir)?;
if let Some(config_dir) = config_path.parent() {
if !config_path.exists() {
fs::create_dir_all(config_dir)?;
}
}
let config_json = serde_json::to_string_pretty(self)?;
@@ -25,33 +27,39 @@ impl DaemonConfig {
pub fn load_from_file() -> Result<DaemonConfig, Box<dyn Error>> {
let config_path = get_config_path()?.join("daemon.json");
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, Serialize, Deserialize)]
#[serde(default)]
pub struct GuiConfig {
pub scale_factor: f32,
pub left_panel_width: f32,
pub save_volume: bool,
pub save_input: bool,
pub save_scale_factor: bool,
pub pause_on_exit: bool,
pub dirs: HashSet<PathBuf>,
pub dirs: Vec<PathBuf>,
}
impl Default for GuiConfig {
fn default() -> Self {
GuiConfig {
scale_factor: 1.0,
left_panel_width: 280.0,
save_volume: false,
save_input: false,
save_scale_factor: false,
pause_on_exit: false,
dirs: HashSet::default(),
dirs: vec![],
}
}
}
@@ -59,10 +67,11 @@ impl Default for GuiConfig {
impl GuiConfig {
pub fn save_to_file(&mut self) -> Result<(), Box<dyn Error>> {
let config_path = get_config_path()?.join("gui.json");
let config_dir = config_path.parent().unwrap();
if !config_path.exists() {
fs::create_dir_all(config_dir)?;
if let Some(config_dir) = config_path.parent() {
if !config_path.exists() {
fs::create_dir_all(config_dir)?;
}
}
// Do not save scale factor if user does not want to
@@ -78,6 +87,120 @@ impl GuiConfig {
pub fn load_from_file() -> Result<GuiConfig, Box<dyn Error>> {
let config_path = get_config_path()?.join("gui.json");
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,
#[serde(skip_serializing_if = "Option::is_none")]
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, Box<dyn Error>> {
Ok(get_config_path()?.join("hotkeys.json"))
}
pub fn load() -> Result<HotkeyConfig, Box<dyn Error>> {
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(_) => Ok(HotkeyConfig::default()),
}
}
pub fn save(&self) -> Result<(), Box<dyn Error>> {
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<(String, String)> {
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].to_string(), slots[j].to_string()));
}
}
}
}
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()
}
}
+18 -4
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;
@@ -28,19 +31,27 @@ pub struct AppState {
pub show_settings: bool,
pub volume_dragged: bool,
pub force_focus_search: bool,
pub volume_slider_value: f32,
pub search_field_id: Option<Id>,
pub ignore_volume_update_until: Option<Instant>,
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 files: HashSet<PathBuf>,
pub search_field_id: Option<Id>,
pub force_focus_id: Option<Id>,
pub show_hotkeys: bool,
pub hotkey_config: HotkeyConfig,
pub hotkey_search_query: String,
pub assigning_hotkey_slot: Option<String>,
pub assigning_hotkey_for_file: Option<PathBuf>,
pub hotkey_capture_active: bool,
}
#[derive(Default, Debug, Clone)]
@@ -54,6 +65,9 @@ pub struct AudioPlayerState {
pub current_input: String,
pub all_inputs: HashMap<String, String>,
pub all_inputs_sorted: Vec<(String, String)>,
pub is_daemon_running: bool,
pub hotkey_config: Option<HotkeyConfig>,
}
+56 -3
View File
@@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Request {
pub name: String,
pub args: HashMap<String, String>,
@@ -24,6 +24,10 @@ impl Request {
Request::new("ping", vec![])
}
pub fn kill() -> Self {
Request::new("kill", vec![])
}
pub fn pause(id: Option<u32>) -> Self {
let mut args = vec![];
let id_str;
@@ -78,8 +82,14 @@ impl Request {
Request::new("is_paused", vec![])
}
pub fn get_volume() -> Self {
Request::new("get_volume", vec![])
pub fn get_volume(id: Option<u32>) -> Self {
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 {
@@ -155,6 +165,49 @@ impl Request {
}
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)])
}
}
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
+38 -1
View File
@@ -7,13 +7,14 @@ pub fn parse_command(request: &Request) -> Option<Box<dyn Executable + Send>> {
match request.name.as_str() {
"ping" => Some(Box::new(PingCommand {})),
"kill" => Some(Box::new(KillCommand {})),
"pause" => Some(Box::new(PauseCommand { id })),
"resume" => Some(Box::new(ResumeCommand { id })),
"toggle_pause" => Some(Box::new(TogglePauseCommand { id })),
"stop" => Some(Box::new(StopCommand { id })),
"is_paused" => Some(Box::new(IsPausedCommand {})),
"get_state" => Some(Box::new(GetStateCommand {})),
"get_volume" => Some(Box::new(GetVolumeCommand {})),
"get_volume" => Some(Box::new(GetVolumeCommand { id })),
"set_volume" => {
let volume = request
.args
@@ -69,6 +70,42 @@ pub fn parse_command(request: &Request) -> Option<Box<dyn Executable + Send>> {
Some(Box::new(SetLoopCommand { enabled, 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 }))
}
_ => None,
}
}
+21 -32
View File
@@ -2,11 +2,11 @@ use crate::{
types::{
audio_player::AudioPlayer,
config::DaemonConfig,
pipewire::AudioDevice,
socket::{Request, Response},
},
utils::pipewire::{create_link, get_all_devices},
utils::pipewire::{create_link, get_device},
};
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::{error::Error, fs};
use tokio::{
@@ -18,11 +18,14 @@ use tokio::{
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
.get_or_init(|| async {
.get_or_try_init(|| async {
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
}
@@ -36,37 +39,22 @@ pub fn get_daemon_config() -> DaemonConfig {
}
pub async fn link_player_to_virtual_mic() -> Result<(), Box<dyn Error>> {
let (input_devices, output_devices) = get_all_devices().await?;
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;
}
let pwsp_daemon_output;
if let Ok(device) = get_device("pwsp-daemon").await {
pwsp_daemon_output = device;
} else {
return Err(
"Could not find alsa_playback.pwsp-daemon device, skipping device linking".into(),
);
}
if pwsp_daemon_output.is_none() {
eprintln!("Could not find pwsp-daemon output device, skipping device linking");
return Ok(());
let pwsp_daemon_input;
if let Ok(device) = get_device("pwsp-virtual-mic").await {
pwsp_daemon_input = device;
} else {
return Err("Could not find pwsp-virtual-mic device, skipping device linking".into());
}
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
@@ -97,6 +85,7 @@ pub fn create_runtime_dir() -> Result<(), Box<dyn Error>> {
if !runtime_dir.exists() {
fs::create_dir_all(&runtime_dir)?;
}
fs::set_permissions(&runtime_dir, fs::Permissions::from_mode(0o700))?;
Ok(())
}
+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);
}
}
}
}
+52 -75
View File
@@ -1,16 +1,16 @@
use crate::{
types::{
audio_player::{PlayerState, TrackInfo},
config::GuiConfig,
audio_player::FullState,
config::{GuiConfig, HotkeyConfig},
gui::AudioPlayerState,
socket::{Request, Response},
},
utils::daemon::{is_daemon_running, make_request},
};
use std::{
collections::HashMap,
error::Error,
sync::{Arc, Mutex},
time::Instant,
};
use tokio::time::{Duration, sleep};
@@ -50,102 +50,79 @@ pub fn format_time_pair(position: f32, duration: f32) -> String {
pub fn start_app_state_thread(audio_player_state_shared: Arc<Mutex<AudioPlayerState>>) {
tokio::spawn(async move {
let sleep_duration = Duration::from_secs_f32(1.0 / 60.0);
let mut last_hotkey_poll = Instant::now();
loop {
let is_running = is_daemon_running().unwrap_or(false);
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;
}
sleep(Duration::from_millis(500)).await;
continue;
}
let state_req = Request::get_state();
let tracks_req = Request::get_tracks();
let volume_req = Request::get_volume();
let current_input_req = Request::get_input();
let all_inputs_req = Request::get_inputs();
let full_state_req = Request::get_full_state();
let full_state_res = make_request(full_state_req).await.unwrap_or_default();
let (state_res, tracks_res, volume_res, current_input_res, all_inputs_res) = tokio::join!(
make_request(state_req),
make_request(tracks_req),
make_request(volume_req),
make_request(current_input_req),
make_request(all_inputs_req),
);
if full_state_res.status {
let full_state: FullState =
serde_json::from_str(&full_state_res.message).unwrap_or_default();
let state_res = state_res.unwrap_or_default();
let tracks_res = tracks_res.unwrap_or_default();
let volume_res = volume_res.unwrap_or_default();
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();
let mut guard = audio_player_state_shared
.lock()
.unwrap_or_else(|e| e.into_inner());
guard.state = match guard.new_state.clone() {
Some(new_state) => {
guard.new_state = None;
new_state
}
None => state,
None => full_state.state,
};
guard.tracks = tracks.clone();
guard.volume = volume;
guard.current_input = current_input;
guard.all_inputs = all_inputs;
guard.tracks = full_state.tracks;
guard.volume = full_state.volume;
guard.current_input = full_state
.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;
}
// 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 {
if 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;
}
});
+1
View File
@@ -1,5 +1,6 @@
pub mod commands;
pub mod config;
pub mod daemon;
pub mod global_hotkeys;
pub mod gui;
pub mod pipewire;
+25 -23
View File
@@ -57,17 +57,19 @@ fn parse_global_object(
};
// 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 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 {
node_id,
port_id,
name: port_name.to_string(),
};
let port = Port {
node_id,
port_id,
name: port_name.to_string(),
};
return (None, Some(port));
return (None, Some(port));
}
}
}
(None, None)
@@ -151,8 +153,7 @@ pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>), B
for port in ports {
let node_id = port.node_id;
if input_devices.contains_key(&node_id) {
let input_device = input_devices.get_mut(&node_id).unwrap();
if let Some(input_device) = input_devices.get_mut(&node_id) {
match port.name.as_str() {
"input_FL" => input_device.input_fl = Some(port),
"input_FR" => input_device.input_fr = Some(port),
@@ -170,8 +171,7 @@ pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>), B
}
_ => {}
}
} else if output_devices.contains_key(&node_id) {
let output_device = output_devices.get_mut(&node_id).unwrap();
} else if let Some(output_device) = output_devices.get_mut(&node_id) {
match port.name.as_str() {
"input_FL" => output_device.input_fl = Some(port),
"input_FR" => output_device.input_fr = Some(port),
@@ -206,16 +206,18 @@ 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>> {
let (mut input_devices, output_devices) = get_all_devices().await?;
input_devices.extend(output_devices);
let (input_devices, output_devices) = get_all_devices().await?;
for device in input_devices {
if device.name == device_name {
return Ok(device);
}
}
Err("Device not found".into())
input_devices
.into_iter()
.chain(output_devices)
.find(|device| {
device.name == device_name
|| device.nick == device_name
|| device.name.contains(device_name)
|| device.nick.contains(device_name)
})
.ok_or_else(|| "Device not found".into())
}
pub fn create_virtual_mic() -> Result<pipewire::channel::Sender<Terminate>, Box<dyn Error>> {
+113
View File
@@ -0,0 +1,113 @@
use rodio::{DeviceSinkBuilder, MixerDeviceSink};
use std::fs;
use std::path::Path;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::Mutex;
// A mock of AudioPlayer to isolate the play method's blocking behavior.
// We only implement the relevant part of the logic that needs optimizing.
pub struct AudioPlayerMock {
pub tracks: std::collections::HashMap<u32, ()>,
pub next_id: u32,
pub volume: f32,
}
impl AudioPlayerMock {
pub fn new() -> Self {
AudioPlayerMock {
tracks: std::collections::HashMap::new(),
next_id: 1,
volume: 1.0,
}
}
pub async fn play(
&mut self,
file_path: &Path,
concurrent: bool,
) -> Result<u32, Box<dyn std::error::Error + Send + Sync>> {
if !file_path.exists() {
return Err(format!("File does not exist: {}", file_path.display()).into());
}
let path_buf = file_path.to_path_buf();
let _file = tokio::task::spawn_blocking(move || {
// Simulate some blocking work like Decoder::try_from which reads file headers
let _f = fs::File::open(&path_buf).unwrap();
// Emulate the actual time spent reading file and decoding header (which is what Decoder::try_from does)
std::thread::sleep(std::time::Duration::from_millis(100)); // Simulate slow disk/decode
_f
})
.await?;
if !concurrent {
self.tracks.clear();
}
let id = self.next_id;
self.next_id += 1;
self.tracks.insert(id, ());
Ok(id)
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_performance_blocking() {
println!("Setting up mock environment...");
// Create a dummy file to read
let test_file = Path::new("test_dummy.wav");
fs::write(test_file, "dummy content").unwrap();
let player = Arc::new(Mutex::new(AudioPlayerMock::new()));
println!("Starting benchmark for synchronous behavior in async fn...");
// We launch a background task that measures event loop latency.
// If the main tasks block the executor, this task will suffer high latency.
let latency_task = tokio::spawn(async {
let mut max_latency = std::time::Duration::from_secs(0);
for _ in 0..50 {
let start = Instant::now();
tokio::task::yield_now().await;
let elapsed = start.elapsed();
if elapsed > max_latency {
max_latency = elapsed;
}
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
max_latency
});
// Launch multiple play operations
let mut tasks = vec![];
let start_time = Instant::now();
for _ in 0..10 {
let player_clone = Arc::clone(&player);
let file_path = test_file.to_path_buf();
tasks.push(tokio::spawn(async move {
let mut p = player_clone.lock().await;
let _ = p.play(&file_path, true).await;
}));
}
// Wait for all tasks to finish
for t in tasks {
let _ = t.await;
}
let total_time = start_time.elapsed();
let max_latency = latency_task.await.unwrap();
println!("Total execution time: {:?}", total_time);
println!(
"Max event loop latency (blocking indicator): {:?}",
max_latency
);
// Cleanup
fs::remove_file(test_file).unwrap();
}