Compare commits

..

251 Commits

Author SHA1 Message Date
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
arabianq 2f33c48bcc change version to 1.4.0 2026-01-25 00:34:21 +03:00
arabianq 1f48dad71c cargo update 2026-01-25 00:33:49 +03:00
Tarasov Aleksandr 8066f6d96b Merge pull request #8 from arabianq/feature/parallel-sounds
[Feature] Parallel sounds
2026-01-25 00:25:21 +03:00
arabianq 5c3bc30a6e readme: update screenshot 2026-01-25 00:23:42 +03:00
arabianq 90ed05d88d readme: add information about hotkeys and mouse controls 2026-01-25 00:21:53 +03:00
arabianq f59050ef04 feat: shift + enter is now equal to shift + left mouse 2026-01-25 00:20:55 +03:00
arabianq bae10edc99 feat: replace synchronous requests with asynchronous counterparts for improved performance 2026-01-25 00:14:14 +03:00
arabianq cfa2681ba3 feat: enhance file playback controls with shift and ctrl modifiers 2026-01-25 00:00:11 +03:00
arabianq 03e936ac34 feat: sort tracks by their id in AudioPlayer on get_tracks 2026-01-24 23:59:23 +03:00
arabianq f7f96abcbb feat: add functionality to stop all audio tracks with backspace key 2026-01-24 23:46:51 +03:00
arabianq 4d54443593 feat: add master volume slider 2026-01-24 23:45:33 +03:00
arabianq 5afe3dd45b refactor: remove unused requests and variables from start_app_state_thread 2026-01-24 22:36:26 +03:00
arabianq bd75ac6190 refactor: remove unused fields from AudioPlayerState 2026-01-24 22:34:25 +03:00
arabianq 3e6a8b6e79 feat: first attemp to support playing multiple tracks in parallel 2026-01-24 22:18:42 +03:00
arabianq c1c8deb1b3 README: small changes 2026-01-24 20:46:59 +03:00
arabianq dc61342af8 change version to 1.3.2 2026-01-16 16:19:17 +03:00
arabianq fded7f7b3f deps: cargo update 2026-01-16 16:18:44 +03:00
arabianq ec83680333 deps: bump rfd to 0.17.2 2026-01-16 16:18:11 +03:00
arabianq 16e94e71d3 fix: update input device properties to output for capture_MONO 2026-01-16 16:16:27 +03:00
arabianq 56040934e9 cargo update 2026-01-08 02:56:37 +03:00
arabianq 89975cb124 change version to 1.3.1 2026-01-08 02:53:50 +03:00
arabianq 03e20ffa7e deps: bump rfd to 0.17.1 2026-01-08 02:53:16 +03:00
arabianq 8f7ea09ef5 deps: bump clap to 4.5.54 2026-01-08 02:49:54 +03:00
arabianq c639721f2a deps: bump serde_json to 1.0.149 2026-01-08 02:49:11 +03:00
arabianq 84512efda8 deps: bump tokio to 1.49.0 2026-01-08 02:48:23 +03:00
arabianq 6cf4a9744d replace println! with eprintln! for errors 2026-01-08 02:46:44 +03:00
arabianq d9ced4e650 fix: replace .except() with correct error handling 2026-01-08 02:45:07 +03:00
arabianq c1f3f34169 feat: pwsp-gui now uses Request::toggle_loop instead of set Request::set_loop 2026-01-02 04:41:00 +03:00
arabianq 6c06da7b0d feat: implemented toggle-loop 2026-01-02 04:39:48 +03:00
arabianq 023caa4616 change version to 1.3.0 2026-01-02 04:33:00 +03:00
arabianq fc593f8a8e cargo update 2026-01-02 04:24:32 +03:00
arabianq f281ad5a78 deps: bump serde_json to 1.0.148 2026-01-02 04:18:58 +03:00
arabianq fe8fec4ea7 fix: loop -> tokio::select! in pwsp-daemon main loop for less CPU usage 2026-01-02 04:12:49 +03:00
arabianq 7583e72687 fix: remove debug print statements from commands loop 2026-01-02 04:09:21 +03:00
arabianq e4b4a3d198 fix: remove unnecessary mutability in audio player lock 2026-01-02 03:59:56 +03:00
arabianq a7af1caace fix: highly improved GUI performance 2026-01-02 03:59:05 +03:00
arabianq c5086189d6 fix: changed position slider step from 1.0 to 0.01
now it looks fine when playing short files
2026-01-02 03:58:38 +03:00
arabianq ac667f1273 feat: make_request_sync now uses tokio instead of futures 2026-01-02 03:33:20 +03:00
arabianq fdb79e609f fix: deadlock in play_toggle 2026-01-02 03:32:56 +03:00
arabianq 531a49dbdf feat: implemented loop support in pwsp-gui 2026-01-02 03:18:51 +03:00
arabianq 2a13c25230 feat: implemented loop support in pwsp-cli 2026-01-02 02:57:34 +03:00
arabianq 408f5317c4 feat: implemented loop functionality on daemon level 2026-01-02 02:25:30 +03:00
arabianq 3672d6f43e feat: now pwsp-daemon main loop is separated into commands_loop and player_loop 2026-01-02 02:10:58 +03:00
arabianq cc3bec0746 fix: change get_state and get_position methods to take immutable reference 2026-01-02 02:09:10 +03:00
arabianq 180a69bd40 change version to 1.2.0 2025-12-16 21:08:47 +03:00
arabianq 6de51bc11c cargo update 2025-12-16 21:08:47 +03:00
arabianq bc9f086245 fix: do not display files from removed dir 2025-12-16 21:08:47 +03:00
arabianq f84cc03a6c now can select multiple dirs at once 2025-12-16 21:08:47 +03:00
arabianq 96dda1d8a9 fix: incorrect scroll bar rendering 2025-12-16 21:08:47 +03:00
arabianq 87b791ee3a new feature: pause playback when GUI window is closed 2025-12-16 21:08:47 +03:00
arabianq ae5da6d9bd new feature: now can play file without opening any dir 2025-12-16 21:08:47 +03:00
arabianq 546b6fd13f add toggle-pause command to pwsp-cli 2025-12-16 21:08:47 +03:00
arabianq e8d1947f18 change version to 1.1.4 2025-12-16 21:08:47 +03:00
arabianq 9e55cb5f8b cargo update 2025-12-16 21:08:47 +03:00
arabianq 7f57175519 bump clap version to 4.5.53 2025-12-16 21:08:47 +03:00
arabianq b21b32f746 bump egui and eframe to 0.33.3 2025-12-16 21:08:47 +03:00
arabianq 3827d54ba8 change version to 1.1.3 2025-12-16 21:08:47 +03:00
arabianq 9801b0cdf5 cargo update 2025-12-16 21:08:47 +03:00
arabianq 154f33b15b bump rfd to 0.16.0 2025-12-16 21:08:47 +03:00
arabianq fa217c3b25 fix not working with mono input devices 2025-12-16 21:08:47 +03:00
arabianq 1fb8daf389 change version to 1.1.2 2025-12-16 21:08:47 +03:00
arabianq f2213d1def fix incorrect volume slider width 2025-12-16 21:08:47 +03:00
arabianq 6997a6fa71 disable hotkeys when some widget is focused 2025-12-16 21:08:47 +03:00
arabianq e1ae768f26 change version to 1.1.1 2025-12-16 21:08:47 +03:00
arabianq 57c4461952 fix: crash when setting negative position 2025-12-16 21:08:47 +03:00
arabianq 5c46ff4a1e new hotkeys to select dirs, files. 2025-12-16 21:08:47 +03:00
arabianq 9112102c16 cargo update 2025-12-16 21:08:47 +03:00
arabianq 9d1eb8bee9 change version to 1.1.0 2025-12-16 21:08:47 +03:00
arabianq 39fe8a59c7 add 10s timeout before starting pwsp-daemon via systemd service 2025-12-16 21:08:47 +03:00
arabianq 5870f7d440 update dependencies 2025-12-16 21:08:47 +03:00
arabianq 4a060e4669 change version to 1.0.3 2025-12-16 21:08:47 +03:00
arabianq 3981833a3b call pipewire::init in every pipewire thread 2025-12-16 21:08:47 +03:00
arabianq 1ea84dbb28 cargo update 2025-12-16 21:08:47 +03:00
arabianq 195aa56ceb deps: update clap 2025-12-16 21:08:47 +03:00
arabianq b205800a66 deps: update egui, eframe, egui_material_icons 2025-12-16 21:08:47 +03:00
arabianq 5e538db201 fix: now systemd service should wait for pipewire to start 2025-12-16 21:08:47 +03:00
arabianq 0bcd23195f change pwsp version to 1.0.2 2025-12-16 21:08:47 +03:00
arabianq ca0196f927 update dependencies 2025-12-16 21:08:47 +03:00
arabianq 874c8063aa use device name instead of node id to get audio device 2025-12-16 21:08:47 +03:00
arabianq d877f79d5c fix pwsp.spec failed to build 2025-12-16 21:08:47 +03:00
arabianq 3cfd2cb9bd update descriptions in github actions to use new tags format 2025-12-16 21:08:47 +03:00
arabianq bf1f8410c1 change pwsp.spec to use new tags format 2025-12-16 21:08:47 +03:00
arabianq ad7c63617b change package version to 1.0.1 2025-12-16 21:08:47 +03:00
arabianq 00b25dde9d cargo update 2025-12-16 21:08:47 +03:00
arabianq e3118a4b89 bump serde version to 1.0.227 2025-12-16 21:08:47 +03:00
arabianq 23f5ee0d22 change code to work with pipewire 0.9.2 2025-12-16 21:08:47 +03:00
arabianq 5c4df232d5 bump pipewire crate to version 0.9.2 2025-12-16 21:08:47 +03:00
arabianq 7f0e6f7996 change: now, instead of the full path to the file, only its name is displayed at the top 2025-12-16 21:08:47 +03:00
arabianq ab60e6b0ab fix: too large directory names break the interface 2025-12-16 21:08:47 +03:00
arabian 3b3c5c32f9 Delete scripts directory 2025-12-16 21:08:47 +03:00
arabian 74b76dd61d Update README.md 2025-12-16 21:08:47 +03:00
arabian 3b3db608e8 Update release-deb.yml
fix .deb uploading
2025-12-16 21:08:47 +03:00
arabian b1a24cb50e Update release-deb.yml
change name
2025-12-16 21:08:47 +03:00
arabian 1855a8b72c Create release-deb.yml 2025-12-16 21:08:47 +03:00
arabian 42ab513ad9 Update and rename build-release.yml to release-archive.yml 2025-12-16 21:08:47 +03:00
arabian ba04546b17 Update build-release.yml 2025-12-16 21:08:47 +03:00
arabian 6feec98b4c Update build-release.yml
try to fix zip creation
2025-12-16 21:08:47 +03:00
arabian 43e1c60baa Update build-release.yml
change workflow_dispatch description
2025-12-16 21:08:47 +03:00
arabian 2f5fc04244 Update build-release.yml
add build dependencies installation
2025-12-16 21:08:47 +03:00
arabian aa45a49c3a Create build-release.yml 2025-12-16 21:08:47 +03:00
arabian dd8ff8b1bf Update README.md
add installation on Arch Linux from AUR
2025-12-16 21:08:47 +03:00
arabianq 25667ce0f0 update README.md 2025-12-16 21:08:47 +03:00
arabianq ccf48e6426 remove build_rpm.sh and cargo-generate-rpm mentions. Now copr handles rpm builds 2025-12-16 21:08:47 +03:00
arabianq da79be0d22 fix pwsp.spec 2025-12-16 21:08:47 +03:00
arabianq 01afb01198 add missing files to pwsp.spec 2025-12-16 21:08:47 +03:00
arabianq 37723ddc30 specify BuildRequires and %install manually in pwsp.spec 2025-12-16 21:08:47 +03:00
arabianq b672d9bd10 update pwsp.spec file 2025-12-16 21:08:47 +03:00
arabianq fa8a7b7590 update pwsp.spec file 2025-12-16 21:08:47 +03:00
arabianq d3c8706c33 update pwsp.spec file 2025-12-16 21:08:47 +03:00
arabianq 071e805457 fix Source url in pwsp.spec 2025-12-16 21:08:47 +03:00
arabianq 4f30f5047e add pwsp.spec 2025-12-16 21:08:47 +03:00
arabianq e173a27c6e remove generate_rpm_spec.sh 2025-12-16 21:08:47 +03:00
arabianq 5891cc071e use --path . in generate_rpm_spec 2025-12-16 21:08:47 +03:00
arabianq b02acb44fb add rust-pwsp.spec 2025-12-16 21:08:47 +03:00
arabianq c4a01a9f72 add generate_rpm_spec.sh and rust-pwsp.spec 2025-12-16 21:08:47 +03:00
arabianq be5a619b89 fix .rpm configuration 2025-12-16 21:08:47 +03:00
arabianq 00a3aa4ca4 new README 2025-12-16 21:08:47 +03:00
arabianq dee908a347 1.0.0 rewrite 2025-12-16 21:08:47 +03:00
arabianq 4f8681a8c6 clear README 2025-12-16 21:08:47 +03:00
arabianq 675f29f51f remove everything 2025-12-16 21:08:47 +03:00
arabianq 2efe16376c change version to 0.1.8 2025-12-16 21:08:47 +03:00
arabianq 607e30da7c cargo update 2025-12-16 21:08:47 +03:00
arabianq d6f96f3035 better imports 2025-12-16 21:08:47 +03:00
arabianq 81cea863e4 PlayerState::PLAYING -> PlayerState::Playing; PlayerState::PAUSED -> PlayerState::Paused 2025-12-16 21:08:47 +03:00
arabianq ac721d0c4d cargo clippy 2025-12-16 21:08:47 +03:00
arabianq 06f12cd740 bump egui and eframe to 0.32.3 2025-12-16 21:08:47 +03:00
arabianq dda87f3222 change version to 0.1.7 2025-12-16 21:08:47 +03:00
arabianq 8b1c4773c3 update egui and eframe to 0.32.2 2025-12-16 21:08:47 +03:00
arabianq 9b71c105fc change version to 0.1.6 2025-12-16 21:08:47 +03:00
arabianq 82d603cbab update deps 2025-12-16 21:08:47 +03:00
arabianq e6d0e6e128 fix crash when seeking 2025-12-16 21:08:47 +03:00
arabian 4c62d9f1e7 Update README.md 2025-12-16 21:08:47 +03:00
arabianq c40251b2a1 version -> 0.1.5 2025-12-16 21:08:47 +03:00
arabianq b7fa90d37e update dependencies 2025-12-16 21:08:47 +03:00
arabianq e3b787204d rodio -> 0.21.1 2025-12-16 21:08:47 +03:00
arabianq 0a5d61b510 version -> 0.1.4 2025-12-16 21:08:47 +03:00
arabianq ef3cb0f48c fix incorrect dependency for deb package 2025-12-16 21:08:47 +03:00
arabianq 19b5e52ba6 update Cargo.toml 2025-12-16 21:08:47 +03:00
arabianq 6b75654511 egui, eframe -> 0.32.0; egui_material_icons -> 0.4.0 2025-12-16 21:08:47 +03:00
arabianq 22c5ca6384 update build scripts 2025-12-16 21:08:47 +03:00
arabianq eacfaaf8f6 update README 2025-12-16 21:08:47 +03:00
arabianq f3e574e8c9 update screenshot.png 2025-12-16 21:08:47 +03:00
arabianq cc02c6c3c0 new build scripts and .desktop file 2025-12-16 21:08:47 +03:00
arabianq 7ec53f259e version -> 0.1.31 2025-12-16 21:08:47 +03:00
arabianq dddd4c5440 maximum volume -> 1.0 from 5.0 2025-12-16 21:08:47 +03:00
arabianq 1a9836a7d9 version -> 0.1.3 2025-12-16 21:08:47 +03:00
arabianq 26eec0c0ef now use single settings file instead of many; minor refactoring 2025-12-16 21:08:47 +03:00
arabianq 3ba7a79010 new app::run function 2025-12-16 21:08:47 +03:00
arabianq 6ab54d7ec9 move creation of dirs to the separate function 2025-12-16 21:08:47 +03:00
arabianq af4f175e0c split main.rs into main.rs and app.rs 2025-12-16 21:08:47 +03:00
arabianq 9907408541 bump pwsp version to 0.1.2 2025-12-16 21:08:47 +03:00
arabianq b3e0d730da bump edition to 2024 2025-12-16 21:08:47 +03:00
arabianq edfd2ba9b7 fixed setting player position to 0 2025-12-16 21:08:47 +03:00
arabianq f2ce47cdcd bump egui, eframe and rfd versions 2025-12-16 21:08:47 +03:00
arabianq 6b10d365fc v0.1.1 - minor fixes 2025-12-16 21:08:47 +03:00
arabianq da756ccffe Added unlink() function 2025-12-16 21:08:47 +03:00
arabianq 5e99870a71 Update README.md 2025-12-16 21:08:47 +03:00
arabianq ed5d6f63b1 Added Cargo.lock 2025-12-16 21:08:47 +03:00
arabian 89a58c27a0 Create LICENSE 2025-12-16 21:08:47 +03:00
38 changed files with 4295 additions and 1774 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
+1514 -978
View File
File diff suppressed because it is too large Load Diff
+63 -17
View File
@@ -1,6 +1,6 @@
[package]
name = "pwsp"
version = "1.0.1"
version = "1.6.3"
edition = "2024"
authors = ["arabian"]
description = "PWSP lets you play audio files through your microphone. Has both CLI and GUI clients."
@@ -12,23 +12,45 @@ keywords = ["soundpad", "pipewire", "linux", "cli", "gui"]
[dependencies]
tokio = { version = "1.47.1", features = ["full"] }
futures = { version = "0.3.31", features = ["thread-pool"] }
tokio = { version = "1.50.0", features = ["full"] }
async-trait = "0.1.89"
serde = { version = "1.0.227", features = ["derive"] }
serde_json = "1.0.145"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
clap = { version = "4.5.48", 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 = "0.15.4"
rfd = { version = "0.17.2", default-features = false, features = [
"xdg-portal",
] }
opener = { version = "0.8.4", features = ["reveal"] }
egui = { version = "0.32.3", default-features = false, features = ["default_fonts", "rayon"] }
eframe = { version = "0.32.3", default-features = false, features = ["default_fonts", "glow", "x11", "wayland"] }
egui_material_icons = "0.4.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"
@@ -51,10 +73,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",
],
]
+36 -8
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**
@@ -35,8 +38,8 @@ three main components:
* Creating and managing virtual audio devices.
* Linking these devices within the PipeWire graph.
* Handling all audio playback.
* **pwsp-gui**: This is the graphical user interface. It acts as a client that communicates with pwsp-daemon via a *
*UnixSocket**. This is how you interact with your sound collection, control playback, and configure settings.
* **pwsp-gui**: This is the graphical user interface. It acts as a client that communicates with pwsp-daemon via a
**UnixSocket**. This is how you interact with your sound collection, control playback, and configure settings.
* **pwsp-cli**: This is the command-line interface, also acting as a client. It provides a way to control the daemon
without a GUI, allowing for scripting or quick command-based actions.
@@ -44,10 +47,10 @@ three main components:
## **Pre-built Packages**
You can download pre-built binaries, .deb and .rpm packages from
You can download pre-built binaries and .deb packages from
the [releases page](https://github.com/arabianq/pipewire-soundpad/releases).
## **Fedora Linux**
## **Fedora Linux (and derivatives)**
If you're using Fedora, you can install PWSP from a dedicated repository using DNF.
@@ -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**
@@ -137,12 +140,12 @@ You can start the daemon from the terminal or enable the systemd service for aut
### **Using the GUI**
1. **Add Sounds**: Click the **"Add Directory"** button and select a folder containing your audio files. The application
1. **Add Sounds**: Click the **"+"** button and select a folder containing your audio files. The application
will automatically list all supported files.
2. **Select Microphone**: In the main application window, select your **physical microphone**. PWSP will automatically
2. **Select Microphone**: In the main application window, select your microphone. PWSP will automatically
create a virtual microphone and feed it sound from two sources: **your microphone** and the **audio files**.
3. **Playback**: Click on a file in the list to load it, then use the **"Play"** and **"Pause"** buttons to control
playback.
playback. You can also play single file once using **"Play File"** button.
### **Using the CLI**
@@ -173,6 +176,28 @@ pwsp-cli --help
pwsp-cli set position 20
```
### **Hotkeys & Controls**
#### **Keyboard Shortcuts**
| Key | Action |
| :----------------------- | :--------------------------------------------------- |
| **Space** | Pause / Resume audio |
| **Backspace** | Stop all audio tracks |
| **Enter** | Play selected file (stops all other tracks) |
| **Ctrl + Enter** | Add selected file to playback (plays simultaneously) |
| **Shift + Enter** | Replace the last added track with the selected one |
| **I** | Open / Close settings |
| **/** | Focus search field |
| **Ctrl + ↑ / ↓** | Navigate through files |
| **Ctrl + Shift + ↑ / ↓** | Navigate through directories |
#### **Mouse Controls**
* **Left Click**: Play track (stops all other tracks).
* **Ctrl + Left Click**: Add track (plays simultaneously with current tracks).
* **Shift + Left Click**: Replace the last added track with the selected one.
# **🤝 Contributing**
Contributions are welcome\! If you have ideas for improvements or find a bug, feel free to create
@@ -183,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
@@ -1,5 +1,6 @@
[Unit]
Description=Pipewire Soundpad Daemon
After=pipewire.service
[Service]
ExecStart=/usr/bin/pwsp-daemon
Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 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.6.3
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.6.3.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.6.3/pwsp-v1.6.3-linux-x64.zip
source = pipewire-soundpad-1.6.3.tar.gz :: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.6.3.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.6.3
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.6.3
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.6.3.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.6.3
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.0.1
Version: 1.6.3
Release: %autorelease
Summary: Lets you play audio files through your microphone
@@ -26,7 +26,7 @@ GUI clients.}
%description %{_description}
%prep
%autosetup -n pipewire-soundpad-v%{version} -p1
%autosetup -n pipewire-soundpad-%{version} -p1
%build
cargo build --release --locked
+94 -31
View File
@@ -14,17 +14,17 @@ struct Cli {
#[derive(Subcommand, Debug)]
enum Commands {
/// Perform an action (ping, pause, resume, stop, play)
/// Perform an action (ping, pause, resume, toggle-pause, stop, play)
Action {
#[clap(subcommand)]
action: Actions,
},
/// Get information from the player (is paused, volume, position, state)
/// Get information from the player (is paused, volume, position, duration, state, current-file-path, input, inputs)
Get {
#[clap(subcommand)]
parameter: GetCommands,
},
/// Set information in the player (volume, position)
/// Set information in the player (volume, position, input)
Set {
#[clap(subcommand)]
parameter: SetCommands,
@@ -35,14 +35,39 @@ enum Commands {
enum Actions {
/// Ping the daemon
Ping,
/// Kill the daemon
Kill,
/// Pause audio playback
Pause,
Pause {
#[clap(short, long)]
id: Option<u32>,
},
/// Resume audio playback
Resume,
Resume {
#[clap(short, long)]
id: Option<u32>,
},
/// Toggle pause
TogglePause {
#[clap(short, long)]
id: Option<u32>,
},
/// Stop audio playback and clear the queue
Stop,
Stop {
#[clap(short, long)]
id: Option<u32>,
},
/// Play a file
Play { file_path: PathBuf },
Play {
file_path: PathBuf,
#[clap(short, long)]
concurrent: bool,
},
/// Toggle loop
ToggleLoop {
#[clap(short, long)]
id: Option<u32>,
},
}
#[derive(Subcommand, Debug)]
@@ -50,29 +75,56 @@ enum GetCommands {
/// Check if the player is paused
IsPaused,
/// Playback volume
Volume,
/// Playback position
Position,
Volume {
#[clap(short, long)]
id: Option<u32>,
},
/// Playback position (in seconds)
Position {
#[clap(short, long)]
id: Option<u32>,
},
/// Duration of the current file
Duration,
/// Player state
Duration {
#[clap(short, long)]
id: Option<u32>,
},
/// Player state (Playing, Paused or Stopped)
State,
/// Current playing file path
CurrentFilePath,
/// Get all playing tracks
Tracks,
/// Current audio input
Input,
/// All audio inputs
Inputs,
/// Version of the daemon
DaemonVersion,
/// Full player state
FullState,
}
#[derive(Subcommand, Debug)]
enum SetCommands {
/// Playback volume
Volume { volume: f32 },
/// Playback position
Position { position: f32 },
/// Input
Input { id: u32 },
Volume {
volume: f32,
#[clap(short, long)]
id: Option<u32>,
},
/// Playback position (in seconds)
Position {
position: f32,
#[clap(short, long)]
id: Option<u32>,
},
/// Audio input id (see pwsp-cli get inputs)
Input { name: String },
/// Enable or disable loop (true or false)
Loop {
enabled: String,
#[clap(short, long)]
id: Option<u32>,
},
}
#[tokio::main]
@@ -84,29 +136,40 @@ async fn main() -> Result<(), Box<dyn Error>> {
let request = match cli.command {
Commands::Action { action } => match action {
Actions::Ping => Request::ping(),
Actions::Pause => Request::pause(),
Actions::Resume => Request::resume(),
Actions::Stop => Request::stop(),
Actions::Play { file_path } => Request::play(file_path.to_str().unwrap()),
Actions::Kill => Request::kill(),
Actions::Pause { id } => Request::pause(id),
Actions::Resume { id } => Request::resume(id),
Actions::TogglePause { id } => Request::toggle_pause(id),
Actions::Stop { id } => Request::stop(id),
Actions::Play {
file_path,
concurrent,
} => Request::play(&file_path.to_string_lossy(), concurrent),
Actions::ToggleLoop { id } => Request::toggle_loop(id),
},
Commands::Get { parameter } => match parameter {
GetCommands::IsPaused => Request::get_is_paused(),
GetCommands::Volume => Request::get_volume(),
GetCommands::Position => Request::get_position(),
GetCommands::Duration => Request::get_duration(),
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::CurrentFilePath => Request::get_current_file_path(),
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(),
},
Commands::Set { parameter } => match parameter {
SetCommands::Volume { volume } => Request::set_volume(volume),
SetCommands::Position { position } => Request::seek(position),
SetCommands::Input { id } => Request::set_input(id),
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),
},
};
let response = make_request(request).await?;
let response = make_request(request)
.await
.map_err(|e| e as Box<dyn Error>)?;
println!("{} : {}", response.status, response.message);
Ok(())
+100 -9
View File
@@ -9,10 +9,12 @@ use pwsp::{
pipewire::create_virtual_mic,
},
};
use std::{error::Error, fs};
use std::os::unix::fs::PermissionsExt;
use std::{error::Error, fs, time::Duration};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::UnixListener,
time::sleep,
};
#[tokio::main]
@@ -25,8 +27,24 @@ 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;
}
});
let runtime_dir = get_runtime_dir();
@@ -34,16 +52,41 @@ 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()
);
let commands_loop_handle = tokio::spawn(async {
commands_loop(listener).await.ok();
});
let player_loop_handle = tokio::spawn(async {
player_loop().await;
});
tokio::select! {
_ = commands_loop_handle => {
eprint!("Commands loop was finished, stopping program...");
}
_ = player_loop_handle => {
eprint!("Audio Player loop was finished, stopping program...");
}
}
Ok(())
}
async fn commands_loop(listener: UnixListener) -> Result<(), Box<dyn Error>> {
loop {
let (mut stream, _addr) = listener.accept().await?;
@@ -57,14 +100,35 @@ async fn main() -> 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();
println!("Received request: {:?}", request);
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) ----------
@@ -78,7 +142,13 @@ async fn main() -> 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() {
@@ -89,8 +159,29 @@ async fn main() -> Result<(), Box<dyn Error>> {
eprintln!("Failed to write response to client!");
return;
}
println!("Sent response: {:?}", response);
// ---------- Send response (end) ----------
if response.status && response.message.eq("killed") {
std::process::exit(0);
}
});
}
}
async fn player_loop() {
loop {
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;
}
}
+365 -119
View File
@@ -1,14 +1,34 @@
use crate::gui::SoundpadGui;
use egui::{
AtomExt, Button, Color32, ComboBox, FontFamily, Label, RichText, ScrollArea, Slider, TextEdit,
Ui, Vec2,
Align, AtomExt, Button, CollapsingHeader, Color32, ComboBox, CursorIcon, FontFamily, Label,
Layout, RichText, ScrollArea, Sense, Slider, TextEdit, Ui, Vec2,
};
use egui_material_icons::icons;
use pwsp::types::audio_player::PlayerState;
use egui_dnd::dnd;
use egui_material_icons::icons::*;
use pwsp::types::{audio_player::TrackInfo, gui::AppState};
use pwsp::utils::gui::format_time_pair;
use std::{error::Error, path::PathBuf};
use std::{error::Error, time::Instant};
enum TrackAction {
Pause(u32),
Resume(u32),
ToggleLoop(u32),
Stop(u32),
}
impl SoundpadGui {
fn get_volume_icon(volume: f32) -> &'static str {
if volume > 0.7 {
ICON_VOLUME_UP.codepoint
} else if volume <= 0.0 {
ICON_VOLUME_OFF.codepoint
} else if volume < 0.3 {
ICON_VOLUME_MUTE.codepoint
} else {
ICON_VOLUME_DOWN.codepoint
}
}
pub fn draw_waiting_for_daemon(&mut self, ui: &mut Ui) {
ui.centered_and_justified(|ui| {
ui.label(
@@ -24,7 +44,7 @@ impl SoundpadGui {
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;
@@ -48,14 +68,23 @@ impl SoundpadGui {
&mut self.config.save_scale_factor,
"Always remember UI scale factor",
);
let pause_on_exit_response = ui.checkbox(
&mut self.config.pause_on_exit,
"Pause audio playback when the window is closed",
);
if save_volume_response.changed()
|| save_input_response.changed()
|| save_scale_response.changed()
|| pause_on_exit_response.changed()
{
self.config.save_to_file().ok();
}
// --------------------------------
ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
ui.label(format!("GUI version: {}", env!("CARGO_PKG_VERSION")));
});
});
}
@@ -69,107 +98,201 @@ impl SoundpadGui {
fn draw_header(&mut self, ui: &mut Ui) {
ui.vertical_centered_justified(|ui| {
// Current file name
ui.label(
RichText::new(
self.audio_player_state
.current_file_path
.file_stem()
.unwrap_or_default()
.to_str()
.unwrap_or_default(),
if self.audio_player_state.tracks.is_empty() {
ui.label("No tracks playing");
return;
}
let tracks = self.audio_player_state.tracks.clone();
let mut action = None;
for track in tracks {
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),
);
// Media controls
self.draw_controls(ui);
ui.separator();
.default_open(true)
.show(ui, |ui| {
if let Some(act) = Self::draw_track_control(ui, &mut self.app_state, &track) {
action = Some(act);
}
});
ui.separator();
}
if let Some(action) = action {
match action {
TrackAction::Pause(id) => self.pause(Some(id)),
TrackAction::Resume(id) => self.resume(Some(id)),
TrackAction::ToggleLoop(id) => self.toggle_loop(Some(id)),
TrackAction::Stop(id) => self.stop(Some(id)),
}
}
});
}
fn draw_controls(&mut self, ui: &mut Ui) {
fn draw_track_control(
ui: &mut Ui,
app_state: &mut AppState,
track: &TrackInfo,
) -> Option<TrackAction> {
let ui_state = app_state.track_ui_states.entry(track.id).or_default();
let should_update_position = !ui_state.position_dragged
&& ui_state
.ignore_position_update_until
.map(|t| Instant::now() > t)
.unwrap_or(true);
if should_update_position {
ui_state.position_slider_value = track.position;
}
let should_update_volume = !ui_state.volume_dragged
&& ui_state
.ignore_volume_update_until
.map(|t| Instant::now() > t)
.unwrap_or(true);
if should_update_volume {
ui_state.volume_slider_value = track.volume;
}
let mut action = None;
ui.horizontal_top(|ui| {
// ---------- Play Button ----------
let play_button = Button::new(match self.audio_player_state.state {
PlayerState::Playing => icons::ICON_PAUSE,
PlayerState::Paused | PlayerState::Stopped => icons::ICON_PLAY_ARROW,
let play_button = Button::new(if track.paused {
ICON_PLAY_ARROW
} else {
ICON_PAUSE
})
.corner_radius(15.0);
let play_button_response = ui.add_sized([30.0, 30.0], play_button);
if play_button_response.clicked() {
self.play_toggle();
if track.paused {
action = Some(TrackAction::Resume(track.id));
} else {
action = Some(TrackAction::Pause(track.id));
}
}
// --------------------------------
// ---------- Loop Button ----------
let loop_button = Button::new(
RichText::new(if track.looped {
ICON_REPEAT_ONE
} else {
ICON_REPEAT
})
.size(18.0),
)
.frame(false);
let loop_button_response = ui.add_sized([15.0, 30.0], loop_button);
if loop_button_response.clicked() {
action = Some(TrackAction::ToggleLoop(track.id));
}
// --------------------------------
// ---------- Position Slider ----------
let position_slider = Slider::new(
&mut self.app_state.position_slider_value,
0.0..=self.audio_player_state.duration,
)
.show_value(false)
.step_by(1.0);
let duration = track.duration.unwrap_or(1.0);
let position_slider = Slider::new(&mut ui_state.position_slider_value, 0.0..=duration)
.show_value(false)
.step_by(0.01);
let default_slider_width = ui.spacing().slider_width;
let position_slider_width = ui.available_width()
- (30.0 * 3.0)
- default_slider_width
- (ui.spacing().item_spacing.x * 5.0);
- (ui.spacing().item_spacing.x * 6.0);
ui.spacing_mut().slider_width = position_slider_width;
let position_slider_response = ui.add_sized([30.0, 30.0], position_slider);
if position_slider_response.drag_stopped() {
self.app_state.position_dragged = true;
ui_state.position_dragged = true;
}
// --------------------------------
// ---------- Time Label ----------
let time_label = Label::new(
RichText::new(format_time_pair(
self.audio_player_state.position,
self.audio_player_state.duration,
))
.monospace(),
);
let time_label =
Label::new(RichText::new(format_time_pair(track.position, duration)).monospace());
ui.add_sized([30.0, 30.0], time_label);
// --------------------------------
// ---------- Volume Icon ----------
let volume_icon = if self.audio_player_state.volume > 0.7 {
icons::ICON_VOLUME_UP
} else if self.audio_player_state.volume == 0.0 {
icons::ICON_VOLUME_OFF
} else if self.audio_player_state.volume < 0.3 {
icons::ICON_VOLUME_MUTE
} else {
icons::ICON_VOLUME_DOWN
};
let volume_icon = Label::new(RichText::new(volume_icon).size(18.0));
ui.add_sized([30.0, 25.0], volume_icon);
let volume_icon = Self::get_volume_icon(track.volume);
let volume_label = Label::new(RichText::new(volume_icon).size(18.0));
ui.add_sized([30.0, 30.0], volume_label)
.on_hover_text(format!("Volume: {:.0}%", track.volume * 100.0));
// --------------------------------
// ---------- Volume Slider ----------
let volume_slider = Slider::new(&mut self.app_state.volume_slider_value, 0.0..=1.0)
let volume_slider = Slider::new(&mut ui_state.volume_slider_value, 0.0..=1.0)
.show_value(false)
.step_by(0.01);
ui.spacing_mut().slider_width = default_slider_width;
ui.spacing_mut().slider_width = default_slider_width - 30.0;
ui.spacing_mut().item_spacing.x = 0.0;
let volume_slider_response = ui.add_sized([30.0, 30.0], volume_slider);
if volume_slider_response.drag_stopped() {
self.app_state.volume_dragged = true;
ui_state.volume_dragged = true;
}
// --------------------------------
// ---------- Stop Button ---------
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));
}
// --------------------------------
});
action
}
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);
@@ -182,37 +305,90 @@ impl SoundpadGui {
ui.set_min_height(area_size.y);
ScrollArea::vertical().id_salt(0).show(ui, |ui| {
let mut dirs: Vec<PathBuf> = self.app_state.dirs.iter().cloned().collect();
dirs.sort();
for path in dirs.iter() {
ui.set_min_width(area_size.x);
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())
.unwrap_or_else(|| path.to_string_lossy().to_string());
let dir_button =
Button::new(RichText::new(name).atom_max_width(area_size.x))
.frame(false);
let dir_button_response = ui.add(dir_button);
if dir_button_response.clicked() {
self.app_state.current_dir = Some(path.clone());
let mut dir_button_text = RichText::new(name.clone());
if let Some(current_dir) = &self.app_state.current_dir {
if current_dir.eq(&path) {
dir_button_text = dir_button_text.color(Color32::WHITE);
}
}
let delete_dir_button = Button::new(icons::ICON_DELETE).frame(false);
let dir_button =
Button::new(dir_button_text.atom_max_width(area_size.x)).frame(false);
let dir_button_response = ui.add(dir_button);
if dir_button_response.clicked() {
self.open_dir(&path);
}
let delete_dir_button = Button::new(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_dir_button = egui::Button::new(icons::ICON_ADD).frame(false);
let add_dir_button_response = ui.add_sized([18.0, 18.0], add_dir_button);
if add_dir_button_response.clicked() {
self.add_dir();
let add_dirs_button = Button::new(ICON_ADD).frame(false);
let add_dirs_button_response = ui.add_sized([18.0, 18.0], add_dirs_button);
if add_dirs_button_response.clicked() {
self.add_dirs();
}
});
ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
let play_file_button = Button::new("Play file");
let play_file_button_response = ui.add(play_file_button);
if play_file_button_response.clicked() {
self.open_file();
}
});
});
@@ -220,16 +396,19 @@ impl SoundpadGui {
}
fn draw_files(&mut self, ui: &mut Ui, area_size: Vec2) {
let extensions = [
"mp3", "wav", "ogg", "flac", "mp4", "m4a", "aac", "mov", "mkv", "webm", "avi",
];
ui.vertical(|ui| {
ui.horizontal(|ui| {
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..."),
);
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();
@@ -239,44 +418,82 @@ impl SoundpadGui {
ui.set_min_height(area_size.y);
ui.vertical(|ui| {
if let Some(path) = self.app_state.current_dir.clone() {
for entry in path.read_dir().unwrap() {
let entry = entry.unwrap();
let entry_path = entry.path();
let files = self.get_filtered_files();
if entry_path.is_dir() {
continue;
}
for entry_path in files {
let file_name = entry_path
.file_name()
.unwrap()
.to_string_lossy()
.to_string();
if !extensions.contains(
&entry_path.extension().unwrap_or_default().to_str().unwrap(),
) {
continue;
}
let file_name = entry_path
.file_name()
.unwrap()
.to_string_lossy()
.to_string();
let search_query = self
.app_state
.search_query
.to_lowercase()
.trim()
.to_string();
if !file_name.to_lowercase().contains(search_query.as_str()) {
continue;
}
let file_button = Button::new(file_name).frame(false);
let file_button_response = ui.add(file_button);
if file_button_response.clicked() {
self.play_file(entry_path);
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);
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);
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);
}
}
});
}
});
});
@@ -285,15 +502,14 @@ impl SoundpadGui {
fn draw_footer(&mut self, ui: &mut Ui) {
ui.add_space(5.0);
ui.horizontal_top(|ui| {
ui.horizontal(|ui| {
// ---------- Microphone selection ----------
let mut mics: Vec<(&u32, &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();
ComboBox::from_label("Choose microphone")
.height(30.0)
.selected_text(
self.audio_player_state
.all_inputs
@@ -301,8 +517,8 @@ impl SoundpadGui {
.unwrap_or(&String::new()),
)
.show_ui(ui, |ui| {
for (index, device) in mics {
ui.selectable_value(&mut selected_input, index.to_owned(), device);
for (name, nick) in mics {
ui.selectable_value(&mut selected_input, name.clone(), nick);
}
});
@@ -311,10 +527,40 @@ impl SoundpadGui {
}
// --------------------------------
// ---------- Master Volume Slider ----------
let volume_icon = Self::get_volume_icon(self.audio_player_state.volume);
let volume_label = Label::new(RichText::new(volume_icon).size(18.0));
ui.add_sized([18.0, 18.0], volume_label)
.on_hover_text(format!(
"Master Volume: {:.0}%",
self.audio_player_state.volume * 100.0
));
let should_update_volume = !self.app_state.volume_dragged
&& self
.app_state
.ignore_volume_update_until
.map(|t| Instant::now() > t)
.unwrap_or(true);
if should_update_volume {
self.app_state.volume_slider_value = self.audio_player_state.volume;
}
let volume_slider = Slider::new(&mut self.app_state.volume_slider_value, 0.0..=1.0)
.show_value(false)
.step_by(0.01);
let volume_slider_response = ui.add_sized([150.0, 18.0], volume_slider);
if volume_slider_response.drag_stopped() {
self.app_state.volume_dragged = true;
}
// ------------------------------------------
ui.add_space(ui.available_width() - 18.0 - ui.spacing().item_spacing.x);
// ---------- Settings button ----------
let settings_button = Button::new(icons::ICON_SETTINGS).frame(false);
let settings_button =
Button::new(ICON_SETTINGS.atom_size(Vec2::new(18.0, 18.0))).frame(false);
let settings_button_response = ui.add_sized([18.0, 18.0], settings_button);
if settings_button_response.clicked() {
self.app_state.show_settings = true;
+117 -12
View File
@@ -1,24 +1,129 @@
use crate::gui::SoundpadGui;
use egui::{Context, Key};
use egui::{Context, Id, Key, Modifiers};
use std::path::PathBuf;
impl SoundpadGui {
pub fn handle_input(&mut self, ctx: &Context) {
ctx.input(|i| {
if i.key_pressed(Key::Escape) {
std::process::exit(0);
}
fn key_pressed(&self, ctx: &Context, key: Key) -> bool {
ctx.input(|i| i.key_pressed(key))
}
if !self.app_state.show_settings && i.key_pressed(Key::Space) {
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) {
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
}
};
// Open/close settings
if !search_focused && self.key_pressed(ctx, Key::I) {
self.app_state.show_settings = !self.app_state.show_settings;
}
if !self.app_state.show_settings {
// Pause / resume audio on space
if !search_focused && self.key_pressed(ctx, Key::Space) {
self.play_toggle();
}
if i.key_pressed(Key::Slash) {
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 self.app_state.show_settings && i.key_pressed(Key::Backspace) {
self.app_state.show_settings = false;
// Focus search field
if self.key_pressed(ctx, Key::Slash) {
if search_focused {
ctx.memory_mut(|m| {
m.request_focus(Id::NULL);
});
} else {
self.app_state.force_focus_search = true;
}
}
});
// 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());
}
}
}
// });
}
}
+124 -31
View File
@@ -4,6 +4,7 @@ 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,
@@ -13,16 +14,20 @@ use pwsp::{
},
utils::{
daemon::get_daemon_config,
gui::{get_gui_config, make_request_sync, start_app_state_thread},
gui::{get_gui_config, make_request_async, make_request_sync, start_app_state_thread},
},
};
use rfd::FileDialog;
use std::path::PathBuf;
use std::{
error::Error,
path::PathBuf,
sync::{Arc, Mutex},
};
const SUPPORTED_EXTENSIONS: [&str; 11] = [
"mp3", "wav", "ogg", "flac", "mp4", "m4a", "aac", "mov", "mkv", "webm", "avi",
];
struct SoundpadGui {
pub app_state: AppState,
pub config: GuiConfig,
@@ -52,55 +57,137 @@ impl SoundpadGui {
}
pub fn play_toggle(&mut self) {
let mut guard = self.audio_player_state_shared.lock().unwrap();
guard.state = match guard.state {
PlayerState::Playing => {
make_request_sync(Request::pause()).ok();
guard.new_state = Some(PlayerState::Paused);
PlayerState::Paused
let (new_state, request) = {
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))),
PlayerState::Stopped => (None, None),
}
PlayerState::Paused => {
make_request_sync(Request::resume()).ok();
guard.new_state = Some(PlayerState::Playing);
PlayerState::Playing
}
PlayerState::Stopped => PlayerState::Stopped,
};
if let Some(req) = request {
make_request_async(req);
}
if let Some(state) = new_state {
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;
}
}
pub fn add_dir(&mut self) {
pub fn open_file(&mut self) {
let file_dialog = FileDialog::new().add_filter("Audio File", &SUPPORTED_EXTENSIONS);
if let Some(path) = file_dialog.pick_file() {
self.play_file(&path, false);
}
}
pub fn add_dirs(&mut self) {
let file_dialog = FileDialog::new();
if let Some(path) = file_dialog.pick_folder() {
self.app_state.dirs.insert(path);
if let Some(paths) = file_dialog.pick_folders() {
for path in paths {
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;
pub fn open_dir(&mut self, path: &PathBuf) {
self.app_state.current_dir = Some(path.clone());
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();
}
}
self.config.dirs = self.app_state.dirs.clone();
self.config.save_to_file().ok();
}
pub fn play_file(&mut self, path: PathBuf) {
make_request_sync(Request::play(path.to_str().unwrap())).ok();
pub fn play_file(&mut self, path: &PathBuf, concurrent: bool) {
make_request_async(Request::play(&path.to_string_lossy(), concurrent));
}
pub fn set_input(&mut self, id: u32) {
make_request_sync(Request::set_input(id)).ok();
pub fn set_input(&mut self, name: String) {
make_request_async(Request::set_input(&name));
if self.config.save_input {
let mut daemon_config = get_daemon_config();
daemon_config.default_input_id = Some(id);
daemon_config.default_input_name = Some(name);
daemon_config.save_to_file().ok();
}
}
pub fn toggle_loop(&mut self, id: Option<u32>) {
make_request_async(Request::toggle_loop(id));
}
pub fn pause(&mut self, id: Option<u32>) {
make_request_async(Request::pause(id));
}
pub fn resume(&mut self, id: Option<u32>) {
make_request_async(Request::resume(id));
}
pub fn stop(&mut self, id: Option<u32>) {
make_request_async(Request::stop(id));
}
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>> {
@@ -128,7 +215,13 @@ pub async fn run() -> Result<(), Box<dyn Error>> {
Ok(Box::new(SoundpadGui::new(&cc.egui_ctx)))
}),
) {
Ok(_) => Ok(()),
Ok(_) => {
let config = get_gui_config();
if config.pause_on_exit {
make_request_sync(Request::pause(None)).ok();
}
Ok(())
}
Err(e) => Err(e.into()),
}
}
+83 -39
View File
@@ -3,19 +3,88 @@ use eframe::{App, Frame as EFrame};
use egui::{CentralPanel, Context};
use pwsp::{
types::socket::Request,
utils::{
daemon::{get_daemon_config, is_daemon_running},
gui::make_request_sync,
},
utils::{daemon::get_daemon_config, gui::make_request_async},
};
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![];
for (id, ui_state) in &mut self.app_state.track_ui_states {
if ui_state.position_dragged {
seek_requests.push((*id, ui_state.position_slider_value));
}
if ui_state.volume_dragged {
volume_requests.push((*id, ui_state.volume_slider_value));
ui_state.volume_dragged = false;
}
}
for (id, pos) in seek_requests {
make_request_async(Request::seek(pos, Some(id)));
if let Some(ui_state) = self.app_state.track_ui_states.get_mut(&id) {
ui_state.position_dragged = false;
ui_state.ignore_position_update_until =
Some(Instant::now() + Duration::from_millis(300));
}
}
for (id, vol) in volume_requests {
make_request_async(Request::set_volume(vol, Some(id)));
if let Some(ui_state) = self.app_state.track_ui_states.get_mut(&id) {
ui_state.volume_dragged = false;
ui_state.ignore_volume_update_until =
Some(Instant::now() + Duration::from_millis(300));
}
}
if self.app_state.volume_dragged {
make_request_async(Request::set_volume(
self.app_state.volume_slider_value,
None,
));
self.app_state.volume_dragged = false;
self.app_state.ignore_volume_update_until =
Some(Instant::now() + Duration::from_millis(300));
if self.config.save_volume {
let mut daemon_config = get_daemon_config();
daemon_config.default_volume = Some(self.app_state.volume_slider_value);
daemon_config.save_to_file().ok();
}
}
// Sync audio player state
{
let guard = self.audio_player_state_shared.lock().unwrap();
let guard = self
.audio_player_state_shared
.lock()
.unwrap_or_else(|e| e.into_inner());
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);
@@ -26,10 +95,14 @@ impl App for SoundpadGui {
self.config.save_to_file().ok();
}
// Handle input
self.handle_input(ctx);
}
CentralPanel::default().show(ctx, |ui| {
if !is_daemon_running().unwrap() {
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;
}
@@ -42,36 +115,7 @@ impl App for SoundpadGui {
self.draw(ui).ok();
});
if self.app_state.position_dragged {
make_request_sync(Request::seek(self.app_state.position_slider_value)).ok();
let mut guard = self.audio_player_state_shared.lock().unwrap();
guard.new_position = Some(self.app_state.position_slider_value);
guard.position = self.app_state.position_slider_value;
self.app_state.position_dragged = false;
} else {
self.app_state.position_slider_value = self.audio_player_state.position;
}
if self.app_state.volume_dragged {
let new_volume = self.app_state.volume_slider_value;
make_request_sync(Request::set_volume(new_volume)).ok();
let mut guard = self.audio_player_state_shared.lock().unwrap();
guard.new_volume = Some(self.app_state.volume_slider_value);
guard.volume = self.app_state.volume_slider_value;
self.app_state.volume_dragged = false;
if self.config.save_volume {
let mut daemon_config = get_daemon_config();
daemon_config.default_volume = Some(new_volume);
daemon_config.save_to_file().ok();
}
} else {
self.app_state.volume_slider_value = self.audio_player_state.volume;
}
ctx.request_repaint_after_secs(1.0 / 60.0);
// Request repaint
ui.request_repaint_after_secs(1.0 / 60.0);
}
}
+316 -126
View File
@@ -1,13 +1,14 @@
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,
error::Error,
fs,
path::{Path, PathBuf},
@@ -22,49 +23,65 @@ pub enum PlayerState {
Playing,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrackInfo {
pub id: u32,
pub path: PathBuf,
pub duration: Option<f32>,
pub position: f32,
pub volume: f32,
pub looped: bool,
pub paused: bool,
}
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
pub struct FullState {
pub state: PlayerState,
pub tracks: Vec<TrackInfo>,
pub volume: f32,
pub current_input: String,
pub all_inputs: HashMap<String, String>,
}
pub struct PlayingSound {
pub id: u32,
pub sink: Player,
pub path: PathBuf,
pub duration: Option<f32>,
pub looped: bool,
pub volume: f32,
}
pub struct AudioPlayer {
_stream_handle: OutputStream,
sink: Sink,
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,
pub duration: Option<f32>,
pub current_file_path: Option<PathBuf>,
pub volume: f32, // Master volume
}
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(id) = daemon_config.default_input_id
&& let Ok(device) = get_device(id).await
&& device.device_type == DeviceType::Input
{
default_input_device = Some(device);
}
let stream_handle = OutputStreamBuilder::open_default_stream()?;
let sink = Sink::connect_new(stream_handle.mixer());
sink.set_volume(default_volume);
let stream_handle = DeviceSinkBuilder::open_default_sink()?;
let mut audio_player = AudioPlayer {
_stream_handle: stream_handle,
sink,
stream_handle,
tracks: HashMap::new(),
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,
duration: None,
current_file_path: None,
};
if default_input_device.is_some() {
if audio_player.input_device_name.is_some() {
audio_player.link_devices().await?;
}
@@ -74,8 +91,11 @@ 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"),
Err(_) => println!("Failed to send 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"),
}
}
}
@@ -83,155 +103,325 @@ impl AudioPlayer {
async fn link_devices(&mut self) -> Result<(), Box<dyn Error>> {
self.abort_link_thread();
if self.current_input_device.is_none() {
println!("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;
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(());
}
}
if pwsp_daemon_input.is_none() {
println!("Could not find pwsp-daemon input device, skipping device linking");
} else {
eprintln!("No input device selected, skipping device linking");
return Ok(());
}
let pwsp_daemon_input = pwsp_daemon_input.unwrap();
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 Some(output_fl) = input_device.output_fl.clone() else {
eprintln!("Failed to get pwsp-daemon output_fl");
return Ok(());
};
let Some(output_fr) = input_device.output_fr.clone() else {
eprintln!("Failed to get pwsp-daemon output_fr");
return Ok(());
};
let Some(input_fl) = daemon_input.input_fl.clone() else {
eprintln!("Failed to get pwsp-daemon input_fl");
return Ok(());
};
let Some(input_fr) = daemon_input.input_fr.clone() else {
eprintln!("Failed to get pwsp-daemon input_fr");
return Ok(());
};
let current_input_device = self.current_input_device.clone().unwrap();
let output_fl = current_input_device
.clone()
.output_fl
.expect("Failed to get pwsp-daemon output_fl");
let output_fr = current_input_device
.clone()
.output_fr
.expect("Failed to get pwsp-daemon output_fl");
let input_fl = pwsp_daemon_input
.clone()
.input_fl
.expect("Failed to get pwsp-daemon input_fl");
let input_fr = pwsp_daemon_input
.clone()
.input_fr
.expect("Failed to get pwsp-daemon input_fr");
self.input_link_sender = Some(create_link(output_fl, output_fr, input_fl, input_fr)?);
Ok(())
}
pub fn pause(&mut self) {
if self.get_state() == PlayerState::Playing {
self.sink.pause();
pub fn pause(&mut self, id: Option<u32>) {
if let Some(id) = id {
if let Some(sound) = self.tracks.get_mut(&id) {
sound.sink.pause();
}
} else {
for sound in self.tracks.values_mut() {
sound.sink.pause();
}
}
}
pub fn resume(&mut self) {
if self.get_state() == PlayerState::Paused {
self.sink.play();
pub fn resume(&mut self, id: Option<u32>) {
if let Some(id) = id {
if let Some(sound) = self.tracks.get_mut(&id) {
sound.sink.play();
}
} else {
for sound in self.tracks.values_mut() {
sound.sink.play();
}
}
}
pub fn stop(&mut self) {
self.sink.stop();
pub fn stop(&mut self, id: Option<u32>) {
if let Some(id) = id {
self.tracks.remove(&id);
} else {
self.tracks.clear();
}
}
pub fn is_paused(&self) -> bool {
self.sink.is_paused()
if self.tracks.is_empty() {
return false;
}
self.tracks.values().all(|s| s.sink.is_paused())
}
pub fn get_state(&mut self) -> PlayerState {
if self.sink.len() == 0 {
pub fn get_state(&self) -> PlayerState {
if self.tracks.is_empty() {
return PlayerState::Stopped;
}
if self.sink.is_paused() {
if self
.tracks
.values()
.any(|s| !s.sink.is_paused() && !s.sink.empty())
{
return PlayerState::Playing;
}
if self.is_paused() {
return PlayerState::Paused;
}
PlayerState::Playing
PlayerState::Stopped
}
pub fn set_volume(&mut self, volume: f32) {
self.volume = volume;
self.sink.set_volume(volume);
}
pub fn get_position(&mut self) -> f32 {
if self.get_state() == PlayerState::Stopped {
return 0.0;
}
self.sink.get_pos().as_secs_f32()
}
pub fn seek(&mut self, position: f32) -> Result<(), Box<dyn Error>> {
match self.sink.try_seek(Duration::from_secs_f32(position)) {
Ok(_) => Ok(()),
Err(err) => Err(err.into()),
}
}
pub fn get_duration(&mut self) -> Result<f32, Box<dyn Error>> {
if self.get_state() == PlayerState::Stopped {
Err("Nothing is playing right now".into())
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 {
match self.duration {
Some(duration) => Ok(duration),
None => Err("Couldn't determine duration for current file".into()),
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) {
sound.volume = volume;
sound.sink.set_volume(self.volume * volume);
}
} else {
self.volume = volume;
for sound in self.tracks.values_mut() {
sound.sink.set_volume(self.volume * sound.volume);
}
}
}
pub async fn play(&mut self, file_path: &Path) -> Result<(), Box<dyn Error>> {
if !file_path.exists() {
return Err(format!("File does not exist: {}", file_path.display()).into());
pub fn get_position(&self, id: Option<u32>) -> f32 {
if let Some(id) = id {
if let Some(sound) = self.tracks.get(&id) {
return sound.sink.get_pos().as_secs_f32();
}
} else if let Some(sound) = self.tracks.values().last() {
// Fallback to last added track if no ID
return sound.sink.get_pos().as_secs_f32();
}
0.0
}
let file = fs::File::open(file_path)?;
match Decoder::try_from(file) {
Ok(source) => {
self.current_file_path = Some(file_path.to_path_buf());
pub fn seek(&mut self, position: f32, id: Option<u32>) -> Result<(), Box<dyn Error>> {
let position = if position < 0.0 { 0.0 } else { position };
if let Some(duration) = source.total_duration() {
self.duration = Some(duration.as_secs_f32());
} else {
self.duration = None;
if let Some(id) = id {
if let Some(sound) = self.tracks.get_mut(&id) {
sound.sink.try_seek(Duration::from_secs_f32(position))?;
}
} else {
// Seek all? Or last? Let's seek all for now if no ID provided
for sound in self.tracks.values_mut() {
sound.sink.try_seek(Duration::from_secs_f32(position)).ok();
}
}
Ok(())
}
pub fn get_duration(&mut self, id: Option<u32>) -> Result<f32, Box<dyn Error>> {
if let Some(id) = id {
if let Some(sound) = self.tracks.get(&id) {
return sound.duration.ok_or("Unknown duration".into());
}
} else if let Some(sound) = self.tracks.values().last() {
return sound.duration.ok_or("Unknown duration".into());
}
Err("No track playing".into())
}
pub async fn play(
&mut self,
file_path: &Path,
concurrent: bool,
) -> Result<u32, Box<dyn Error>> {
let path_buf = file_path.to_path_buf();
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());
}
self.sink.stop();
self.sink.append(source);
self.sink.play();
self.link_devices().await?;
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?;
Ok(())
match decoder_result {
Ok(source) => {
if !concurrent {
self.tracks.clear();
}
let id = self.next_id;
self.next_id += 1;
let duration = source.total_duration().map(|d| d.as_secs_f32());
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();
let sound = PlayingSound {
id,
sink,
path: file_path.to_path_buf(),
duration,
looped: false,
volume: 1.0,
};
self.tracks.insert(id, sound);
Ok(id)
}
Err(err) => Err(err.into()),
Err(err) => Err(err as Box<dyn Error>),
}
}
pub fn get_current_file_path(&mut self) -> &Option<PathBuf> {
if self.get_state() == PlayerState::Stopped {
self.current_file_path = None;
pub fn set_loop(&mut self, enabled: bool, id: Option<u32>) {
if let Some(id) = id {
if let Some(sound) = self.tracks.get_mut(&id) {
sound.looped = enabled;
}
} else {
// Set loop for all? Or just last?
// Let's set for all.
for sound in self.tracks.values_mut() {
sound.looped = enabled;
}
}
&self.current_file_path
}
pub async fn set_current_input_device(&mut self, id: u32) -> Result<(), Box<dyn Error>> {
let input_device = get_device(id).await?;
pub fn get_tracks(&self) -> Vec<TrackInfo> {
let mut tracks: Vec<_> = self
.tracks
.values()
.map(|sound| TrackInfo {
id: sound.id,
path: sound.path.clone(),
duration: sound.duration,
position: sound.sink.get_pos().as_secs_f32(),
volume: sound.volume,
looped: sound.looped,
paused: sound.sink.is_paused(),
})
.collect();
tracks.sort_by_key(|t| t.id);
tracks
}
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 {
if sound.sink.empty() && sound.looped {
restarts.push(*id);
}
}
let mut restart_futures = vec![];
for id in restarts {
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();
}
}
}
self.tracks
.retain(|_, sound| !sound.sink.empty() || sound.looped);
}
pub async fn set_current_input_device(&mut self, name: &str) -> Result<(), Box<dyn Error>> {
let input_device = get_device(name).await?;
if input_device.device_type != DeviceType::Input {
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?;
+297 -48
View File
@@ -1,9 +1,15 @@
use crate::{
types::socket::Response,
utils::{daemon::get_audio_player, pipewire::get_all_devices},
types::{
audio_player::{FullState, PlayerState},
socket::Response,
},
utils::{
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,44 +18,78 @@ pub trait Executable {
pub struct PingCommand {}
pub struct PauseCommand {}
pub struct KillCommand {}
pub struct ResumeCommand {}
pub struct PauseCommand {
pub id: Option<u32>,
}
pub struct StopCommand {}
pub struct ResumeCommand {
pub id: Option<u32>,
}
pub struct TogglePauseCommand {
pub id: Option<u32>,
}
pub struct StopCommand {
pub id: Option<u32>,
}
pub struct IsPausedCommand {}
pub struct GetStateCommand {}
pub struct GetVolumeCommand {}
pub struct GetVolumeCommand {
pub id: Option<u32>,
}
pub struct SetVolumeCommand {
pub volume: Option<f32>,
pub id: Option<u32>,
}
pub struct GetPositionCommand {}
pub struct GetPositionCommand {
pub id: Option<u32>,
}
pub struct SeekCommand {
pub position: Option<f32>,
pub id: Option<u32>,
}
pub struct GetDurationCommand {}
pub struct GetDurationCommand {
pub id: Option<u32>,
}
pub struct PlayCommand {
pub file_path: Option<PathBuf>,
pub concurrent: Option<bool>,
}
pub struct GetCurrentFilePathCommand {}
pub struct GetTracksCommand {}
pub struct GetCurrentInputCommand {}
pub struct GetAllInputsCommand {}
pub struct SetCurrentInputCommand {
pub name: Option<String>,
}
pub struct SetLoopCommand {
pub enabled: Option<bool>,
pub id: Option<u32>,
}
pub struct ToggleLoopCommand {
pub id: Option<u32>,
}
pub struct GetDaemonVersionCommand {}
pub struct GetFullStateCommand {}
#[async_trait]
impl Executable for PingCommand {
async fn execute(&self) -> Response {
@@ -57,11 +97,21 @@ 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;
audio_player.pause();
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")
}
}
@@ -69,17 +119,64 @@ 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;
audio_player.resume();
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")
}
}
#[async_trait]
impl Executable for TogglePauseCommand {
async fn execute(&self) -> Response {
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");
}
// This logic is a bit tricky with multiple tracks.
// If ID is provided, toggle that track.
// If not, toggle global pause state?
// For now, let's just use pause/resume based on global state if no ID.
if let Some(id) = self.id {
if let Some(track) = audio_player.tracks.get(&id) {
if track.sink.is_paused() {
audio_player.resume(Some(id));
Response::new(true, "Audio was resumed")
} else {
audio_player.pause(Some(id));
Response::new(true, "Audio was paused")
}
} else {
Response::new(false, "Track not found")
}
} else {
if audio_player.is_paused() {
audio_player.resume(None);
Response::new(true, "Audio was resumed")
} else {
audio_player.pause(None);
Response::new(true, "Audio was paused")
}
}
}
}
#[async_trait]
impl Executable for StopCommand {
async fn execute(&self) -> Response {
let mut audio_player = get_audio_player().await.lock().await;
audio_player.stop();
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")
}
}
@@ -87,7 +184,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)
}
@@ -96,18 +196,32 @@ impl Executable for IsPausedCommand {
#[async_trait]
impl Executable for GetStateCommand {
async fn execute(&self) -> Response {
let mut 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")
}
}
}
@@ -115,8 +229,11 @@ 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;
audio_player.set_volume(volume);
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 {
Response::new(false, "Invalid volume value")
@@ -127,8 +244,11 @@ impl Executable for SetVolumeCommand {
#[async_trait]
impl Executable for GetPositionCommand {
async fn execute(&self) -> Response {
let mut audio_player = get_audio_player().await.lock().await;
let position = audio_player.get_position();
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())
}
}
@@ -137,8 +257,11 @@ 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;
match audio_player.seek(position) {
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()),
}
@@ -151,8 +274,11 @@ 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;
match audio_player.get_duration() {
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()),
}
@@ -163,9 +289,15 @@ 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;
match audio_player.play(file_path).await {
Ok(_) => Response::new(true, format!("Now playing {}", file_path.display())),
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
{
Ok(id) => Response::new(true, id.to_string()),
Err(err) => Response::new(false, err.to_string()),
}
} else {
@@ -175,14 +307,16 @@ impl Executable for PlayCommand {
}
#[async_trait]
impl Executable for GetCurrentFilePathCommand {
impl Executable for GetTracksCommand {
async fn execute(&self) -> Response {
let mut audio_player = get_audio_player().await.lock().await;
let current_file_path = audio_player.get_current_file_path();
if let Some(current_file_path) = current_file_path {
Response::new(true, current_file_path.to_str().unwrap())
} else {
Response::new(false, "No file is playing")
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();
match serde_json::to_string(&tracks) {
Ok(json) => Response::new(true, json),
Err(err) => Response::new(false, format!("Failed to serialize tracks: {}", err)),
}
}
}
@@ -190,9 +324,19 @@ impl Executable for GetCurrentFilePathCommand {
#[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.id, 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")
}
@@ -202,14 +346,17 @@ 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" {
continue;
}
let string = format!("{} - {}", device.id, device.nick);
let string = format!("{} - {}", device.name, device.nick);
input_devices_strings.push(string);
}
let response_message = input_devices_strings.join("; ");
@@ -221,9 +368,12 @@ impl Executable for GetAllInputsCommand {
#[async_trait]
impl Executable for SetCurrentInputCommand {
async fn execute(&self) -> Response {
if let Some(id) = self.id {
let mut audio_player = get_audio_player().await.lock().await;
match audio_player.set_current_input_device(id).await {
if let Some(name) = &self.name {
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()),
}
@@ -232,3 +382,102 @@ impl Executable for SetCurrentInputCommand {
}
}
}
#[async_trait]
impl Executable for SetLoopCommand {
async fn execute(&self) -> Response {
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) => {
audio_player.set_loop(enabled, self.id);
Response::new(true, format!("Loop was set to {}", enabled))
}
None => Response::new(false, "Invalid enabled value"),
}
}
}
#[async_trait]
impl Executable for ToggleLoopCommand {
async fn execute(&self) -> Response {
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;
Response::new(true, format!("Loop was set to {}", track.looped))
} else {
Response::new(false, "Track not found")
}
} else {
// Toggle all?
for track in audio_player.tracks.values_mut() {
track.looped = !track.looped;
}
Response::new(true, "Loop toggled for all tracks")
}
}
}
#[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)),
}
}
}
+26 -12
View File
@@ -1,20 +1,22 @@
use crate::utils::config::get_config_path;
use serde::{Deserialize, Serialize};
use std::{collections::HashSet, error::Error, fs, path::PathBuf};
use std::{error::Error, fs, path::PathBuf};
#[derive(Default, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DaemonConfig {
pub default_input_id: Option<u32>,
pub default_input_name: Option<String>,
pub default_volume: Option<f32>,
}
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,31 +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![],
}
}
}
@@ -57,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
@@ -76,6 +87,9 @@ 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()),
}
}
}
+36 -14
View File
@@ -1,39 +1,61 @@
use crate::types::audio_player::PlayerState;
use crate::types::audio_player::{PlayerState, TrackInfo};
use egui::Id;
use std::{
collections::{HashMap, HashSet},
path::PathBuf,
time::Instant,
};
#[derive(Default, Debug)]
pub struct AppState {
pub search_query: String,
pub struct TrackUiState {
pub position_slider_value: f32,
pub volume_slider_value: f32,
pub position_dragged: bool,
pub volume_dragged: bool,
pub ignore_position_update_until: Option<Instant>,
pub ignore_volume_update_until: Option<Instant>,
}
#[derive(Default, Debug)]
pub struct AppState {
pub search_query: String,
pub track_ui_states: HashMap<u32, TrackUiState>,
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>,
}
#[derive(Default, Debug, Clone)]
pub struct AudioPlayerState {
pub state: PlayerState,
pub new_state: Option<PlayerState>,
pub current_file_path: PathBuf,
pub is_paused: bool,
pub tracks: Vec<TrackInfo>,
pub volume: f32,
pub new_volume: Option<f32>,
pub position: f32,
pub new_position: Option<f32>,
pub duration: f32,
pub volume: f32, // Master volume
pub current_input: u32,
pub all_inputs: HashMap<u32, String>,
pub current_input: String,
pub all_inputs: HashMap<String, String>,
pub all_inputs_sorted: Vec<(String, String)>,
pub is_daemon_running: bool,
}
+112 -22
View File
@@ -24,44 +24,100 @@ impl Request {
Request::new("ping", vec![])
}
pub fn pause() -> Self {
Request::new("pause", vec![])
pub fn kill() -> Self {
Request::new("kill", vec![])
}
pub fn resume() -> Self {
Request::new("resume", vec![])
pub fn pause(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("pause", args)
}
pub fn stop() -> Self {
Request::new("stop", vec![])
pub fn resume(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("resume", args)
}
pub fn play(file_path: &str) -> Self {
Request::new("play", vec![("file_path", file_path)])
pub fn toggle_pause(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("toggle_pause", args)
}
pub fn stop(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("stop", args)
}
pub fn play(file_path: &str, concurrent: bool) -> Self {
Request::new(
"play",
vec![
("file_path", file_path),
("concurrent", &concurrent.to_string()),
],
)
}
pub fn get_is_paused() -> Self {
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() -> Self {
Request::new("get_position", vec![])
pub fn get_position(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_position", args)
}
pub fn get_duration() -> Self {
Request::new("get_duration", vec![])
pub fn get_duration(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_duration", args)
}
pub fn get_state() -> Self {
Request::new("get_state", vec![])
}
pub fn get_current_file_path() -> Self {
Request::new("get_current_file_path", vec![])
pub fn get_tracks() -> Self {
Request::new("get_tracks", vec![])
}
pub fn get_input() -> Self {
@@ -72,16 +128,50 @@ impl Request {
Request::new("get_inputs", vec![])
}
pub fn set_volume(volume: f32) -> Self {
Request::new("set_volume", vec![("volume", &volume.to_string())])
pub fn set_volume(volume: f32, id: Option<u32>) -> Self {
let mut args = vec![("volume".to_string(), volume.to_string())];
if let Some(id) = id {
args.push(("id".to_string(), id.to_string()));
}
Request::new("set_volume".to_string(), args)
}
pub fn seek(position: f32) -> Self {
Request::new("seek", vec![("position", &position.to_string())])
pub fn seek(position: f32, id: Option<u32>) -> Self {
let mut args = vec![("position".to_string(), position.to_string())];
if let Some(id) = id {
args.push(("id".to_string(), id.to_string()));
}
Request::new("seek".to_string(), args)
}
pub fn set_input(id: u32) -> Self {
Request::new("set_input", vec![("input_id", &id.to_string())])
pub fn set_input(name: &str) -> Self {
Request::new("set_input", vec![("input_name", name)])
}
pub fn set_loop(enabled: &str, id: Option<u32>) -> Self {
let mut args = vec![("enabled".to_string(), enabled.to_string())];
if let Some(id) = id {
args.push(("id".to_string(), id.to_string()));
}
Request::new("set_loop".to_string(), args)
}
pub fn toggle_loop(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("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![])
}
}
+37 -17
View File
@@ -3,14 +3,18 @@ use crate::types::{commands::*, socket::Request};
use std::path::PathBuf;
pub fn parse_command(request: &Request) -> Option<Box<dyn Executable + Send>> {
let id = request.args.get("id").and_then(|s| s.parse::<u32>().ok());
match request.name.as_str() {
"ping" => Some(Box::new(PingCommand {})),
"pause" => Some(Box::new(PauseCommand {})),
"resume" => Some(Box::new(ResumeCommand {})),
"stop" => Some(Box::new(StopCommand {})),
"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
@@ -18,9 +22,9 @@ pub fn parse_command(request: &Request) -> Option<Box<dyn Executable + Send>> {
.unwrap_or(&String::new())
.parse::<f32>()
.ok();
Some(Box::new(SetVolumeCommand { volume }))
Some(Box::new(SetVolumeCommand { volume, id }))
}
"get_position" => Some(Box::new(GetPositionCommand {})),
"get_position" => Some(Box::new(GetPositionCommand { id })),
"seek" => {
let position = request
.args
@@ -28,9 +32,9 @@ pub fn parse_command(request: &Request) -> Option<Box<dyn Executable + Send>> {
.unwrap_or(&String::new())
.parse::<f32>()
.ok();
Some(Box::new(SeekCommand { position }))
Some(Box::new(SeekCommand { position, id }))
}
"get_duration" => Some(Box::new(GetDurationCommand {})),
"get_duration" => Some(Box::new(GetDurationCommand { id })),
"play" => {
let file_path = request
.args
@@ -38,20 +42,36 @@ pub fn parse_command(request: &Request) -> Option<Box<dyn Executable + Send>> {
.unwrap_or(&String::new())
.parse::<PathBuf>()
.ok();
Some(Box::new(PlayCommand { file_path }))
let concurrent = request
.args
.get("concurrent")
.unwrap_or(&String::new())
.parse::<bool>()
.ok();
Some(Box::new(PlayCommand {
file_path,
concurrent,
}))
}
"get_current_file_path" => Some(Box::new(GetCurrentFilePathCommand {})),
"get_tracks" => Some(Box::new(GetTracksCommand {})),
"get_input" => Some(Box::new(GetCurrentInputCommand {})),
"get_inputs" => Some(Box::new(GetAllInputsCommand {})),
"set_input" => {
let id = request
.args
.get("input_id")
.unwrap_or(&String::new())
.parse::<u32>()
.ok();
Some(Box::new(SetCurrentInputCommand { id }))
let name = Some(request.args.get("input_name").unwrap_or(&String::new())).cloned();
Some(Box::new(SetCurrentInputCommand { name }))
}
"set_loop" => {
let enabled = request
.args
.get("enabled")
.unwrap_or(&String::new())
.parse::<bool>()
.ok();
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 {})),
_ => None,
}
}
+22 -33
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() {
println!("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() {
println!("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(())
}
@@ -124,7 +113,7 @@ pub async fn wait_for_daemon() -> Result<(), Box<dyn Error>> {
Ok(())
}
pub async fn make_request(request: Request) -> Result<Response, Box<dyn Error>> {
pub async fn make_request(request: Request) -> Result<Response, Box<dyn Error + Send + Sync>> {
let socket_path = get_runtime_dir().join("daemon.sock");
let mut stream = UnixStream::connect(socket_path).await?;
+55 -103
View File
@@ -1,16 +1,14 @@
use crate::{
types::{
audio_player::PlayerState,
audio_player::FullState,
config::GuiConfig,
gui::AudioPlayerState,
socket::{Request, Response},
},
utils::daemon::{make_request, wait_for_daemon},
utils::daemon::{is_daemon_running, make_request},
};
use std::{
collections::HashMap,
error::Error,
path::PathBuf,
sync::{Arc, Mutex},
};
use tokio::time::{Duration, sleep};
@@ -24,7 +22,17 @@ pub fn get_gui_config() -> GuiConfig {
}
pub fn make_request_sync(request: Request) -> Result<Response, Box<dyn Error>> {
futures::executor::block_on(make_request(request))
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current()
.block_on(make_request(request))
.map_err(|e| e as Box<dyn Error>)
})
}
pub fn make_request_async(request: Request) {
tokio::spawn(async move {
make_request(request).await.ok();
});
}
pub fn format_time_pair(position: f32, duration: f32) -> String {
@@ -40,117 +48,61 @@ 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_millis(100);
let sleep_duration = Duration::from_secs_f32(1.0 / 60.0);
loop {
wait_for_daemon().await.ok();
let is_running = is_daemon_running().unwrap_or(false);
let state_req = Request::get_state();
let file_path_req = Request::get_current_file_path();
let is_paused_req = Request::get_is_paused();
let volume_req = Request::get_volume();
let position_req = Request::get_position();
let duration_req = Request::get_duration();
let current_input_req = Request::get_input();
let all_inputs_req = Request::get_inputs();
if !is_running {
{
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_res = make_request(state_req).await.unwrap_or_default();
let file_path_res = make_request(file_path_req).await.unwrap_or_default();
let is_paused_res = make_request(is_paused_req).await.unwrap_or_default();
let volume_res = make_request(volume_req).await.unwrap_or_default();
let position_res = make_request(position_req).await.unwrap_or_default();
let duration_res = make_request(duration_req).await.unwrap_or_default();
let current_input_res = make_request(current_input_req).await.unwrap_or_default();
let all_inputs_res = make_request(all_inputs_req).await.unwrap_or_default();
let full_state_req = Request::get_full_state();
let full_state_res = make_request(full_state_req).await.unwrap_or_default();
let state = match state_res.status {
true => serde_json::from_str::<PlayerState>(&state_res.message).unwrap(),
false => PlayerState::default(),
};
if full_state_res.status {
let full_state: FullState =
serde_json::from_str(&full_state_res.message).unwrap_or_default();
let file_path = match file_path_res.status {
true => PathBuf::from(file_path_res.message),
false => PathBuf::new(),
};
let is_paused = match is_paused_res.status {
true => is_paused_res.message == "true",
false => false,
};
let volume = match volume_res.status {
true => volume_res.message.parse::<f32>().unwrap(),
false => 0.0,
};
let position = match position_res.status {
true => position_res.message.parse::<f32>().unwrap(),
false => 0.0,
};
let duration = match duration_res.status {
true => duration_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()
.parse::<u32>()
.unwrap_or_default(),
false => 0,
};
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(" - ").and_then(|(k, v)| {
k.trim()
.parse::<u32>()
.ok()
.map(|key| (key, v.trim().to_string()))
})
})
.collect(),
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.current_file_path = file_path;
guard.is_paused = is_paused;
guard.volume = match guard.new_volume {
Some(new_volume) => {
guard.new_volume = None;
new_volume
}
None => volume,
};
guard.position = match guard.new_position {
Some(new_position) => {
guard.new_position = None;
new_position
}
None => position,
};
guard.duration = if duration > 0.0 { duration } else { 1.0 };
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;
}
sleep(sleep_duration).await;
+36 -24
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)
@@ -77,6 +79,8 @@ async fn pw_get_global_objects_thread(
main_sender: mpsc::Sender<(Option<AudioDevice>, Option<Port>)>,
pw_receiver: pipewire::channel::Receiver<Terminate>,
) {
pipewire::init();
let main_loop = MainLoopRc::new(None).expect("Failed to initialize pipewire main loop");
// Stop main loop on Terminate message
@@ -149,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),
@@ -162,10 +165,13 @@ pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>), B
input_device.input_fl = Some(port.clone());
input_device.input_fr = Some(port)
}
"capture_MONO" => {
input_device.output_fl = Some(port.clone());
input_device.output_fr = Some(port);
}
_ => {}
}
} else if output_devices.contains_key(&node_id) {
let output_device = output_devices.get_mut(&node_id).unwrap();
} 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),
@@ -199,23 +205,27 @@ pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>), B
}
}
pub async fn get_device(node_id: u32) -> Result<AudioDevice, Box<dyn Error>> {
let (mut input_devices, output_devices) = get_all_devices().await?;
input_devices.extend(output_devices);
pub async fn get_device(device_name: &str) -> Result<AudioDevice, Box<dyn Error>> {
let (input_devices, output_devices) = get_all_devices().await?;
for device in input_devices {
if device.id == node_id {
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>> {
let (pw_sender, pw_receiver) = pipewire::channel::channel::<Terminate>();
let _pw_thread = thread::spawn(move || {
pipewire::init();
let main_loop = MainLoopRc::new(None).expect("Failed to initialize pipewire main loop");
let context = ContextRc::new(&main_loop, None).expect("Failed to create pipewire context");
let core = context
@@ -257,6 +267,8 @@ pub fn create_link(
let (pw_sender, pw_receiver) = pipewire::channel::channel::<Terminate>();
let _pw_thread = thread::spawn(move || {
pipewire::init();
let main_loop = MainLoopRc::new(None).expect("Failed to initialize pipewire main loop");
let context = ContextRc::new(&main_loop, None).expect("Failed to create pipewire context");
let core = context
+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();
}