Compare commits

..
138 Commits
Author SHA1 Message Date
arabianq a91252d7fb change version to 1.12.4 2026-07-07 19:31:00 +03:00
arabianq 239170df45 cargo update 2026-07-07 19:30:15 +03:00
arabianq 5fef42a2e2 cargo fmt 2026-07-07 19:29:48 +03:00
Tarasov AleksandrGitHubgoogle-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
4f8f663f12 fix(pwsp-gui): fix arbitrary file overwrite via path traversal (#159)
* Fix path traversal vulnerability in audio download logic

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

* std::path::Path -> Path

* add random id to the filename if it's unknown

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-07-07 19:29:12 +03:00
Tarasov AleksandrGitHubgoogle-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
6ccc55a1d0 perf(pwsp-gui): optimize gui file filtering performance (#160)
Avoid unnecessary string cloning and allocation in `pwsp-gui/src/gui/mod.rs` when checking if a file matches the search query.

Specifically, we replaced `.to_string_lossy().to_string()` with `.to_string_lossy()` because `.to_lowercase()` directly works on `Cow<str>`, avoiding an intermediate String allocation.

Measured improvement was verified using an isolated benchmark demonstrating roughly 10-15% reduction in execution time.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-07-07 19:00:16 +03:00
Tarasov AleksandrGitHubgoogle-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
d266a3f073 fix(pwsp-lib): fixed unhandled panic on missing config directory (#157)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-07-07 18:58:43 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d2993592be chore(deps): bump actions/upload-artifact from 4 to 7 (#153)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-07 16:56:24 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1a53ab62dc chore(deps): bump peaceiris/actions-gh-pages from 3 to 4 (#154)
Bumps [peaceiris/actions-gh-pages](https://github.com/peaceiris/actions-gh-pages) from 3 to 4.
- [Release notes](https://github.com/peaceiris/actions-gh-pages/releases)
- [Changelog](https://github.com/peaceiris/actions-gh-pages/blob/main/CHANGELOG.md)
- [Commits](https://github.com/peaceiris/actions-gh-pages/compare/v3...v4)

---
updated-dependencies:
- dependency-name: peaceiris/actions-gh-pages
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-07 16:06:50 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
51fcd94a61 chore(deps): bump actions/checkout from 4 to 7 (#155)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-07 15:59:53 +03:00
arabianq 7f6a122816 ci(flatpak): use /run/ccache for flatpak builds 2026-07-07 15:50:06 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
7ca01ed8cb chore(deps): bump stefanzweifel/git-auto-commit-action from 5 to 7 (#156)
Bumps [stefanzweifel/git-auto-commit-action](https://github.com/stefanzweifel/git-auto-commit-action) from 5 to 7.
- [Release notes](https://github.com/stefanzweifel/git-auto-commit-action/releases)
- [Changelog](https://github.com/stefanzweifel/git-auto-commit-action/blob/master/CHANGELOG.md)
- [Commits](https://github.com/stefanzweifel/git-auto-commit-action/compare/v5...v7)

---
updated-dependencies:
- dependency-name: stefanzweifel/git-auto-commit-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-07 15:41:31 +03:00
arabianq 07321afb60 docs(README): change pwsp-gui to pwsp 2026-07-05 22:41:49 +03:00
arabianq 8534e516ad docs(README): change arabianq-repo to pwsp for flatpak repo name 2026-07-05 22:41:15 +03:00
arabianq 16ce233f79 packages(flatapak): fix gpg key for flatpak repo 2026-07-05 22:36:20 +03:00
arabianq c2f4658871 packages(flatpak): add flatpakrepo file generation 2026-07-05 22:22:50 +03:00
arabianq 701bdebc88 packages(aur, rpm, deb): add openssl-devel as build dependency 2026-07-05 21:17:55 +03:00
arabianq d4ae78c7b0 ci(rpm): arabianq/pwsp-git -> arabianq/pwsp 2026-07-05 21:04:07 +03:00
arabianq 9e1d50e30c ci(rpm): add workflow for pwsp-git.rpm 2026-07-05 21:01:05 +03:00
arabianq 85b6c17bd1 deps(flatpak): update cargro-sources.json 2026-07-05 21:00:14 +03:00
arabianq cfe478b02d ci(rpm): replaced rustls with native-tls (OpenSSL) so it can build in copr 2026-07-05 20:56:18 +03:00
arabianq 0b23b3ed51 packages(deb): change package.metadata.deb.name to pwsp 2026-07-05 20:55:27 +03:00
arabianq 728d14006c change version to 1.12.3 2026-07-05 20:20:46 +03:00
arabianq f8bb84bbcf docs(readme): fix screenshot 2026-07-05 19:35:56 +03:00
Tarasov AleksandrandGitHub 0dbcc771f5 docs: new readme (#152) 2026-07-05 19:33:20 +03:00
arabianq b3a786a76c deps(flatpak): update cargo-sources.json 2026-07-05 19:11:08 +03:00
arabianq 972e303bff deps: cargo update 2026-07-05 19:07:46 +03:00
arabianq c406704ce5 fix(ci, flatter): fix building nightly repo 2026-07-05 18:56:32 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
fd4a35e0e1 chore(deps): bump crazy-max/ghaction-import-gpg from 6 to 7 (#147)
Bumps [crazy-max/ghaction-import-gpg](https://github.com/crazy-max/ghaction-import-gpg) from 6 to 7.
- [Release notes](https://github.com/crazy-max/ghaction-import-gpg/releases)
- [Commits](https://github.com/crazy-max/ghaction-import-gpg/compare/v6...v7)

---
updated-dependencies:
- dependency-name: crazy-max/ghaction-import-gpg
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-05 18:44:26 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c793d09006 chore(deps): bump actions/setup-python from 5 to 6 (#148)
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-05 18:44:13 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e4dd6d8b56 chore(deps): bump actions/cache from 4 to 6 (#149)
Bumps [actions/cache](https://github.com/actions/cache) from 4 to 6.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-05 18:43:58 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
457416779a chore(deps): bump softprops/action-gh-release from 2 to 3 (#150)
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-05 18:43:42 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b0ac5490cc chore(deps): bump actions/download-artifact from 4 to 8 (#151)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-05 18:43:27 +03:00
arabianq a728671b01 fix(ci, flatter): fixed building nightly repo 2026-07-05 18:31:38 +03:00
arabianq 08153e4a1c fix(ci, flatter): fixed building nightly repo 2026-07-05 18:22:03 +03:00
arabianq c4699002fd fix(ci, deb): add secion and priority fields to the package.metadata.deb 2026-07-05 18:09:52 +03:00
arabianq dbcbf36df0 ci(deb): add nightly builds 2026-07-05 18:03:22 +03:00
arabianq 873ed9a6db fix github pages 2026-07-05 17:58:38 +03:00
Tarasov AleksandrandGitHub 601a5ce336 ci: refactor and optimize GitHub Actions workflows (#146)
* ci: refactor and optimize GitHub Actions workflows\n\n- **Consolidate build processes (`build.yml`, `release.yml`)**: Merged the standalone `.zip` (linux-build) and `.deb` (deb-build) jobs into a single matrix job (`build-and-package` / `build-release`).
  This prevents compiling the Rust binaries twice per architecture, effectively cutting the build time in half and saving CI runner minutes.
    - **Parallelize and fix Flatpak deployment (`flatter.yml`)**:
      - Converted `x86_64` and `aarch64` flatpak jobs to run concurrently via a matrix strategy, removing the sequential bottleneck.
      - Resolved an issue where GitHub Pages would only host the `aarch64` repository. Introduced a new `prepare-repo` job that downloads the `.flatpak` bundles from both architectures, merges them into a
  single OSTree repository locally, signs it with GPG, and deploys the unified multi-arch repository to GitHub Pages.
      - Configured `flatter.yml` to automatically attach the offline `.flatpak` bundles to GitHub Releases when triggered by a release event.
    - **Toolchain and cleanup**: Switched hardcoded Rust toolchain version (`1.96.0`) to `stable` across all workflows to prevent manual bumps in the future. Simplified bash scripts and removed redundant
  dependency installation steps.

* Extract Copr trigger into a separate job: Copr build now starts immediately in parallel with GitHub Actions builds, eliminating unnecessary waiting time.

* use ubuntu 22.04 to build binaries instead of ubuntu-latest

* fix cache poisoning

* another attemp to fix cache poisoning

* disable cache in setup-rust-toolchain

* use ubuntu-latest

* optimize dependencies in debug profile

* remove --release flag from build.yml and CARGO env vars

* fix flatter names collision

* better flatter key changing

* auto formatting

* add auto updating cargo-sources for dependabot pull requests

* add groups to dependabot.yml

* add github-actions to dependabot

* add ppa deb repo

* add concurrency: gh-pages-deploy
2026-07-05 17:49:38 +03:00
Tarasov AleksandrandGitHub 62a851714e Update funding link in FUNDING.yml 2026-07-02 14:04:03 +03:00
arabianq cc152de59f change version to 1.12.2 2026-07-02 11:11:50 +03:00
arabianq 501ff9bd64 deps(flatpak): update cargo-sources.json 2026-07-01 18:43:49 +03:00
arabianq f83e18b800 cargo update 2026-07-01 18:03:57 +03:00
arabianq ba920473c7 refactor: deprecated CentralPanel::show_inside -> show 2026-07-01 18:03:34 +03:00
arabianq dfb5985e4a fix: egui 0.35.0 changes 2026-07-01 18:03:00 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Tarasov Aleksandr
52dc0e3a09 chore(deps): bump egui_dnd from 0.15.0 to 0.16.0 (#145)
Bumps [egui_dnd](https://github.com/lucasmerlin/hello_egui) from 0.15.0 to 0.16.0.
- [Release notes](https://github.com/lucasmerlin/hello_egui/releases)
- [Changelog](https://github.com/lucasmerlin/hello_egui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lucasmerlin/hello_egui/compare/egui_dnd-v0.15.0...egui_dnd-v0.16.0)

---
updated-dependencies:
- dependency-name: egui_dnd
  dependency-version: 0.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Tarasov Aleksandr <55220741+arabianq@users.noreply.github.com>
2026-07-01 17:57:57 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
4ce16d5301 chore(deps): bump egui_material_icons from 0.6.0 to 0.7.0 (#142)
Bumps [egui_material_icons](https://github.com/lucasmerlin/hello_egui) from 0.6.0 to 0.7.0.
- [Release notes](https://github.com/lucasmerlin/hello_egui/releases)
- [Changelog](https://github.com/lucasmerlin/hello_egui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lucasmerlin/hello_egui/compare/egui_material_icons-v0.6.0...egui_material_icons-v0.7.0)

---
updated-dependencies:
- dependency-name: egui_material_icons
  dependency-version: 0.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-01 17:55:46 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
48cda995ee chore(deps): bump egui_extras from 0.34.3 to 0.35.0 (#144)
Bumps [egui_extras](https://github.com/emilk/egui) from 0.34.3 to 0.35.0.
- [Release notes](https://github.com/emilk/egui/releases)
- [Changelog](https://github.com/emilk/egui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/emilk/egui/compare/0.34.3...0.35.0)

---
updated-dependencies:
- dependency-name: egui_extras
  dependency-version: 0.35.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-01 17:48:00 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
7950f75c07 chore(deps): bump eframe from 0.34.3 to 0.35.0 (#143)
Bumps [eframe](https://github.com/emilk/egui) from 0.34.3 to 0.35.0.
- [Release notes](https://github.com/emilk/egui/releases)
- [Changelog](https://github.com/emilk/egui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/emilk/egui/compare/0.34.3...0.35.0)

---
updated-dependencies:
- dependency-name: eframe
  dependency-version: 0.35.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-01 17:43:56 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
38ad8d5bc2 chore(deps): bump egui from 0.34.3 to 0.35.0 (#141)
Bumps [egui](https://github.com/emilk/egui) from 0.34.3 to 0.35.0.
- [Release notes](https://github.com/emilk/egui/releases)
- [Changelog](https://github.com/emilk/egui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/emilk/egui/compare/0.34.3...0.35.0)

---
updated-dependencies:
- dependency-name: egui
  dependency-version: 0.35.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-01 17:41:24 +03:00
arabianq 347dee713c deps: update cargo-sources.json [no ci] 2026-06-26 00:03:42 +03:00
arabianq e6e6293e20 change version to 1.12.1 [no ci] 2026-06-25 23:57:52 +03:00
arabianq a1e53aa536 deps: update cargo-sources.json 2026-06-25 23:43:08 +03:00
arabianq bff50b99f1 deps: update rodio 2026-06-25 23:35:09 +03:00
arabianq 09b86dc108 cargo update 2026-06-25 23:32:58 +03:00
arabianq 30070c439b add more options to FUNDING.yml 2026-06-25 23:30:28 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>arabianq
44c3a5e3a7 chore(deps): bump itertools from 0.14.0 to 0.15.0 (#140)
* chore(deps): bump itertools from 0.14.0 to 0.15.0

Bumps [itertools](https://github.com/rust-itertools/itertools) from 0.14.0 to 0.15.0.
- [Changelog](https://github.com/rust-itertools/itertools/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-itertools/itertools/compare/v0.14.0...v0.15.0)

---
updated-dependencies:
- dependency-name: itertools
  dependency-version: 0.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

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

* deps: update cargo-sources.json

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: arabian <a.tevg@ya.ru>
2026-06-25 23:23:37 +03:00
Tarasov AleksandrGitHubgoogle-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
9a0bc92222 refactor(gui): pre-filter directory contents by supported extension (#136)
*  [performance] pre-filter directory contents by supported extension

💡 **What:** Moved the check for supported audio file extensions from the GUI rendering loop into the directory read/caching layer.
🎯 **Why:** The file extension string parsing and check was executing on every frame of the render loop for every file listed, causing unnecessary CPU overhead. By caching the pre-filtered items, we only execute the check once per directory load.
📊 **Measured Improvement:** In a micro-benchmark simulating 10k files (with 50% matching extensions), the unoptimized loop took ~14.2ms to execute, while the optimized loop takes ~5.8ms. This yields a ~59% speed improvement in the iteration logic over the baseline.

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

*  [performance] pre-filter directory contents by supported extension

💡 **What:** Moved the check for supported audio file extensions from the GUI rendering loop into the directory read/caching layer.
🎯 **Why:** The file extension string parsing and check was executing on every frame of the render loop for every file listed, causing unnecessary CPU overhead. By caching the pre-filtered items, we only execute the check once per directory load.
📊 **Measured Improvement:** In a micro-benchmark simulating 10k files (with 50% matching extensions), the unoptimized loop took ~14.2ms to execute, while the optimized loop takes ~5.8ms. This yields a ~59% speed improvement in the iteration logic over the baseline.

Flatpak cargo-sources.json regenerated to include criterion dependency to fix CI.

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

* chore: remove criterion bench dependency from gui app

This removes the `bench.rs` and `criterion` dependencies, which failed to compile in offline flatpak builds.

CI build is now fixed.

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

* cargo fmt

* deps: update cargo-sources.json

* chore: update cargo lock and flatpak sources

After removing criterion, the cargo lockfile and flatpak sources have been refreshed to properly remove the offline build dependencies issue.

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

* Revert "chore: update cargo lock and flatpak sources"

This reverts commit 27379d8e78.

[no ci]

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-06-25 23:23:14 +03:00
Tarasov AleksandrGitHubgoogle-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
3f85994f83 refactor(daemon): Consolidate PipeWire operations into a single background actor
Replaces the previous approach of spawning new PipeWire main loops and
awaiting a 100ms timeout for every `get_all_devices` call. Instead,
this introduces a `PipeWireManager` which maintains a single, persistent
background thread with a PipeWire main loop and registry listener. The
actor caches `AudioDevice`s and `Port`s continuously in a `HashMap`,
providing instantaneous responses to device queries via a channel.

This architectural change replaces `Sender<Terminate>` with a simple
`PwTerminator` drop-guard to manage the lifetimes of PipeWire objects
(virtual mic and links) via `PwCommand::DestroyObject` without creating
additional OS threads or PipeWire contexts.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-06-25 22:58:05 +03:00
arabianq caf8ac19a1 ci: add --no-wait flag to copr in release.yml 2026-06-19 15:31:05 +03:00
arabianq f563680ceb ci: add rust-cache 2026-06-19 15:29:03 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>arabianq
78ca2ddf68 chore(deps): bump opener from 0.8.4 to 0.8.5 (#139)
* chore(deps): bump opener from 0.8.4 to 0.8.5

Bumps [opener](https://github.com/Seeker14491/opener) from 0.8.4 to 0.8.5.
- [Changelog](https://github.com/Seeker14491/opener/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Seeker14491/opener/compare/v0.8.4...v0.8.5)

---
updated-dependencies:
- dependency-name: opener
  dependency-version: 0.8.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

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

* deps(flatpak): update cargo-sources.json

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: arabian <a.tevg@ya.ru>
2026-06-19 02:47:19 +03:00
arabianq 838fc1ce29 fix: increment pkgrel to 2 for pwsp-bin aur package 2026-06-05 23:52:31 +03:00
arabianq 622cf39fa2 fix: correct asset paths in pwsp-bin aur package 2026-06-05 23:52:06 +03:00
arabianq 809b1a8490 fix: update Copr build command to use correct repository name 2026-06-04 20:51:36 +03:00
arabianq d1a5275173 fix: remove redundant name parameter from Copr build command 2026-06-04 20:42:11 +03:00
arabianq e67f174a59 change version to 1.12.0 2026-06-04 20:18:56 +03:00
arabianq 6545431ac2 deps: update cargo-sources.json 2026-06-04 20:17:27 +03:00
arabianq 026ef97a72 deps: cargo update 2026-06-04 20:16:59 +03:00
arabianq 9f833cc30b deps: update rodio 2026-06-04 20:16:35 +03:00
Tarasov AleksandrandGitHub 410a2c7959 feat(gui): sorting options (#134)
* feat(gui): added an ability to copy ```pwsp-cli action play``` command for every sound

* feat(gui): added files sorting options
2026-06-04 20:14:28 +03:00
Tarasov AleksandrandGitHub c173e602ad feat(gui): added an ability to copy ``pwsp-cli action play`` command for every sound (#133) 2026-06-04 20:06:35 +03:00
arabianq 3576c634fd packages(rpm): update version macro to use git describe for accurate versioning 2026-06-03 22:35:33 +03:00
arabianq 5747f39ace packages(rpm): fix version macro in spec file 2026-06-03 22:27:03 +03:00
arabianq c501033834 packages(rpm): fix source and setup macros in spec file 2026-06-03 21:26:16 +03:00
arabianq c0a27e0c3b packages(rpm): fix VCS macro in spec file 2026-06-03 21:23:55 +03:00
arabianq 3c2882ef1f packages(rpm): update version and changelog macros 2026-06-03 21:20:18 +03:00
arabianq 36aed3f55d packages(rpm): add copr-cli installation and trigger build step 2026-06-03 21:18:17 +03:00
arabianq c48a425bb0 packages(rpm): update version and source macros 2026-06-03 21:07:07 +03:00
arabianq 9a5436cd35 packages(rpm): add gcc 13 as BuildRequires for openSUSE compatibility 2026-06-03 20:01:54 +03:00
arabianq 2ce243e896 packages(rpm): replace pkgconf-pkg-config with pkgconfig in BuildRequires 2026-06-03 19:49:55 +03:00
arabianq 57fb3fd7a3 packages(rpm): unify BuildRequires for openSUSE compatibility 2026-06-03 18:48:17 +03:00
arabianq 82b02bf520 packages(rpm): fix builds for opensuse (maybe) 2026-06-03 18:26:29 +03:00
arabianq 3d4dbbe866 packages(rpm): pwsp-git.spec.rpkg -> pwsp-git.spec 2026-06-02 23:46:54 +03:00
arabianq 70bd3a889a packages(rpm): add fallback macros for systems without rpmautospec 2026-06-02 23:44:32 +03:00
arabianq a7b5bdd2a9 packages(rp[m): remove bcond check 1 to allow building on older systems 2026-06-02 23:32:22 +03:00
arabianq 99fef4a167 packages(rpm): add pwsp-git.spec.rpkg 2026-06-02 23:30:22 +03:00
arabianq ae08f7ddc3 packages(rpm): disable debuginfo 2026-06-02 23:15:31 +03:00
arabianq 0060d0bdee deps: update cargo-sources.json 2026-06-02 22:38:27 +03:00
arabianq f91a49cb70 change version to 1.11.0 2026-06-02 22:36:40 +03:00
arabianq 8d513ff65b deps: cargo update 2026-06-02 22:35:26 +03:00
arabianq 226dfd91ff scripts: move generate-sources.sh into scripts/ 2026-06-02 22:34:57 +03:00
arabianq 344ea60fa5 scripts: add script to automatically update pwsp version 2026-06-02 22:34:24 +03:00
arabianq 8411cb3528 fix deb packaging 2026-06-02 22:21:23 +03:00
arabianq ad8f22a359 ci: add arm64 support 2026-06-02 21:59:46 +03:00
arabianq 4ec49d822b parallel deb packaging 2026-06-02 21:54:29 +03:00
arabianq ec2fa2a478 ci: better github actions 2026-06-02 21:44:56 +03:00
Tarasov AleksandrandGitHub e91465365d feat: better testing (#131)
* add tests

* update github actions to include testing step

* optimization
2026-06-02 21:37:22 +03:00
Tarasov AleksandrGitHubgoogle-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
0476329798 Refactor to Cargo Workspace (#129)
* Refactor project into a Cargo workspace with distinct packages

- Created a root `Cargo.toml` defining a workspace.
- Moved `src/types` and `src/utils` into a new `pwsp-lib` crate for shared logic.
- Split binaries into their own crates: `pwsp-daemon`, `pwsp-cli`, and `pwsp-gui`.
- Shifted all dependencies into `[workspace.dependencies]` for centralized version management.
- Updated import paths across all crates (e.g. from `pwsp::` to `pwsp_lib::`).
- Updated build scripts, GitHub actions, Flatpak manifest, and AUR PKGBUILD to support the new workspace structure.
- Ensured no core application logic was altered.

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

* Fix cargo-deb build process in GitHub actions for workspace architecture

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

* Fix cargo-deb asset discovery by using exact target/release paths

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

* refactor deps in Cargo.toml files

* fix incorrect assets path

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-06-02 21:12:44 +03:00
arabianq 6c59137639 fix icon 2026-06-01 23:37:46 +03:00
arabianq 3693a678ea new icon 2026-06-01 23:20:11 +03:00
arabianq 5511d23c3e deps: update cargo-sources.json 2026-06-01 23:05:02 +03:00
arabianq 818cd8b50d deps: cargo update 2026-06-01 23:04:42 +03:00
arabianq 6f7d631e28 deps: update rodio 2026-06-01 23:03:21 +03:00
arabianq 18904052c7 change version to 1.10.0 2026-06-01 23:01:32 +03:00
Tarasov AleksandrGitHubgoogle-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
6841d8d1c3 refactor(gui): break down monolithic draw_footer into helper methods (#127)
Split the long continuous block in `draw_footer` into smaller,
modular methods (`draw_mic_selection`, `draw_master_volume`,
`draw_hotkeys_button`, `draw_settings_button`) for better
readability and maintainability.

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

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

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

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

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

* fix: flatpak

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

* feat(gui): implemented theme switching

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

* fix(gui): fixed incorrect colors in light theme
2026-05-27 18:24:28 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>arabianq
798a6d1887 chore(deps): bump system-fonts from 0.1.0 to 0.1.1 (#118)
* chore(deps): bump system-fonts from 0.1.0 to 0.1.1

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

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

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

* deps: update cargo-sources.json

---------

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

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

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

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

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

* deps: update cargo-sources.json

---------

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

Bumps pipewire from 0.9.2 to 0.10.0.

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

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

* deps: update cargo-sources.json

---------

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

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

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

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

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

* 🔒 Fix insecure fallback directory and secure file creation

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

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

* small refactor

---------

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

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

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

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

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

* add Russian locale

* add missing entries

* add Spanish locale

* add French locale

* add Chinese locale

* add Arabic locale

* update cargo-sources.json
2026-05-15 20:29:39 +03:00
Tarasov AleksandrandGitHub dc1ecc81ea refactor: replace all rust Result with anyhow::Result (#103) 2026-05-15 19:32:26 +03:00
Tarasov AleksandrandGitHub 9b70bcd69d Update copyright year and owner in LICENSE file 2026-05-15 18:50:20 +03:00
70 changed files with 6897 additions and 4132 deletions
+1 -1
View File
@@ -1 +1 @@
custom: ['https://boosty.to/arabian'] custom: ['https://dalink.to/arabianq', 'https://boosty.to/arabian', 'https://sponsr.ru/notultranx/']
+23
View File
@@ -1,6 +1,29 @@
version: 2 version: 2
updates: updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "cargo" - package-ecosystem: "cargo"
directory: "/" directory: "/"
schedule: schedule:
interval: "weekly" interval: "weekly"
groups:
egui-stack:
patterns:
- "egui*"
- "eframe"
serde-stack:
patterns:
- "serde*"
tokio-stack:
patterns:
- "tokio*"
- "reqwest"
clap-stack:
patterns:
- "clap*"
+169 -33
View File
@@ -12,8 +12,16 @@ on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
linux-build: build-and-package:
runs-on: ubuntu-latest strategy:
matrix:
include:
- arch: x64
runner: ubuntu-latest
- arch: arm64
runner: ubuntu-24.04-arm
fail-fast: false
runs-on: ${{ matrix.runner }}
steps: steps:
- name: Install apt deps (jq/zip + dev-libs) - name: Install apt deps (jq/zip + dev-libs)
@@ -25,43 +33,53 @@ jobs:
libclang-dev \ libclang-dev \
libasound2-dev \ libasound2-dev \
libdbus-1-dev \ libdbus-1-dev \
libssl-dev \
pkg-config pkg-config
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Setup Rust toolchain - name: Setup Rust toolchain
uses: actions-rs/toolchain@v1 uses: actions-rust-lang/setup-rust-toolchain@v1
with: with:
toolchain: 1.94.1 toolchain: stable
cache: false
- name: Rust Cache
uses: swatinem/rust-cache@v2
with:
prefix-key: "v3-${{ matrix.runner }}"
- name: Run tests
run: cargo test --locked
- name: Build all binaries
run: cargo build --locked
- name: Extract all binary names - name: Extract all binary names
id: cargo-meta id: cargo-meta
run: | run: |
set -euo pipefail set -euo pipefail
BIN_NAMES=$(cargo metadata --no-deps --format-version 1 \ BIN_NAMES=$(cargo metadata --no-deps --format-version 1 \
| jq -r '.packages[0].targets[] | select(.kind[] | contains("bin")) | .name') | jq -r '.packages[].targets[] | select(.kind[] | contains("bin")) | .name')
echo "bin_names<<EOF" >> $GITHUB_OUTPUT echo "bin_names<<EOF" >> $GITHUB_OUTPUT
echo "$BIN_NAMES" >> $GITHUB_OUTPUT echo "$BIN_NAMES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT
- name: Build all release binaries - name: Package ZIP archive
run: cargo build --release --locked
- name: Package all binaries into one archive
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
COMMIT_SHA="${{ github.sha }}" COMMIT_SHA="${{ github.sha }}"
ARCHIVE_NAME="pwsp-${COMMIT_SHA}-linux-x64.zip" ARCHIVE_NAME="pwsp-${COMMIT_SHA}-linux-${{ matrix.arch }}.zip"
echo "Creating archive: $ARCHIVE_NAME" echo "Creating archive: $ARCHIVE_NAME"
FILES=() FILES=()
while IFS= read -r BIN; do while IFS= read -r BIN; do
[ -z "$BIN" ] && continue [ -z "$BIN" ] && continue
FILES+=("target/release/$BIN") FILES+=("target/debug/$BIN")
done <<< "${{ steps.cargo-meta.outputs.bin_names }}" done <<< "${{ steps.cargo-meta.outputs.bin_names }}"
if [ "${#FILES[@]}" -eq 0 ]; then if [ "${#FILES[@]}" -eq 0 ]; then
@@ -69,53 +87,171 @@ jobs:
exit 1 exit 1
fi 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[@]}" zip -j "$ARCHIVE_NAME" "${FILES[@]}"
- name: Upload archive as artifact - name: Upload ZIP artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: archive name: archive-${{ matrix.arch }}
path: pwsp-*.zip path: pwsp-*.zip
retention-days: 7 retention-days: 7
- name: Install cargo-deb and create .deb - name: Cache cargo-deb
id: cache-cargo-deb
uses: actions/cache@v6
with:
path: ~/.cargo/bin/cargo-deb
key: ${{ runner.os }}-${{ runner.arch }}-cargo-deb-v1
- name: Install cargo-deb
if: steps.cache-cargo-deb.outputs.cache-hit != 'true'
run: cargo install --locked cargo-deb
- name: Create .deb package
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
cargo install --locked cargo-deb
export PATH="$HOME/.cargo/bin:$PATH" export PATH="$HOME/.cargo/bin:$PATH"
if [ "${{ github.event_name }}" == "push" ] && [ "${{ github.ref }}" == "refs/heads/main" ]; then
BASE_VERSION=$(grep '^version =' Cargo.toml | head -1 | cut -d'"' -f2)
GIT_REV=$(git rev-parse --short HEAD)
DATE=$(date +%Y%m%d)
DEB_VER="${BASE_VERSION}+git${DATE}.${GIT_REV}"
echo "Building nightly deb version: $DEB_VER"
cargo-deb -p pwsp-gui --no-build --no-strip --profile dev --deb-version "$DEB_VER"
else
cargo-deb -p pwsp-gui --no-build --no-strip --profile dev
fi
cargo-deb - name: Upload .deb artifact
uses: actions/upload-artifact@v7
- name: Upload .deb(s) as artifacts
uses: actions/upload-artifact@v4
with: with:
name: deb-packages name: deb-packages-${{ matrix.arch }}
path: target/debian/*.deb path: target/debian/*.deb
retention-days: 7 retention-days: 7
flatpak-build: deploy-nightly-apt:
needs: build-and-package
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest runs-on: ubuntu-latest
concurrency: gh-pages-deploy
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Setup GPG
id: gpg
uses: crazy-max/ghaction-import-gpg@v7
with:
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.GPG_PASSPHRASE }}
- name: Download DEB artifacts
uses: actions/download-artifact@v8
with:
pattern: deb-packages-*
path: debs
merge-multiple: true
- name: Checkout existing gh-pages
uses: actions/checkout@v7
with:
ref: gh-pages
path: gh-pages-branch
continue-on-error: true
- name: Generate Nightly APT Repository
run: |
sudo apt-get update
sudo apt-get install -y reprepro
mkdir -p apt-repo
if [ -d "gh-pages-branch/apt" ]; then
cp -r gh-pages-branch/apt/* apt-repo/ || true
fi
mkdir -p apt-repo/conf
cat <<EOF > apt-repo/conf/distributions
Origin: arabianq
Label: PipeWire Soundpad
Codename: stable
Architectures: amd64 arm64
Components: main
Description: APT Repository for PWSP
SignWith: ${{ steps.gpg.outputs.fingerprint }}
Origin: arabianq
Label: PipeWire Soundpad
Codename: nightly
Architectures: amd64 arm64
Components: main
Description: Nightly APT Repository for PWSP
SignWith: ${{ steps.gpg.outputs.fingerprint }}
EOF
gpg --armor --export ${{ steps.gpg.outputs.fingerprint }} > apt-repo/pubkey.gpg
cd apt-repo
for deb in ../debs/*.deb; do
if [ -f "$deb" ]; then
echo "Adding $deb to nightly APT repository..."
reprepro includedeb nightly "$deb"
fi
done
- name: Deploy APT Repo to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./apt-repo
destination_dir: apt
keep_files: true
flatpak-build:
if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'
strategy:
matrix:
include:
- arch: x86_64
runner: ubuntu-latest
- arch: aarch64
runner: ubuntu-24.04-arm
fail-fast: false
runs-on: ${{ matrix.runner }}
container: container:
image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-25.08 image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-25.08
options: --privileged options: --privileged
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- name: Build Flatpak - name: Build Flatpak
uses: flatpak/flatpak-github-actions/flatpak-builder@v6 uses: flatpak/flatpak-github-actions/flatpak-builder@v6
with: with:
bundle: ru.arabianq.pwsp.flatpak bundle: ru.arabianq.pwsp_${{ matrix.arch }}.flatpak
manifest-path: packages/flatpak/ru.arabianq.pwsp.yaml manifest-path: packages/flatpak/ru.arabianq.pwsp.yaml
cache: true cache: true
branch: master branch: master
build-bundle: true build-bundle: true
arch: ${{ matrix.arch }}
trigger-copr-nightly:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Install copr-cli
run: pip install copr-cli
- name: Trigger Copr Build
env:
COPR_CONFIG: ${{ secrets.COPR_CONFIG }}
run: |
mkdir -p ~/.config
echo "$COPR_CONFIG" > ~/.config/copr
copr-cli buildscm --nowait \
--clone-url https://github.com/arabianq/pipewire-soundpad.git \
--commit ${{ github.sha }} \
--spec packages/rpm/pwsp-git.spec \
arabianq/pwsp
+36
View File
@@ -0,0 +1,36 @@
name: Dependabot Flatpak Cargo Sources
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- "Cargo.lock"
jobs:
update-cargo-sources:
if: github.actor == 'dependabot[bot]'
runs-on: ubuntu-latest
permissions:
contents: write # Needed to push back to the branch
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
ref: ${{ github.head_ref }}
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: "3.x"
- name: Install dependencies
run: pip install aiohttp toml
- name: Generate cargo-sources.json
run: bash ./scripts/generate-sources.sh
- name: Commit and push changes
uses: stefanzweifel/git-auto-commit-action@v7
with:
commit_message: "chore(flatpak): automatically update cargo-sources.json"
file_pattern: "packages/flatpak/cargo-sources.json"
+118 -20
View File
@@ -21,9 +21,17 @@ on:
default: "stable" default: "stable"
jobs: jobs:
flatter: build-flatpak:
name: Flatter name: Build Flatpak (${{ matrix.arch }})
runs-on: ubuntu-latest strategy:
matrix:
include:
- arch: x86_64
runner: ubuntu-latest
- arch: aarch64
runner: ubuntu-24.04-arm
fail-fast: false
runs-on: ${{ matrix.runner }}
permissions: permissions:
contents: read contents: read
container: container:
@@ -32,13 +40,13 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v7
with: with:
ref: ${{ inputs.tag_name || github.ref }} ref: ${{ inputs.tag_name || github.ref }}
- name: Setup GPG - name: Setup GPG
id: gpg id: gpg
uses: crazy-max/ghaction-import-gpg@v6 uses: crazy-max/ghaction-import-gpg@v7
with: with:
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.GPG_PASSPHRASE }} passphrase: ${{ secrets.GPG_PASSPHRASE }}
@@ -56,33 +64,123 @@ jobs:
- name: Modify Manifest - name: Modify Manifest
run: | run: |
sed -i '/^branch:/d' packages/flatpak/ru.arabianq.pwsp.yaml
sed -i '/^default-branch:/d' packages/flatpak/ru.arabianq.pwsp.yaml
echo "branch: ${{ steps.set_branch.outputs.branch }}" >> packages/flatpak/ru.arabianq.pwsp.yaml echo "branch: ${{ steps.set_branch.outputs.branch }}" >> packages/flatpak/ru.arabianq.pwsp.yaml
echo "default-branch: ${{ steps.set_branch.outputs.branch }}" >> packages/flatpak/ru.arabianq.pwsp.yaml echo "default-branch: ${{ steps.set_branch.outputs.branch }}" >> packages/flatpak/ru.arabianq.pwsp.yaml
- name: Install SDK Extensions - name: Install SDK Extensions
run: flatpak install -y flathub org.freedesktop.Sdk.Extension.rust-stable//25.08 run: |
org.freedesktop.Sdk.Extension.llvm20//25.08 flatpak install -y flathub org.freedesktop.Sdk.Extension.rust-stable//25.08
flatpak install -y flathub org.freedesktop.Sdk.Extension.llvm20//25.08
- name: Build Flatpak - name: Build Flatpak
uses: andyholmes/flatter@main uses: andyholmes/flatter@main
with: with:
files: packages/flatpak/ru.arabianq.pwsp.yaml files: packages/flatpak/ru.arabianq.pwsp.yaml
gpg-sign: ${{ steps.gpg.outputs.fingerprint }} gpg-sign: ${{ steps.gpg.outputs.fingerprint }}
upload-bundles: false upload-bundles: true
upload-pages-artifact: true upload-pages-artifact: false
arch: ${{ matrix.arch }}
cache: true
deploy: prepare-repo:
name: Deploy to GitHub Pages name: Prepare OSTree Repo
needs: build-flatpak
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: flatter concurrency: gh-pages-deploy
container:
image: ghcr.io/andyholmes/flatter/freedesktop:25.08
options: --privileged
permissions: permissions:
pages: write contents: write # Needed to upload files to GitHub Release
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps: steps:
- name: GitHub Pages - name: Download Flatpak Bundles
id: deployment uses: actions/download-artifact@v8
uses: actions/deploy-pages@v4 with:
path: bundles
pattern: ru.arabianq.pwsp-*
- name: Checkout existing gh-pages
uses: actions/checkout@v7
with:
ref: gh-pages
path: gh-pages-branch
continue-on-error: true
- name: Setup GPG
id: gpg
uses: crazy-max/ghaction-import-gpg@v7
with:
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.GPG_PASSPHRASE }}
- name: Update OSTree Repo and Prepare Release Bundles
run: |
mkdir -p release-bundles
# Inherit existing OSTree repository to preserve previous branches (e.g. stable vs nightly)
# Only inherit if objects/ exists, otherwise it's a corrupted/empty repo due to Git ignoring empty folders
if [ -d "gh-pages-branch/objects" ]; then
echo "Found existing OSTree repository. Copying..."
cp -r gh-pages-branch pages-repo
# Clean up the .git folder so it doesn't interfere
rm -rf pages-repo/.git
# Recreate empty OSTree directories that Git ignored when pushing previously
mkdir -p pages-repo/refs/heads pages-repo/refs/mirrors pages-repo/refs/remotes \
pages-repo/objects pages-repo/state pages-repo/extensions pages-repo/tmp
else
echo "Initializing new OSTree repository..."
mkdir -p pages-repo
ostree init --mode=archive-z2 --repo=pages-repo
fi
shopt -s nullglob
for dir in bundles/ru.arabianq.pwsp-*; do
arch=$(basename "$dir" | sed 's/ru.arabianq.pwsp-//')
# Find the flatpak file inside the dir
bundle=$(find "$dir" -name "*.flatpak" | head -n 1)
if [ -n "$bundle" ]; then
echo "Importing $bundle for architecture $arch..."
flatpak build-import-bundle pages-repo "$bundle"
# Copy with a unique name for GitHub Release
cp "$bundle" "release-bundles/ru.arabianq.pwsp-${arch}.flatpak"
fi
done
# Update summary and sign the repository
flatpak build-update-repo --generate-static-deltas --gpg-sign=${{ steps.gpg.outputs.fingerprint }} pages-repo
# Export GPG public key for manual import
gpg --export ${{ steps.gpg.outputs.fingerprint }} > pages-repo/pwsp.gpg
# Generate a .flatpakrepo file for easy installation
GPG_BASE64=$(gpg --export ${{ steps.gpg.outputs.fingerprint }} | base64 -w 0)
cat <<EOF | sed 's/^ *//' > pages-repo/pwsp.flatpakrepo
[Flatpak Repo]
Title=PipeWire Soundpad
Url=https://arabianq.github.io/pipewire-soundpad/
Homepage=https://github.com/arabianq/pipewire-soundpad
Comment=PipeWire Soundpad Flatpak Repository
Description=Official repository for PWSP
GPGKey=${GPG_BASE64}
EOF
- name: Deploy Flatpak Repo to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./pages-repo
destination_dir: .
keep_files: true
- name: Upload bundles to GitHub Release
if: github.event_name == 'release'
uses: softprops/action-gh-release@v3
with:
files: release-bundles/*.flatpak
tag_name: ${{ github.event.release.tag_name }}
+168 -74
View File
@@ -23,51 +23,45 @@ jobs:
id: tag id: tag
run: | run: |
set -euo pipefail set -euo pipefail
INPUT_TAG="${{ github.event.inputs.tag || '' }}" 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 || '' }}" EVENT_TAG="${{ github.event.release.tag_name || '' }}"
if [ -n "$EVENT_TAG" ]; then
echo "Using event tag: $EVENT_TAG" if [ -n "$INPUT_TAG" ]; then
echo "tag=$INPUT_TAG" >> $GITHUB_OUTPUT
elif [ -n "$EVENT_TAG" ]; then
echo "tag=$EVENT_TAG" >> $GITHUB_OUTPUT echo "tag=$EVENT_TAG" >> $GITHUB_OUTPUT
exit 0 elif [[ "${GITHUB_REF:-}" =~ ^refs/tags/(.+)$ ]]; then
fi
if [[ "${GITHUB_REF:-}" =~ ^refs/tags/(.+)$ ]]; then
echo "Using GITHUB_REF tag: ${BASH_REMATCH[1]}"
echo "tag=${BASH_REMATCH[1]}" >> $GITHUB_OUTPUT echo "tag=${BASH_REMATCH[1]}" >> $GITHUB_OUTPUT
exit 0 else
fi
echo "No tag in input/event/GITHUB_REF — querying latest release via API..."
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) LATEST_JSON=$(curl -sSf -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" -H "Accept: application/vnd.github+json" "https://api.github.com/repos/${{ github.repository }}/releases/latest" || true)
TAG_NAME=$(echo "$LATEST_JSON" | jq -r '.tag_name // empty') TAG_NAME=$(echo "$LATEST_JSON" | jq -r '.tag_name // empty')
if [ -n "$TAG_NAME" ]; then if [ -n "$TAG_NAME" ]; then
echo "Found latest release tag: $TAG_NAME"
echo "tag=$TAG_NAME" >> $GITHUB_OUTPUT echo "tag=$TAG_NAME" >> $GITHUB_OUTPUT
exit 0 else
fi
echo "No tag found"
echo "tag=" >> $GITHUB_OUTPUT echo "tag=" >> $GITHUB_OUTPUT
fi
fi
- name: Fail if no tag determined - name: Fail if no tag determined
if: ${{ steps.tag.outputs.tag == '' }} if: ${{ steps.tag.outputs.tag == '' }}
run: | run: |
echo "ERROR: No tag determined. Provide a tag when running manually or ensure a release exists." echo "ERROR: No tag determined."
exit 1 exit 1
linux-release: build-release:
needs: prepare needs: prepare
runs-on: ubuntu-latest strategy:
matrix:
include:
- arch: x64
runner: ubuntu-latest
- arch: arm64
runner: ubuntu-24.04-arm
fail-fast: false
runs-on: ${{ matrix.runner }}
steps: steps:
- name: Install apt deps (jq/zip + dev-libs) - name: Install apt deps
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y \ sudo apt-get install -y \
@@ -76,25 +70,32 @@ jobs:
libclang-dev \ libclang-dev \
libasound2-dev \ libasound2-dev \
libdbus-1-dev \ libdbus-1-dev \
libssl-dev \
pkg-config pkg-config
- name: Checkout code at tag - name: Checkout code at tag
uses: actions/checkout@v4 uses: actions/checkout@v7
with: with:
ref: ${{ needs.prepare.outputs.tag }} ref: ${{ needs.prepare.outputs.tag }}
fetch-depth: 0 fetch-depth: 0
- name: Setup Rust toolchain - name: Setup Rust toolchain
uses: actions-rs/toolchain@v1 uses: actions-rust-lang/setup-rust-toolchain@v1
with: with:
toolchain: 1.94.1 toolchain: stable
cache: false
- name: Rust Cache
uses: swatinem/rust-cache@v2
with:
prefix-key: "v3-${{ matrix.runner }}"
- name: Extract all binary names - name: Extract all binary names
id: cargo-meta id: cargo-meta
run: | run: |
set -euo pipefail set -euo pipefail
BIN_NAMES=$(cargo metadata --no-deps --format-version 1 \ BIN_NAMES=$(cargo metadata --no-deps --format-version 1 \
| jq -r '.packages[0].targets[] | select(.kind[] | contains("bin")) | .name') | jq -r '.packages[].targets[] | select(.kind[] | contains("bin")) | .name')
echo "bin_names<<EOF" >> $GITHUB_OUTPUT echo "bin_names<<EOF" >> $GITHUB_OUTPUT
echo "$BIN_NAMES" >> $GITHUB_OUTPUT echo "$BIN_NAMES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT
@@ -102,13 +103,12 @@ jobs:
- name: Build all release binaries - name: Build all release binaries
run: cargo build --release --locked run: cargo build --release --locked
- name: Package all binaries into one archive - name: Package ZIP archive
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
TAG="${{ needs.prepare.outputs.tag }}" TAG="${{ needs.prepare.outputs.tag }}"
ARCHIVE_NAME="pwsp-${TAG}-linux-x64.zip" ARCHIVE_NAME="pwsp-${TAG}-linux-${{ matrix.arch }}.zip"
echo "Creating archive: $ARCHIVE_NAME"
FILES=() FILES=()
while IFS= read -r BIN; do while IFS= read -r BIN; do
@@ -116,63 +116,157 @@ jobs:
FILES+=("target/release/$BIN") FILES+=("target/release/$BIN")
done <<< "${{ steps.cargo-meta.outputs.bin_names }}" 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[@]}" zip -j "$ARCHIVE_NAME" "${FILES[@]}"
- name: Upload release archive - name: Upload ZIP artifact
uses: softprops/action-gh-release@v2 uses: actions/upload-artifact@v7
with: with:
token: ${{ secrets.GITHUB_TOKEN }} name: zip-archive-${{ matrix.arch }}
tag_name: ${{ needs.prepare.outputs.tag }} path: pwsp-*.zip
files: | retention-days: 1
pwsp-*.zip
- name: Install cargo-deb and create .deb - name: Cache cargo-deb
id: cache-cargo-deb
uses: actions/cache@v6
with:
path: ~/.cargo/bin/cargo-deb
key: ${{ runner.os }}-${{ runner.arch }}-cargo-deb-v1
- name: Install cargo-deb
if: steps.cache-cargo-deb.outputs.cache-hit != 'true'
run: cargo install --locked cargo-deb
- name: Create .deb package
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
cargo install --locked cargo-deb
export PATH="$HOME/.cargo/bin:$PATH" export PATH="$HOME/.cargo/bin:$PATH"
cargo-deb -p pwsp-gui --no-build
cargo-deb - name: Upload DEB artifact
uses: actions/upload-artifact@v7
with:
name: deb-package-${{ matrix.arch }}
path: target/debian/*.deb
retention-days: 1
- name: Upload .deb(s) to release publish-release:
uses: softprops/action-gh-release@v2 needs: [prepare, build-release]
runs-on: ubuntu-latest
steps:
- name: Download all artifacts
uses: actions/download-artifact@v8
with:
path: ./dist
merge-multiple: true
- name: Upload artifacts to Release
uses: softprops/action-gh-release@v3
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
tag_name: ${{ needs.prepare.outputs.tag }} tag_name: ${{ needs.prepare.outputs.tag }}
files: | files: |
target/debian/*.deb ./dist/pwsp-*.zip
./dist/*.deb
flatpak-release: trigger-copr:
needs: prepare needs: prepare
runs-on: ubuntu-latest runs-on: ubuntu-latest
container:
image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-25.08
options: --privileged
steps: steps:
- uses: actions/checkout@v4 - name: Install copr-cli
with: run: pip install copr-cli
ref: ${{ needs.prepare.outputs.tag }}
- name: Build Flatpak - name: Trigger Copr Build
uses: flatpak/flatpak-github-actions/flatpak-builder@v6 env:
COPR_CONFIG: ${{ secrets.COPR_CONFIG }}
run: |
mkdir -p ~/.config
echo "$COPR_CONFIG" > ~/.config/copr
copr-cli buildscm --nowait \
--clone-url https://github.com/arabianq/pipewire-soundpad.git \
--commit ${{ needs.prepare.outputs.tag }} \
--spec packages/rpm/pwsp.spec \
arabianq/pwsp
build-apt-repo:
needs: [prepare, build-release]
runs-on: ubuntu-latest
concurrency: gh-pages-deploy
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Setup GPG
id: gpg
uses: crazy-max/ghaction-import-gpg@v7
with: with:
bundle: ru.arabianq.pwsp.flatpak gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
manifest-path: packages/flatpak/ru.arabianq.pwsp.yaml passphrase: ${{ secrets.GPG_PASSPHRASE }}
cache: true
branch: master - name: Download DEB artifacts
build-bundle: true uses: actions/download-artifact@v8
with:
pattern: deb-package-*
path: debs
merge-multiple: true
- name: Checkout existing gh-pages
uses: actions/checkout@v7
with:
ref: gh-pages
path: gh-pages-branch
continue-on-error: true
- name: Generate APT Repository
run: |
sudo apt-get update
sudo apt-get install -y reprepro
mkdir -p apt-repo
# Restore existing repository first so we don't lose old packages
if [ -d "gh-pages-branch/apt" ]; then
cp -r gh-pages-branch/apt/* apt-repo/ || true
fi
# Always rewrite the config and public key to ensure they are up-to-date
mkdir -p apt-repo/conf
cat <<EOF > apt-repo/conf/distributions
Origin: arabianq
Label: PipeWire Soundpad
Codename: stable
Architectures: amd64 arm64
Components: main
Description: APT Repository for PWSP
SignWith: ${{ steps.gpg.outputs.fingerprint }}
Origin: arabianq
Label: PipeWire Soundpad
Codename: nightly
Architectures: amd64 arm64
Components: main
Description: Nightly APT Repository for PWSP
SignWith: ${{ steps.gpg.outputs.fingerprint }}
EOF
gpg --armor --export ${{ steps.gpg.outputs.fingerprint }} > apt-repo/pubkey.gpg
cd apt-repo
for deb in ../debs/*.deb; do
if [ -f "$deb" ]; then
echo "Adding $deb to APT repository..."
reprepro includedeb stable "$deb"
fi
done
- name: Deploy APT Repo to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./apt-repo
destination_dir: apt
keep_files: true
Generated
+947 -752
View File
File diff suppressed because it is too large Load Diff
+44 -64
View File
@@ -1,18 +1,29 @@
[package] [workspace]
name = "pwsp" members = [
version = "1.8.1" "pwsp-lib",
"pwsp-daemon",
"pwsp-cli",
"pwsp-gui"
]
resolver = "2"
[workspace.package]
version = "1.12.4"
edition = "2024" edition = "2024"
authors = ["arabian"] authors = ["arabian"]
description = "PWSP lets you play audio files through your microphone. Has both CLI and GUI clients."
readme = "README.md"
homepage = "https://pwsp.arabianq.ru" homepage = "https://pwsp.arabianq.ru"
repository = "https://github.com/arabianq/pipewire-soundpad" repository = "https://github.com/arabianq/pipewire-soundpad"
license = "MIT" license = "MIT"
description = "PWSP lets you play audio files through your microphone. Has both CLI and GUI clients."
keywords = ["soundpad", "pipewire", "linux", "cli", "gui"] keywords = ["soundpad", "pipewire", "linux", "cli", "gui"]
[workspace.dependencies]
pwsp-lib = { path = "pwsp-lib" }
pwsp-daemon = { path = "pwsp-daemon" }
pwsp-cli = { path = "pwsp-cli" }
pwsp-gui = { path = "pwsp-gui" }
[dependencies] tokio = { version = "1.52.3", features = ["full"] }
tokio = { version = "1.52.1", features = ["full"] }
async-trait = "0.1.89" async-trait = "0.1.89"
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
@@ -26,47 +37,50 @@ clap = { version = "4.6.1", default-features = false, features = [
"error-context", "error-context",
"derive", "derive",
] } ] }
dirs = "6.0.0"
itertools = "0.14.0"
rodio = { git = "https://github.com/arabianq/rodio.git", rev = "1a08f281c352622bd82b87b8731585245802d9cf", default-features = false, features = [ dirs = "6.0.0"
"symphonia-all", itertools = "0.15.0"
"symphonia-libopus",
"playback",
] }
pipewire = "0.9.2"
evdev = { version = "0.13.2", features = ["tokio"] } evdev = { version = "0.13.2", features = ["tokio"] }
rfd = { version = "0.17.2", default-features = false, features = [ rfd = { version = "0.17.2", default-features = false, features = [
"xdg-portal", "xdg-portal",
] } ] }
opener = { version = "0.8.4", features = ["reveal"] } opener = { version = "0.8.4", features = ["reveal"] }
system-fonts = "0.1.0" system-fonts = "0.1.1"
anyhow = "1.0.102"
rustix = { version = "1.1.4", features = ["process"] }
egui = { version = "0.34.2", default-features = false, features = [ rust-i18n = "4.0.0"
sys-locale = "0.3.2"
rodio = { git = "https://github.com/arabianq/rodio.git", rev = "4746803c2e5f67004264ac946708d47a9faf510c", default-features = false, features = [
"symphonia-all",
"symphonia-libopus",
"playback",
] }
pipewire = "0.10.0"
egui = { version = "0.35.0", default-features = false, features = [
"default_fonts", "default_fonts",
"rayon", "rayon",
] } ] }
eframe = { version = "0.34.2", default-features = false, features = [ eframe = { version = "0.35.0", default-features = false, features = [
"default_fonts", "default_fonts",
"glow", "glow",
"x11", "x11",
"wayland", "wayland",
] } ] }
egui_extras = "0.34.1" egui_extras = "0.35.0"
egui_material_icons = "0.6.0" egui_material_icons = "0.7.0"
egui_dnd = "0.15.0" egui_dnd = "0.16.0"
[[bin]] reqwest = { version = "0.13.4", default-features = false, features = ["native-tls"] }
name = "pwsp-daemon" percent-encoding = "2.3.2"
path = "src/bin/daemon.rs"
[[bin]] [profile.dev]
name = "pwsp-cli"
path = "src/bin/cli.rs"
[[bin]] [profile.dev.package."*"]
name = "pwsp-gui" opt-level = 3
path = "src/main.rs" debug = false
[profile.release] [profile.release]
strip = true strip = true
@@ -74,37 +88,3 @@ lto = true
codegen-units = 1 codegen-units = 1
opt-level = "z" opt-level = "z"
panic = "abort" 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",
],
]
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2025 Tarasov Alexander Copyright (c) 2026 arabianq
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+103 -74
View File
@@ -1,120 +1,149 @@
<div align="center"> # PipeWire Soundpad (PWSP) 🎵
<h1>🎵 PipeWire Soundpad (PWSP)</h1>
<p><b>A simple, modern, and powerful soundboard for Linux, written in Rust.</b></p>
<img src="assets/screenshot.png" alt="PWSP Screenshot" width="700"/>
</div>
## 🌟 Overview ![PWSP Screenshot](pwsp-gui/assets/screenshot.png)
**PipeWire Soundpad (PWSP)** is a graphical soundboard application that routes audio directly to your virtual microphone using **PipeWire**. It provides an intuitive interface for managing your audio collection, making it an ideal tool for gamers, streamers, and anyone looking to inject sound effects into voice chats on platforms like Discord, Zoom, or TeamSpeak.
## ✨ Key Features [🇷🇺 Читать на русском](README.ru.md) | [🇺🇸 Read in English](README.md)
* **🎙️ Virtual Microphone Output:** Seamlessly mixes your microphone input with sound effects by automatically managing PipeWire virtual devices.
* **🎵 Multi-Format Support:** Plays popular audio formats including `mp3`, `wav`, `ogg`, `flac`, `mp4`, and `aac`.
* **⚡ Global Hotkeys:** Trigger sounds instantly from anywhere, even when the app is running in the background.
* **📂 Smart Collection Management:** Drag-and-drop folders, quick search, and collapsible tracks to keep your library organized.
* **🎛️ Advanced Playback Controls:** Individual volume sliders, play/pause, position scrubbing, and concurrent multi-track playback.
* **🔌 Plug & Play:** Automatically detects when an input device is connected or disconnected and handles linking/unlinking on the fly.
* **🖥️ Modern GUI:** Clean, responsive, and lightweight interface powered by [egui](https://egui.rs/).
## ⚙️ Architecture [![GitHub Actions Build Status](https://img.shields.io/github/actions/workflow/status/arabianq/pipewire-soundpad/build.yml?branch=main&style=flat-square)](https://github.com/arabianq/pipewire-soundpad/actions)
PWSP is built with a client-server model to ensure stability and separation of concerns: [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT)
* **`pwsp-daemon`**: The background engine. It runs silently, managing PipeWire virtual devices, audio routing, and playback. [![GitHub Release](https://img.shields.io/github/v/release/arabianq/pipewire-soundpad?style=flat-square)](https://github.com/arabianq/pipewire-soundpad/releases/latest)
* **`pwsp-gui`**: The graphical interface. Communicates with the daemon via a Unix socket to control playback and settings. [![Platform](https://img.shields.io/badge/Platform-Linux%20%7C%20PipeWire-blue?style=flat-square)](https://pipewire.org/)
* **`pwsp-cli`**: The command-line tool. Perfect for scripting, hotkey binding, or quick terminal-based control.
**PipeWire Soundpad (PWSP)** is a modern, low-latency application that lets you play audio files directly through your microphone. Designed specifically for Linux, it leverages the power of PipeWire to achieve native integration without the need for Pulseaudio bridges or complex virtual sinks.
---
## ✨ Features
- **Native PipeWire Integration:** Direct communication with the PipeWire API for the lowest possible latency.
- **Modular Architecture:** Consists of a background `daemon`, a command-line interface (`cli`), and a graphical user interface (`gui`).
- **Modern GUI:** Built with `egui`, supporting both Wayland and X11 seamlessly.
- **Global Hotkeys:** Powered by `evdev`, allowing you to play sounds from anywhere.
- **Broad Audio Support:** Powered by `rodio` and `symphonia` to support a wide range of audio formats.
--- ---
## 🚀 Installation ## 🚀 Installation
We provide multiple ways to install PWSP, including stable releases and rolling "nightly" builds directly from the `main` branch.
### 📦 Flatpak (Recommended) ### 📦 Flatpak (Recommended)
Install PWSP via Flatpak from our custom repository:
Add our official OSTree repository and install the application:
```bash ```bash
flatpak remote-add --user --if-not-exists pwsp-repo https://arabianq.github.io/pipewire-soundpad/index.flatpakrepo # Add the repository
flatpak remote-add --if-not-exists pwsp https://arabianq.github.io/pipewire-soundpad/pwsp.flatpakrepo
# Install stable version # Install the Stable version
flatpak install --user arabianq-repo ru.arabianq.pwsp//stable flatpak install pwsp ru.arabianq.pwsp//stable
# Or install the nightly version (latest commit) # OR Install the Nightly version (rolling updates)
flatpak install --user arabianq-repo ru.arabianq.pwsp//nightly flatpak install pwsp ru.arabianq.pwsp//nightly
``` ```
### 🐧 Linux Packages ### 🟠 Debian / Ubuntu (APT Repository)
**Fedora (and derivatives):**
We maintain an official APT repository for seamless updates via `apt`:
```bash
# 1. Download the public GPG key
wget -O- https://arabianq.github.io/pipewire-soundpad/apt/pubkey.gpg | sudo gpg --dearmor -o /etc/apt/keyrings/pwsp.gpg
# 2. Add the repository (Choose STABLE or NIGHTLY)
# For Stable:
echo "deb [signed-by=/etc/apt/keyrings/pwsp.gpg] https://arabianq.github.io/pipewire-soundpad/apt/ stable main" | sudo tee /etc/apt/sources.list.d/pwsp.list
# For Nightly:
# echo "deb [signed-by=/etc/apt/keyrings/pwsp.gpg] https://arabianq.github.io/pipewire-soundpad/apt/ nightly main" | sudo tee /etc/apt/sources.list.d/pwsp.list
# 3. Update and install
sudo apt update
sudo apt install pwsp
```
### 🐧 Fedora / RHEL (COPR)
Available via the Fedora COPR repository:
```bash ```bash
sudo dnf copr enable arabianq/pwsp sudo dnf copr enable arabianq/pwsp
sudo dnf install pwsp sudo dnf install pwsp
``` ```
**Arch Linux (AUR):** ### ⚙️ Manual / Standalone
You can manually download `.deb` packages or standalone `.zip` binaries from the [Releases page](https://github.com/arabianq/pipewire-soundpad/releases).
### 🦀 Build from Source
Make sure you have Rust, Cargo, and the required dependencies (`libpipewire-0.3-dev`, `libclang-dev`, `libasound2-dev`, `libdbus-1-dev`) installed.
```bash ```bash
paru -S pwsp-bin # or 'pwsp' to build from source
```
**Debian / Ubuntu:**
Download pre-built `.deb` packages or standalone binaries from the [Releases page](https://github.com/arabianq/pipewire-soundpad/releases).
### 🦀 Cargo / Source Build
```bash
cargo install pwsp
# OR clone and build manually:
git clone https://github.com/arabianq/pipewire-soundpad.git git clone https://github.com/arabianq/pipewire-soundpad.git
cd pipewire-soundpad cd pipewire-soundpad
cargo build --release cargo build --release --locked
``` ```
*(Note: Requires Rust toolchain and PipeWire running on your system).*
The binaries will be located in `target/release/`.
--- ---
## 🎮 Usage ## 🎮 Usage
### 1. Start the Daemon ### 1. Start the Daemon
Before using the GUI or CLI, the daemon must be running in the background.
PWSP operates via a background daemon that handles the audio routing.
```bash ```bash
# Recommended: Start and enable via systemd (starts on login) # Run the daemon
systemctl --user enable --now pwsp-daemon pwsp-daemon
# Manual start (if not using systemd):
pwsp-daemon &
``` ```
### 2. Using the GUI _(Tip: If installed via package managers, a systemd user service is provided. You can enable it with `systemctl --user enable --now pwsp-daemon.service`)_
1. **Add Sounds:** Click the **"+"** button to add a directory containing your audio files.
2. **Select Mic:** Choose your physical microphone from the dropdown. PWSP will instantly create a virtual microphone combining your voice and the soundboard. ### 2. Launch the GUI
3. **Play:** Click any sound to play it, adjust its volume, or assign a hotkey for quick access.
Simply run the graphical interface to manage and play your sounds:
### 3. Using the CLI
Control the daemon directly from your terminal:
```bash ```bash
pwsp-cli action play /path/to/sound.mp3 pwsp-gui
pwsp-cli get volume ```
pwsp-cli set position 20
pwsp-cli --help # View all commands ### 3. Use the CLI
You can also interact with the daemon directly via the command line:
```bash
pwsp-cli play /path/to/sound.mp3
pwsp-cli stop
pwsp-cli status
``` ```
--- ---
## ⌨️ Shortcuts & Controls ## 📚 Documentation & DeepWiki
| Action | Keyboard | Mouse | For advanced configuration, troubleshooting, architecture details, and custom setups, please visit our official Wiki:
| :----------------------------------- | :--------------------- | :------------------- |
| **Play Track** (Stops others) | `Enter` | `Left Click` | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/arabianq/pipewire-soundpad)
| **Add Track** (Plays simultaneously) | `Ctrl + Enter` | `Ctrl + Left Click` |
| **Replace Last Track** | `Shift + Enter` | `Shift + Left Click` |
| **Pause / Resume** | `Space` | |
| **Stop All Tracks** | `Backspace` | |
| **Open / Close Settings** | `I` | |
| **Search** | `/` | |
| **Navigate Files** | `Ctrl + ↑ / ↓` | |
| **Navigate Directories** | `Ctrl + Shift + ↑ / ↓` | |
--- ---
## 🤝 Contributing ## 🤝 Contributing
Contributions, issues, and feature requests are welcome! Feel free to check out the [issues page](https://github.com/arabianq/pipewire-soundpad/issues).
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/arabianq/pipewire-soundpad) Contributions, issues, and feature requests are welcome!
## 📜 License 1. Fork the project.
This project is licensed under the [MIT License](LICENSE). 2. Create your feature branch (`git checkout -b feat/amazing-feature`).
3. Commit your changes (`git commit -m 'Add some amazing feature'`).
4. Push to the branch (`git push origin feat/amazing-feature`).
5. Open a Pull Request.
---
## 📝 License
Distributed under the MIT License. See `LICENSE` for more information.
_Built with ❤️ for the Linux community._
+149
View File
@@ -0,0 +1,149 @@
# PipeWire Soundpad (PWSP) 🎵
![PWSP Screenshot](pwsp-gui/assets/screenshot.png)
[🇷🇺 Читать на русском](README.ru.md) | [🇺🇸 Read in English](README.md)
[![GitHub Actions Build Status](https://img.shields.io/github/actions/workflow/status/arabianq/pipewire-soundpad/build.yml?branch=main&style=flat-square)](https://github.com/arabianq/pipewire-soundpad/actions)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT)
[![GitHub Release](https://img.shields.io/github/v/release/arabianq/pipewire-soundpad?style=flat-square)](https://github.com/arabianq/pipewire-soundpad/releases/latest)
[![Platform](https://img.shields.io/badge/Platform-Linux%20%7C%20PipeWire-blue?style=flat-square)](https://pipewire.org/)
**PipeWire Soundpad (PWSP)** — это современное приложение с низкой задержкой, которое позволяет воспроизводить аудиофайлы прямо в ваш микрофон. Разработано специально для Linux с использованием мощи PipeWire для достижения нативной интеграции без использования мостов Pulseaudio или сложных виртуальных устройств.
---
## ✨ Возможности
- **Нативная интеграция с PipeWire:** Прямое взаимодействие с PipeWire API для минимальной задержки.
- **Модульная архитектура:** Состоит из фонового демона (`daemon`), интерфейса командной строки (`cli`) и графического интерфейса (`gui`).
- **Современный GUI:** Построен на `egui`, плавно работает как на Wayland, так и на X11.
- **Глобальные горячие клавиши:** Работают через `evdev`, позволяя воспроизводить звуки из любого окна.
- **Широкая поддержка форматов:** Использует `rodio` и `symphonia` для поддержки большинства аудиоформатов.
---
## 🚀 Установка
Мы предоставляем несколько способов установки PWSP, включая стабильные релизы и постоянно обновляемые "nightly" сборки напрямую из ветки `main`.
### 📦 Flatpak (Рекомендуется)
Добавьте наш официальный OSTree репозиторий и установите приложение:
```bash
# Добавьте репозиторий
flatpak remote-add --if-not-exists pwsp https://arabianq.github.io/pipewire-soundpad/pwsp.flatpakrepo
# Установите стабильную (Stable) версию
flatpak install pwsp ru.arabianq.pwsp//stable
# ИЛИ установите Nightly версию (самые свежие обновления)
flatpak install pwsp ru.arabianq.pwsp//nightly
```
### 🟠 Debian / Ubuntu (APT Репозиторий)
Мы поддерживаем официальный APT-репозиторий для бесшовных обновлений через `apt`:
```bash
# 1. Скачайте публичный GPG ключ
wget -O- https://arabianq.github.io/pipewire-soundpad/apt/pubkey.gpg | sudo gpg --dearmor -o /etc/apt/keyrings/pwsp.gpg
# 2. Добавьте репозиторий (Выберите STABLE или NIGHTLY)
# Для Stable:
echo "deb [signed-by=/etc/apt/keyrings/pwsp.gpg] https://arabianq.github.io/pipewire-soundpad/apt/ stable main" | sudo tee /etc/apt/sources.list.d/pwsp.list
# Для Nightly:
# echo "deb [signed-by=/etc/apt/keyrings/pwsp.gpg] https://arabianq.github.io/pipewire-soundpad/apt/ nightly main" | sudo tee /etc/apt/sources.list.d/pwsp.list
# 3. Обновите индексы и установите
sudo apt update
sudo apt install pwsp
```
### 🐧 Fedora / RHEL (COPR)
Доступно через репозиторий Fedora COPR:
```bash
sudo dnf copr enable arabianq/pwsp
sudo dnf install pwsp
```
### ⚙️ Ручная установка
Вы можете вручную скачать пакеты `.deb` или готовые бинарники `.zip` на [странице релизов](https://github.com/arabianq/pipewire-soundpad/releases).
### 🦀 Сборка из исходников
Убедитесь, что у вас установлены Rust, Cargo и необходимые зависимости (`libpipewire-0.3-dev`, `libclang-dev`, `libasound2-dev`, `libdbus-1-dev`).
```bash
git clone https://github.com/arabianq/pipewire-soundpad.git
cd pipewire-soundpad
cargo build --release --locked
```
Собранные бинарники будут находиться в папке `target/release/`.
---
## 🎮 Использование
### 1. Запуск демона
PWSP работает через фоновый демон, который маршрутизирует аудио.
```bash
# Запуск демона
pwsp-daemon
```
_(Подсказка: Если вы установили программу через пакетный менеджер, она включает пользовательский systemd-сервис. Вы можете включить его командой: `systemctl --user enable --now pwsp-daemon.service`)_
### 2. Запуск GUI
Просто запустите графический интерфейс для управления и воспроизведения звуков:
```bash
pwsp-gui
```
### 3. Использование CLI
Вы также можете взаимодействовать с демоном напрямую через командную строку:
```bash
pwsp-cli play /path/to/sound.mp3
pwsp-cli stop
pwsp-cli status
```
---
## 📚 Документация и DeepWiki
Для детальной настройки, решения проблем, описания архитектуры и кастомных конфигураций, пожалуйста, посетите нашу официальную Wiki:
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/arabianq/pipewire-soundpad)
---
## 🤝 Вклад в проект (Contributing)
Будем рады вашей помощи, баг-репортам и идеям!
1. Сделайте Fork проекта.
2. Создайте свою ветку (`git checkout -b feat/amazing-feature`).
3. Закоммитьте изменения (`git commit -m 'Add some amazing feature'`).
4. Запушьте ветку (`git push origin feat/amazing-feature`).
5. Откройте Pull Request.
---
## 📝 Лицензия
Распространяется под лицензией MIT. Подробнее см. в файле `LICENSE`.
_Сделано с ❤️ для сообщества Linux._
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

+7 -4
View File
@@ -1,17 +1,20 @@
pkgbase = pwsp-bin pkgbase = pwsp-bin
pkgdesc = Lets you play audio files through your microphone (Pre-built binaries) pkgdesc = Lets you play audio files through your microphone (Pre-built binaries)
pkgver = 1.8.1 pkgver = 1.12.4
pkgrel = 1 pkgrel = 1
url = https://github.com/arabianq/pipewire-soundpad url = https://github.com/arabianq/pipewire-soundpad
arch = x86_64 arch = x86_64
arch = aarch64
license = MIT license = MIT
depends = pipewire depends = pipewire
depends = alsa-lib depends = alsa-lib
provides = pwsp provides = pwsp
conflicts = pwsp conflicts = pwsp
source = pwsp-bin-1.8.1.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.8.1/pwsp-v1.8.1-linux-x64.zip source = pipewire-soundpad-1.12.4.tar.gz :: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.12.4.tar.gz
source = pipewire-soundpad-1.8.1.tar.gz :: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.8.1.tar.gz
sha256sums = SKIP
sha256sums = SKIP sha256sums = SKIP
source_x86_64 = pwsp-1.12.4-x86_64.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.12.4/pwsp-v1.12.4-linux-x64.zip
sha256sums_x86_64 = SKIP
source_aarch64 = pwsp-1.12.4-aarch64.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.12.4/pwsp-v1.12.4-linux-arm64.zip
sha256sums_aarch64 = SKIP
pkgname = pwsp-bin pkgname = pwsp-bin
+12 -10
View File
@@ -1,21 +1,23 @@
# Maintainer: Alexander Tarasov <a.tevg@ya.ru> # Maintainer: Alexander Tarasov <a.tevg@ya.ru>
pkgname=pwsp-bin pkgname=pwsp-bin
_pkgname=pipewire-soundpad _pkgname=pipewire-soundpad
pkgver=1.8.1 pkgver=1.12.4
pkgrel=1 pkgrel=1
pkgdesc="Lets you play audio files through your microphone (Pre-built binaries)" pkgdesc="Lets you play audio files through your microphone (Pre-built binaries)"
arch=('x86_64') arch=('x86_64' 'aarch64')
url="https://github.com/arabianq/pipewire-soundpad" url="https://github.com/arabianq/pipewire-soundpad"
license=('MIT') license=('MIT')
depends=('pipewire' 'alsa-lib') depends=('pipewire' 'alsa-lib' 'openssl')
provides=('pwsp') provides=('pwsp')
conflicts=('pwsp') conflicts=('pwsp')
source=("${pkgname}-${pkgver}.zip::https://github.com/arabianq/$_pkgname/releases/download/v$pkgver/pwsp-v$pkgver-linux-x64.zip" source_x86_64=("pwsp-${pkgver}-x86_64.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") source_aarch64=("pwsp-${pkgver}-aarch64.zip::https://github.com/arabianq/$_pkgname/releases/download/v$pkgver/pwsp-v$pkgver-linux-arm64.zip")
source=("${_pkgname}-${pkgver}.tar.gz::https://github.com/arabianq/$_pkgname/archive/refs/tags/v$pkgver.tar.gz")
sha256sums=('SKIP' sha256sums=('SKIP')
'SKIP') sha256sums_x86_64=('SKIP')
sha256sums_aarch64=('SKIP')
package() { package() {
_srcsrc="${srcdir}/${_pkgname}-${pkgver}" _srcsrc="${srcdir}/${_pkgname}-${pkgver}"
@@ -24,9 +26,9 @@ package() {
install -Dm755 "${srcdir}/pwsp-daemon" "${pkgdir}/usr/bin/pwsp-daemon" install -Dm755 "${srcdir}/pwsp-daemon" "${pkgdir}/usr/bin/pwsp-daemon"
install -Dm755 "${srcdir}/pwsp-gui" "${pkgdir}/usr/bin/pwsp-gui" 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/pwsp-gui/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/pwsp-gui/assets/icon.png" "${pkgdir}/usr/share/icons/hicolor/256x256/apps/pwsp.png"
install -Dm644 "$_srcsrc/assets/pwsp-daemon.service" "${pkgdir}/usr/lib/systemd/user/pwsp-daemon.service" install -Dm644 "$_srcsrc/pwsp-gui/assets/pwsp-daemon.service" "${pkgdir}/usr/lib/systemd/user/pwsp-daemon.service"
install -Dm644 "$_srcsrc/LICENSE" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" install -Dm644 "$_srcsrc/LICENSE" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
} }
+4 -3
View File
@@ -1,9 +1,10 @@
pkgbase = pwsp pkgbase = pwsp
pkgdesc = Lets you play audio files through your microphone pkgdesc = Lets you play audio files through your microphone
pkgver = 1.8.1 pkgver = 1.12.4
pkgrel = 1 pkgrel = 1
url = https://github.com/arabianq/pipewire-soundpad url = https://github.com/arabianq/pipewire-soundpad
arch = any arch = x86_64
arch = aarch64
license = MIT license = MIT
makedepends = clang makedepends = clang
makedepends = rust makedepends = rust
@@ -11,7 +12,7 @@ pkgbase = pwsp
makedepends = cmake makedepends = cmake
makedepends = pipewire makedepends = pipewire
makedepends = alsa-lib makedepends = alsa-lib
source = https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.8.1.tar.gz source = https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.12.4.tar.gz
sha256sums = SKIP sha256sums = SKIP
pkgname = pwsp pkgname = pwsp
+6 -5
View File
@@ -1,12 +1,13 @@
# Maintainer: Alexander Tarasov <a.tevg@ya.ru> # Maintainer: Alexander Tarasov <a.tevg@ya.ru>
pkgsubn=pwsp pkgsubn=pwsp
pkgname=pwsp pkgname=pwsp
pkgver=1.8.1 pkgver=1.12.4
pkgrel=1 pkgrel=1
pkgdesc="Lets you play audio files through your microphone" pkgdesc="Lets you play audio files through your microphone"
arch=('any') arch=('x86_64' 'aarch64')
url="https://github.com/arabianq/pipewire-soundpad" url="https://github.com/arabianq/pipewire-soundpad"
license=('MIT') license=('MIT')
depends=('pipewire' 'alsa-lib' 'openssl')
makedepends=(clang rust cargo cmake pipewire alsa-lib) makedepends=(clang rust cargo cmake pipewire alsa-lib)
source=("$url/archive/refs/tags/v$pkgver.tar.gz") source=("$url/archive/refs/tags/v$pkgver.tar.gz")
sha256sums=('SKIP') sha256sums=('SKIP')
@@ -40,8 +41,8 @@ package() {
install -Dm755 "target/release/pwsp-daemon" "${pkgdir}/usr/bin/pwsp-daemon" install -Dm755 "target/release/pwsp-daemon" "${pkgdir}/usr/bin/pwsp-daemon"
install -Dm755 "target/release/pwsp-gui" "${pkgdir}/usr/bin/pwsp-gui" 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 "pwsp-gui/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 "pwsp-gui/assets/icon.png" "${pkgdir}/usr/share/icons/hicolor/256x256/apps/pwsp.png"
install -Dm644 "assets/pwsp-daemon.service" "${pkgdir}/usr/lib/systemd/user/pwsp-daemon.service" install -Dm644 "pwsp-gui/assets/pwsp-daemon.service" "${pkgdir}/usr/lib/systemd/user/pwsp-daemon.service"
} }
File diff suppressed because one or more lines are too long
+10 -5
View File
@@ -2,21 +2,26 @@
import argparse import argparse
import subprocess import subprocess
import sys
if __name__ == "__main__": if __name__ == "__main__":
if len(sys.argv) == 2 and sys.argv[1].startswith("soundpad://"):
subprocess.Popen(["pwsp-gui", sys.argv[1]])
sys.exit(0)
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="PWSP Flatpak", prog="PWSP Flatpak", add_help=True, exit_on_error=True
add_help=True,
exit_on_error=True
) )
subparsers = parser.add_subparsers(dest="command") subparsers = parser.add_subparsers(dest="command")
cli_parser = subparsers.add_parser("cli", add_help=False, prefix_chars=" ") cli_parser = subparsers.add_parser("cli", add_help=False, prefix_chars=" ")
cli_parser.add_argument("args", nargs=argparse.REMAINDER, help="Arguments for pwsp-cli") cli_parser.add_argument(
"args", nargs=argparse.REMAINDER, help="Arguments for pwsp-cli"
)
daemon_parser = subparsers.add_parser("daemon", add_help=True) daemon_parser = subparsers.add_parser("daemon", add_help=True)
daemon_group = daemon_parser.add_mutually_exclusive_group(required=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("--start", action="store_true", help="Start pwsp-daemon")
daemon_group.add_argument("--kill", action="store_true", help="Kill pwsp-daemon") daemon_group.add_argument("--kill", action="store_true", help="Kill pwsp-daemon")
args = parser.parse_args() args = parser.parse_args()
@@ -7,3 +7,4 @@ Terminal=false
Type=Application Type=Application
Categories=AudioVideo;Audio; Categories=AudioVideo;Audio;
Keywords=soundpad;pipewire;audio; Keywords=soundpad;pipewire;audio;
MimeType=x-scheme-handler/soundpad;
@@ -15,7 +15,7 @@
<launchable type="desktop-id">ru.arabianq.pwsp.desktop</launchable> <launchable type="desktop-id">ru.arabianq.pwsp.desktop</launchable>
<screenshots> <screenshots>
<screenshot type="default"> <screenshot type="default">
<image>https://raw.githubusercontent.com/arabianq/pipewire-soundpad/master/assets/screenshot.png</image> <image>https://raw.githubusercontent.com/arabianq/pipewire-soundpad/main/pwsp-gui/assets/screenshot.png</image>
</screenshot> </screenshot>
</screenshots> </screenshots>
<url type="homepage">https://pwsp.arabianq.ru</url> <url type="homepage">https://pwsp.arabianq.ru</url>
@@ -25,7 +25,7 @@
<name>arabian</name> <name>arabian</name>
</developer> </developer>
<releases> <releases>
<release version="1.8.1" date="2026-05-15" /> <release version="1.12.4" date="2026-07-07" />
</releases> </releases>
<content_rating type="oars-1.1" /> <content_rating type="oars-1.1" />
</component> </component>
+2 -1
View File
@@ -30,12 +30,13 @@ modules:
- name: pwsp - name: pwsp
buildsystem: simple buildsystem: simple
build-commands: build-commands:
- if [ -w /run/ccache ]; then mkdir -p /run/ccache/cargo-target && rm -rf target && ln -s /run/ccache/cargo-target target; fi
- export CARGO_HOME=$PWD/cargo && cargo build --release --offline - export CARGO_HOME=$PWD/cargo && cargo build --release --offline
- install -Dm755 target/release/pwsp-daemon /app/bin/pwsp-daemon - 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-cli /app/bin/pwsp-cli
- install -Dm755 target/release/pwsp-gui /app/bin/pwsp-gui - install -Dm755 target/release/pwsp-gui /app/bin/pwsp-gui
- install -Dm755 packages/flatpak/pwsp-wrapper.py /app/bin/pwsp-wrapper.py - 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 pwsp-gui/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.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 - install -Dm644 packages/flatpak/ru.arabianq.pwsp.metainfo.xml /app/share/metainfo/ru.arabianq.pwsp.metainfo.xml
sources: sources:
+85
View File
@@ -0,0 +1,85 @@
# prevent library files from being installed
%global cargo_install_lib 0
# disable debuginfo package generation (debugsourcefiles.list is empty for Rust)
%global debug_package %{nil}
Name: pwsp-git
Version: {{{ git describe --tags --always | sed 's/^v//' | sed -E 's/-([0-9]+)-(g[0-9a-f]+)/^git.\1.\2/' }}}
Release: 1%{?dist}
Summary: Lets you play audio files through your microphone (git version)
License: MIT
URL: https://github.com/arabianq/pipewire-soundpad
VCS: {{{ git_dir_vcs }}}
Source: {{{ git_cwd_pack }}}
BuildRequires: rust
BuildRequires: cargo
BuildRequires: pipewire-devel
%if 0%{?suse_version}
BuildRequires: alsa-devel
BuildRequires: dbus-1-devel
BuildRequires: libopenssl-devel
%else
BuildRequires: alsa-lib-devel
BuildRequires: dbus-devel
BuildRequires: openssl-devel
%endif
BuildRequires: clang-devel
BuildRequires: cmake
BuildRequires: pkgconfig
%if 0%{?suse_version} && 0%{?suse_version} <= 1500
BuildRequires: gcc13-c++
%endif
# Declare compatibility and conflicts with the stable package
Provides: pwsp = %{version}-%{release}
Conflicts: pwsp
%global _description %{expand:
PWSP lets you play audio files through your microphone. Has both CLI and
GUI clients. This is the latest development (git) version.}
%description %{_description}
%prep
{{{ git_cwd_setup_macro }}}
%build
%if 0%{?suse_version} && 0%{?suse_version} <= 1500
export CC=gcc-13
export CXX=g++-13
%endif
cargo build --release --locked
%install
install -Dm755 target/release/pwsp-cli %{buildroot}%{_bindir}/pwsp-cli
install -Dm755 target/release/pwsp-daemon %{buildroot}%{_bindir}/pwsp-daemon
install -Dm755 target/release/pwsp-gui %{buildroot}%{_bindir}/pwsp-gui
install -Dm644 pwsp-gui/assets/pwsp-gui.desktop %{buildroot}%{_datadir}/applications/pwsp.desktop
install -Dm644 pwsp-gui/assets/icon.png %{buildroot}%{_datadir}/icons/hicolor/256x256/apps/pwsp.png
install -Dm644 pwsp-gui/assets/pwsp-daemon.service %{buildroot}/usr/lib/systemd/user/pwsp-daemon.service
%files
%license LICENSE
%doc README.md
%{_bindir}/pwsp-cli
%{_bindir}/pwsp-daemon
%{_bindir}/pwsp-gui
%{_datadir}/applications/pwsp.desktop
%{_datadir}/icons/hicolor/256x256/apps/pwsp.png
/usr/lib/systemd/user/pwsp-daemon.service
%changelog
{{{ git_dir_changelog }}}
+34 -8
View File
@@ -1,10 +1,19 @@
%bcond check 1
# prevent library files from being installed # prevent library files from being installed
%global cargo_install_lib 0 %global cargo_install_lib 0
# Fallback macros for systems without rpmautospec (e.g. openSUSE)
%{!?autorelease: %global autorelease 1}
%{!?autochangelog: %global autochangelog \
* Tue Jun 02 2026 Arabian <arabianq@github> - %{version}-%{release}\
- Release build}
# disable debuginfo package generation (debugsourcefiles.list is empty for Rust)
%global debug_package %{nil}
Name: pwsp Name: pwsp
Version: 1.8.1 Version: 1.12.4
Release: %autorelease Release: %autorelease
Summary: Lets you play audio files through your microphone Summary: Lets you play audio files through your microphone
@@ -16,11 +25,23 @@ Source: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags
BuildRequires: rust BuildRequires: rust
BuildRequires: cargo BuildRequires: cargo
BuildRequires: pipewire-devel BuildRequires: pipewire-devel
%if 0%{?suse_version}
BuildRequires: alsa-devel
BuildRequires: dbus-1-devel
BuildRequires: libopenssl-devel
%else
BuildRequires: alsa-lib-devel BuildRequires: alsa-lib-devel
BuildRequires: dbus-devel
BuildRequires: openssl-devel
%endif
BuildRequires: clang-devel BuildRequires: clang-devel
BuildRequires: cmake BuildRequires: cmake
BuildRequires: dbus-devel BuildRequires: pkgconfig
BuildRequires: pkgconf-pkg-config %if 0%{?suse_version} && 0%{?suse_version} <= 1500
BuildRequires: gcc13-c++
%endif
%global _description %{expand: %global _description %{expand:
PWSP lets you play audio files through your microphone. Has both CLI and PWSP lets you play audio files through your microphone. Has both CLI and
@@ -32,17 +53,22 @@ GUI clients.}
%autosetup -n pipewire-soundpad-%{version} -p1 %autosetup -n pipewire-soundpad-%{version} -p1
%build %build
%if 0%{?suse_version} && 0%{?suse_version} <= 1500
export CC=gcc-13
export CXX=g++-13
%endif
cargo build --release --locked cargo build --release --locked
%install %install
install -Dm755 target/release/pwsp-cli %{buildroot}%{_bindir}/pwsp-cli install -Dm755 target/release/pwsp-cli %{buildroot}%{_bindir}/pwsp-cli
install -Dm755 target/release/pwsp-daemon %{buildroot}%{_bindir}/pwsp-daemon install -Dm755 target/release/pwsp-daemon %{buildroot}%{_bindir}/pwsp-daemon
install -Dm755 target/release/pwsp-gui %{buildroot}%{_bindir}/pwsp-gui install -Dm755 target/release/pwsp-gui %{buildroot}%{_bindir}/pwsp-gui
install -Dm644 assets/pwsp-gui.desktop %{buildroot}%{_datadir}/applications/pwsp.desktop install -Dm644 pwsp-gui/assets/pwsp-gui.desktop %{buildroot}%{_datadir}/applications/pwsp.desktop
install -Dm644 assets/icon.png %{buildroot}%{_datadir}/icons/hicolor/256x256/apps/pwsp.png install -Dm644 pwsp-gui/assets/icon.png %{buildroot}%{_datadir}/icons/hicolor/256x256/apps/pwsp.png
install -Dm644 assets/pwsp-daemon.service %{buildroot}/usr/lib/systemd/user/pwsp-daemon.service install -Dm644 pwsp-gui/assets/pwsp-daemon.service %{buildroot}/usr/lib/systemd/user/pwsp-daemon.service
%files %files
%license LICENSE %license LICENSE
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "pwsp-cli"
version.workspace = true
edition.workspace = true
authors.workspace = true
homepage.workspace = true
repository.workspace = true
license.workspace = true
[dependencies]
pwsp-lib.workspace = true
tokio.workspace = true
anyhow.workspace = true
clap.workspace = true
serde_json.workspace = true
+5 -6
View File
@@ -1,9 +1,10 @@
use anyhow::{Result, anyhow};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use pwsp::{ use pwsp_lib::{
types::socket::Request, types::socket::Request,
utils::daemon::{make_request, wait_for_daemon}, utils::daemon::{make_request, wait_for_daemon},
}; };
use std::{error::Error, path::PathBuf}; use std::path::PathBuf;
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(version, about, long_about = None)] #[command(version, about, long_about = None)]
@@ -146,7 +147,7 @@ enum SetCommands {
} }
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> { async fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
wait_for_daemon().await?; wait_for_daemon().await?;
@@ -204,9 +205,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
}, },
}; };
let response = make_request(request) let response = make_request(request).await.map_err(|e| anyhow!(e))?;
.await
.map_err(|e| e as Box<dyn Error>)?;
println!("{} : {}", response.status, response.message); println!("{} : {}", response.status, response.message);
Ok(()) Ok(())
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "pwsp-daemon"
version.workspace = true
edition.workspace = true
authors.workspace = true
homepage.workspace = true
repository.workspace = true
license.workspace = true
[dependencies]
pwsp-lib.workspace = true
tokio.workspace = true
serde_json.workspace = true
clap.workspace = true
anyhow.workspace = true
pipewire.workspace = true
+200
View File
@@ -0,0 +1,200 @@
use anyhow::{Result, anyhow};
use pwsp_lib::{
types::socket::{MAX_MESSAGE_SIZE, Request, Response},
utils::{
commands::parse_command,
daemon::{
create_runtime_dir, get_audio_player, get_daemon_config, get_runtime_dir,
is_daemon_running,
},
global_hotkeys::start_global_hotkey_listener,
pipewire::create_virtual_mic,
},
};
use std::os::unix::fs::PermissionsExt;
use std::{fs, time::Duration};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::{UnixListener, UnixStream},
time::sleep,
};
#[tokio::main]
async fn main() -> Result<()> {
create_runtime_dir()?;
if is_daemon_running()? {
return Err(anyhow!("Another instance is already running."));
}
get_daemon_config(); // Initialize daemon config
// Virtual mic object must be kept alive by some variable until daemon exits
let _virtual_mic = create_virtual_mic().await?;
if let Err(err) = get_audio_player().await {
eprintln!("Failed to initialize audio player: {}", err);
} // Initialize audio player
tokio::spawn(async {
start_global_hotkey_listener().await;
});
let runtime_dir = get_runtime_dir();
let lock_file = fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(runtime_dir.join("daemon.lock"))?;
lock_file.lock()?;
let socket_path = runtime_dir.join("daemon.sock");
if let Err(e) = fs::remove_file(&socket_path)
&& 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<()> {
loop {
let (stream, _addr) = listener.accept().await?;
tokio::spawn(async move {
handle_connection(stream).await;
});
}
}
async fn handle_connection(mut stream: UnixStream) {
// ---------- Read request (start) ----------
let mut len_bytes = [0u8; 4];
if stream.read_exact(&mut len_bytes).await.is_err() {
eprintln!("Failed to read message length from client!");
return;
}
let request_len = u32::from_le_bytes(len_bytes) as usize;
if request_len > MAX_MESSAGE_SIZE {
eprintln!(
"Failed to read message from client: request too large ({} bytes)!",
request_len
);
return;
}
let mut buffer = Vec::new();
if (&mut stream)
.take(request_len as u64)
.read_to_end(&mut buffer)
.await
.is_err()
|| buffer.len() != request_len
{
eprintln!("Failed to read message from client!");
return;
}
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) ----------
let command = parse_command(&request);
let response: Response;
if let Some(command) = command {
response = command.execute().await;
} else {
response = Response::new(false, "Unknown command");
}
// ---------- Generate response (end) ----------
// ---------- Send response (start) ----------
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() {
eprintln!("Failed to write response length to client!");
return;
}
if stream.write_all(&response_data).await.is_err() {
eprintln!("Failed to write response to client!");
return;
}
// ---------- Send response (end) ----------
if response.status && response.message.eq("killed") {
std::process::exit(0);
}
}
async fn player_loop() {
let mut device_check_counter: u32 = 0;
loop {
let is_idle = match get_audio_player().await {
Ok(player_mutex) => {
let mut audio_player = player_mutex.lock().await;
let check_devices = device_check_counter == 0;
audio_player.update(check_devices).await;
audio_player.tracks.is_empty()
}
Err(_err) => true,
};
if is_idle {
device_check_counter = 0;
sleep(Duration::from_secs(2)).await;
} else {
// Check devices every ~5 seconds (50 * 100ms) while playing
device_check_counter = (device_check_counter + 1) % 50;
sleep(Duration::from_millis(100)).await;
}
}
}
+72
View File
@@ -0,0 +1,72 @@
[package]
name = "pwsp-gui"
version.workspace = true
edition.workspace = true
authors.workspace = true
homepage.workspace = true
repository.workspace = true
license.workspace = true
description.workspace = true
[dependencies]
pwsp-lib.workspace = true
tokio.workspace = true
opener.workspace = true
rfd.workspace = true
itertools.workspace = true
anyhow.workspace = true
serde.workspace = true
serde_json.workspace = true
egui.workspace = true
eframe.workspace = true
egui_extras.workspace = true
egui_material_icons.workspace = true
egui_dnd.workspace = true
system-fonts.workspace = true
rust-i18n.workspace = true
sys-locale.workspace = true
reqwest.workspace = true
percent-encoding.workspace = true
[package.metadata.deb]
name = "pwsp"
section = "sound"
priority = "optional"
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",
],
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

@@ -1,8 +1,9 @@
[Desktop Entry] [Desktop Entry]
Name=PWSP (Soundpad) Name=PWSP (Soundpad)
Comment=Let's you play audio files through you microphone Comment=Let's you play audio files through you microphone
Exec=pwsp-gui %u Exec=/usr/bin/pwsp-gui %u
Icon=pwsp Icon=pwsp
Terminal=false Terminal=false
Type=Application Type=Application
Categories=Audio Categories=Audio
MimeType=x-scheme-handler/soundpad;

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

+486
View File
@@ -0,0 +1,486 @@
_version = 2
# ----------------
# Main page
# ----------------
[gui.play_file_button]
en = "Play file"
ru = "Выбрать файл"
es = "Reproducir archivo"
fr = "Lire le fichier"
zh = "播放文件"
ar = "تشغيل الملف"
kz = "Файлды ойнату"
he = "נגן קובץ"
pt-BR = "Reproduzir arquivo"
[gui.choose_mic_select]
en = "Select microphone"
ru = "Выбрать микрофон"
es = "Seleccionar micrófono"
fr = "Sélectionner le microphone"
zh = "选择麦克风"
ar = "اختر الميكروفون"
kz = "Микрофонды таңдау"
he = "בחר מיקרופון"
pt-BR = "Selecionar microfone"
[gui.search_placeholder]
en = "Search files..."
ru = "Поиск файлов..."
es = "Buscar archivos..."
fr = "Rechercher des fichiers..."
zh = "搜索文件..."
ar = "البحث عن ملفات..."
kz = "Файлдарды іздеу..."
he = "חפש קבצים..."
pt-BR = "Buscar arquivos..."
[gui.context.dirs.open]
en = "Open"
ru = "Открыть"
es = "Abrir"
fr = "Ouvrir"
zh = "打开"
ar = "فتح"
kz = "Ашу"
he = "פתח"
pt-BR = "Abrir"
[gui.context.dirs.open_in_fm]
en = "Open in File Manager"
ru = "Открыть в менеджере файлов"
es = "Abrir en el gestor de archivos"
fr = "Ouvrir dans le gestionnaire de fichiers"
zh = "在文件管理器中打开"
ar = "فتح في مدير الملفات"
kz = "Файл менеджерінде ашу"
he = "פתח במנהל הקבצים"
pt-BR = "Abrir no gestor de arquivos"
[gui.context.dirs.remove]
en = "Remove"
ru = "Удалить"
es = "Eliminar"
fr = "Supprimer"
zh = "移除"
ar = "إزالة"
kz = "Жою"
he = "הסר"
pt-BR = "Remover"
[gui.context.dirs.sort_by]
en = "Sort by"
ru = "Сортировка"
es = "Ordenar por"
fr = "Trier par"
zh = "排序方式"
ar = "ترتيب حسب"
kz = "Сұрыптау"
he = "מיין לפי"
pt-BR = "Ordenar por"
[gui.sort.alpha_asc]
en = "Alphabetical (A-Z)"
ru = "По алфавиту (А-Я)"
es = "Alfabético (A-Z)"
fr = "Alphabétique (A-Z)"
zh = "字母顺序 (A-Z)"
ar = "أبجدي (A-Z)"
kz = "Әліпби бойынша (А-Я)"
he = "אלפביתי (A-Z)"
pt-BR = "Alfabético (A-Z)"
[gui.sort.alpha_desc]
en = "Alphabetical (Z-A)"
ru = "По алфавиту (Я-А)"
es = "Alfabético (Z-A)"
fr = "Alphabétique (Z-A)"
zh = "字母顺序 (Z-A)"
ar = "أبجدي (Z-A)"
kz = "Әліпби бойынша (Я-А)"
he = "אלפביתי (Z-A)"
pt-BR = "Alfabético (Z-A)"
[gui.sort.date_newest]
en = "Date modified (Newest first)"
ru = "Дата изменения (Сначала новые)"
es = "Fecha de modificación (Más nuevo primero)"
fr = "Date de modification (Plus récent en premier)"
zh = "修改日期 (最新优先)"
ar = "تاريخ التعديل (الأحدث أولاً)"
kz = "Өзгертілген күні (Жаңалары бірінші)"
he = "תאריך שינוי (החדש ביותר ראשון)"
pt-BR = "Data de modificação (Mais novo primeiro)"
[gui.sort.date_oldest]
en = "Date modified (Oldest first)"
ru = "Дата изменения (Сначала старые)"
es = "Fecha de modificación (Más antiguo primero)"
fr = "Date de modification (Plus ancien en premier)"
zh = "修改日期 (最旧优先)"
ar = "تاريخ التعديل (الأقدم أولاً)"
kz = "Өзгертілген күні (Ескілері бірінші)"
he = "תאריך שינוי (הישן ביותר ראשון)"
pt-BR = "Data de modificação (Mais antigo primeiro)"
[gui.context.files.play_solo]
en = "Play Solo"
ru = "Играть"
es = "Reproducir solo"
fr = "Jouer en solo"
zh = "单独播放"
ar = "تشغيل منفرد"
kz = "Жалғыз ойнату"
he = "נגן סולו"
pt-BR = "Reproduzir"
[gui.context.files.add_new]
en = "Add New"
ru = "Добавить"
es = "Añadir nuevo"
fr = "Ajouter un nouveau"
zh = "添加新项"
ar = "إضافة جديد"
kz = "Жаңасын қосу"
he = "הוסף חדש"
pt-BR = "Adicionar"
[gui.context.files.replace_last]
en = "Replace Last"
ru = "Заменить Последний"
es = "Reemplazar último"
fr = "Remplacer le dernier"
zh = "替换上一个"
ar = "استبدال الأخير"
kz = "Соңғысын ауыстыру"
he = "החלף אחרון"
pt-BR = "Substituir"
[gui.context.files.show_in_fm]
en = "Show in File Manager"
ru = "Открыть в менеджере файлов"
es = "Mostrar en el gestor de archivos"
fr = "Afficher dans le gestionnaire de fichiers"
zh = "在文件管理器中显示"
ar = "عرض في مدير الملفات"
kz = "Файл менеджерінде көрсету"
he = "הצג במנהל הקבצים"
pt-BR = "Mostrar no gestor de arquivos"
[gui.context.files.asign_hotkey]
en = "Assign Hotkey"
ru = "Назначить Горячую Клавишу"
es = "Asignar atajo"
fr = "Assigner un raccourci"
zh = "分配快捷键"
ar = "تعيين مفتاح اختصار"
kz = "Ыстық пернені тағайындау"
he = "הקצה מקש קיצור"
pt-BR = "Definir tecla de atalho"
[gui.context.files.copy_cli_command]
en = "Copy PWSP-CLI command"
ru = "Скопировать команду для PWSP-CLI"
es = "Copiar comando de PWSP-CLI"
fr = "Copier la commande PWSP-CLI"
zh = "复制 PWSP-CLI 命令"
ar = "نسخ أمر PWSP-CLI"
kz = "PWSP-CLI командасын көшіру"
he = "העתק פקודת PWSP-CLI"
pt-BR = "Copiar comando PWSP-CLI"
# ----------------
# Settings
# ----------------
[gui.settings.header]
en = "Settings"
ru = "Настройки"
es = "Ajustes"
fr = "Paramètres"
zh = "设置"
ar = "الإعدادات"
kz = "Баптаулар"
he = "הגדרות"
pt-BR = "Configurações"
[gui.settings.remember_volume]
en = "Always remember volume"
ru = "Всегда запоминать громкость"
es = "Recordar siempre el volumen"
fr = "Toujours se souvenir du volume"
zh = "始终记住音量"
ar = "تذكر مستوى الصوت دائمًا"
kz = "Әрқашан дыбыс деңгейін есте сақтау"
he = "זכור תמיד עוצמת קול"
pt-BR = "Lembrar volume"
[gui.settings.remember_mic]
en = "Always remember microphone"
ru = "Всегда запоминать микрофон"
es = "Recordar siempre el micrófono"
fr = "Toujours se souvenir du microphone"
zh = "始终记住麦克风"
ar = "تذكر الميكروفون دائمًا"
kz = "Әрқашан микрофонды есте сақтау"
he = "זכור תמיד מיקרופון"
pt-BR = "Lembrar microfone"
[gui.settings.remember_ui_scale]
en = "Always remember UI scale factor"
ru = "Всегда запоминать масштаб интерфейса"
es = "Recordar siempre la escala de la interfaz"
fr = "Toujours se souvenir de l'échelle de l'interface"
zh = "始终记住界面缩放比例"
ar = "تذكر عامل تكبير الواجهة دائمًا"
kz = "Әрқашан интерфейс масштабын есте сақтау"
he = "זכור תמיד קנה מידה של ממשק משתמש"
pt-BR = "Lembrar fator de escala da interface"
[gui.settings.pause_on_window_close]
en = "Pause audio playback when the window is closed"
ru = "Останавливать воспроизведение при закрытии окна"
es = "Pausar la reproducción de audio al cerrar la ventana"
fr = "Mettre en pause la lecture audio à la fermeture de la fenêtre"
zh = "关闭窗口时暂停音频播放"
ar = "إيقاف الصوت مؤقتًا عند إغلاق النافذة"
kz = "Терезе жабылған кезде дыбысты ойнатуды кідірту"
he = "השהה השמעת שמע כאשר החלון נסגר"
pt-BR = "Pausar reprodução de aúdio ao fechar a janela"
[gui.settings.version]
en = "GUI version: %{version}"
ru = "Версия GUI: %{version}"
es = "Versión de la GUI: %{version}"
fr = "Version de l'interface : %{version}"
zh = "GUI 版本: %{version}"
ar = "إصدار الواجهة: %{version}"
kz = "GUI нұсқасы: %{version}"
he = "גרסת ממשק משתמש: %{version}"
pt-BR = "Versão da GUI: %{version}"
[gui.settings.theme.label]
en = "Color Scheme"
ru = "Цветовая схема"
es = "Esquema de color"
fr = "Schéma de couleurs"
zh = "配色方案"
ar = "نظام الألوان"
kz = "Түс схемасы"
he = "ערכת צבעים"
pt-BR = "Esquema de cores"
[gui.settings.theme.system]
en = "System"
ru = "Системная"
es = "Sistema"
fr = "Système"
zh = "系统"
ar = "النظام"
kz = "Жүйе"
he = "מערכת"
pt-BR = "Sistema"
[gui.settings.theme.light]
en = "Light"
ru = "Светлая"
es = "Claro"
fr = "Clair"
zh = "浅色"
ar = "فاتح"
kz = "Жарық"
he = "בהיר"
pt-BR = "Claro"
[gui.settings.theme.dark]
en = "Dark"
ru = "Тёмная"
es = "Oscuro"
fr = "Sombre"
zh = "暗色"
ar = "داكن"
kz = "Қараңғы"
he = "כהה"
pt-BR = "Escuro"
# ----------------
# Hotkeys
# ----------------
[gui.hotkeys.header]
en = "Hotkeys"
ru = "Горячие клавиши"
es = "Atajos de teclado"
fr = "Raccourcis clavier"
zh = "快捷键"
ar = "اختصارات لوحة المفاتيح"
kz = "Ыстық пернелер"
he = "מקשי קיצור"
pt-BR = "Atalhos"
[gui.hotkeys.search_placeholder]
en = "Search hotkeys..."
ru = "Поиск горячих клавиш..."
es = "Buscar atajos..."
fr = "Rechercher des raccourcis..."
zh = "搜索快捷键..."
ar = "البحث عن الاختصارات..."
kz = "Ыстық пернелерді іздеу..."
he = "חפש מקשי קיצור..."
pt-BR = "Buscar atalhos..."
[gui.hotkeys.add_command_select]
en = "Add Command"
ru = "Добавить команду"
es = "Añadir comando"
fr = "Ajouter une commande"
zh = "添加命令"
ar = "إضافة أمر"
kz = "Команда қосу"
he = "הוסף פקודה"
pt-BR = "Adicionar comando"
[gui.hotkeys.toggle_pause_command]
en = "Toggle Pause"
ru = "Переключить паузу"
es = "Alternar pausa"
fr = "Basculer la pause"
zh = "切换暂停"
ar = "تبديل الإيقاف المؤقت"
kz = "Кідіртуді ауыстыру"
he = "הפעל/השהה"
pt-BR = "Alternar reprodução"
[gui.hotkeys.stop_playback_command]
en = "Stop Playback"
ru = "Остановить воспроизведение"
es = "Detener reproducción"
fr = "Arrêter la lecture"
zh = "停止播放"
ar = "إيقاف التشغيل"
kz = "Ойнатуды тоқтату"
he = "עצור השמעה"
pt-BR = "Parar reprodução"
[gui.hotkeys.pause_playback_command]
en = "Pause Playback"
ru = "Поставить воспроизведение на паузу"
es = "Pausar reproducción"
fr = "Mettre en pause la lecture"
zh = "暂停播放"
ar = "إيقاف التشغيل مؤقتاً"
kz = "Ойнатуды кідірту"
he = "השהה השמעה"
pt-BR = "Pausar reprodução"
[gui.hotkeys.resume_playback_command]
en = "Resume Playback"
ru = "Продолжить воспроизведение"
es = "Reanudar reproducción"
fr = "Reprendre la lecture"
zh = "恢复播放"
ar = "استئناف التشغيل"
kz = "Ойнатуды жалғастыру"
he = "המשך השמעה"
pt-BR = "Resumir reprodução"
[gui.hotkeys.toggle_loop_command]
en = "Toggle Loop"
ru = "Переключить зацикливание"
es = "Alternar bucle"
fr = "Basculer la boucle"
zh = "切换循环"
ar = "تبديل التكرار"
kz = "Қайталауды ауыстыру"
he = "הפעל/כבה לולאה"
pt-BR = "Alternar loop"
[gui.hotkeys.column_slot]
en = "Slot"
ru = "Слот"
es = "Ranura"
fr = "Emplacement"
zh = "插槽"
ar = "الخانة"
kz = "Ұяшық"
he = "משבצת"
pt-BR = "Slot"
[gui.hotkeys.column_sound]
en = "Sound"
ru = "Звук"
es = "Sonido"
fr = "Son"
zh = "声音"
ar = "الصوت"
kz = "Дыбыс"
he = "צליל"
pt-BR = "Som"
[gui.hotkeys.column_key_chord]
en = "Key Chord"
ru = "Клавиша"
es = "Combinación de teclas"
fr = "Combinaison de touches"
zh = "组合键"
ar = "تركيبة المفاتيح"
kz = "Пернелер тіркесімі"
he = "צירוף מקשים"
pt-BR = "Combinação de teclas"
[gui.hotkeys.column_actions]
en = "Actions"
ru = "Действия"
es = "Acciones"
fr = "Actions"
zh = "操作"
ar = "الإجراءات"
kz = "Әрекеттер"
he = "פעולות"
pt-BR = "Ações"
[gui.hotkeys.no_hotkeys_configured]
en = "No hotkeys configured"
ru = "Горячие клавиши не настроены"
es = "No hay atajos configurados"
fr = "Aucun raccourci configuré"
zh = "未配置快捷键"
ar = "لا توجد اختصارات معينة"
kz = "Ыстық пернелер бапталмаған"
he = "לא הוגדרו מקשי קיצור"
pt-BR = "Nenhum atalho configurado"
[gui.hotkeys.capture.header]
en = "Press a key combination (e.g. Ctrl+Alt+1)"
ru = "Нажмите сочетание клавиш (например, Ctrl+Alt+1)"
es = "Presione una combinación de teclas (ej. Ctrl+Alt+1)"
fr = "Appuyez sur une combinaison de touches (ex. Ctrl+Alt+1)"
zh = "按下一个组合键 (例如 Ctrl+Alt+1)"
ar = "اضغط على تركيبة مفاتيح (مثلاً Ctrl+Alt+1)"
kz = "Пернелер тіркесімін басыңыз (мысалы, Ctrl+Alt+1)"
he = "לחץ על צירוף מקשים (למשל Ctrl+Alt+1)"
pt-BR = "Pressione uma combinação de tecla (ex: Ctrl+Alt+1)"
[gui.hotkeys.capture.for]
en = "for"
ru = "для"
es = "para"
fr = "pour"
zh = "用于"
ar = "لـ"
kz = "үшін"
he = "עבור"
pt-BR = "para"
[gui.hotkeys.capture.cancel]
en = "Press Escape to cancel"
ru = "Нажмите Escape для отмены"
es = "Presione Escape para cancelar"
fr = "Appuyez sur Échap pour annuler"
zh = "按 Escape 取消"
ar = "اضغط Esc للإلغاء"
kz = "Болдырмау үшін Escape пернесін басыңыз"
he = "לחץ על Escape לביטול"
pt-BR = "Pressione Esc para cancelar"
+70 -73
View File
@@ -1,9 +1,7 @@
use crate::gui::SoundpadGui; use crate::gui::SoundpadGui;
use egui::{Context, Id, Key, Modifiers}; use egui::{Context, Id, Key, Modifiers};
use pwsp::types::socket::Request; use pwsp_lib::types::socket::Request;
use pwsp::utils::gui::make_request_async; use pwsp_lib::utils::gui::make_request_async;
use std::path::PathBuf;
/// Convert an egui Key + Modifiers to a normalized chord string like "Ctrl+Shift+A". /// Convert an egui Key + Modifiers to a normalized chord string like "Ctrl+Shift+A".
fn chord_from_event(modifiers: &Modifiers, key: &Key) -> Option<String> { fn chord_from_event(modifiers: &Modifiers, key: &Key) -> Option<String> {
@@ -94,7 +92,7 @@ impl SoundpadGui {
} }
pub fn handle_input(&mut self, ctx: &Context) { pub fn handle_input(&mut self, ctx: &Context) {
let modifiers = self.modifiers(ctx); let _modifiers = self.modifiers(ctx);
let search_focused = { let search_focused = {
if let Some(focused_id) = self.get_focused(ctx) if let Some(focused_id) = self.get_focused(ctx)
&& let Some(search_id) = self.app_state.search_field_id && let Some(search_id) = self.app_state.search_field_id
@@ -197,74 +195,6 @@ impl SoundpadGui {
} }
} }
// Play selected file on Enter
if self.key_pressed(ctx, Key::Enter)
&& 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.to_vec();
dirs.sort();
let current_dir_index = self
.app_state
.current_dir
.as_ref()
.and_then(|cd| dirs.iter().position(|x| x == cd));
let new_dir_index =
match (current_dir_index, arrow_up_pressed, arrow_down_pressed) {
(Some(i), true, false) => (i + dirs.len() - 1) % dirs.len(),
(Some(i), false, true) => (i + 1) % dirs.len(),
(Some(i), true, true) => i,
(None, true, _) => dirs.len() - 1,
(None, false, true) => 0,
_ => return,
};
self.open_dir(&dirs[new_dir_index]);
} else if self.app_state.current_dir.is_some() {
let files = self.get_filtered_files();
if files.is_empty() {
return;
}
let current_files_index = self
.app_state
.selected_file
.as_ref()
.and_then(|f| files.iter().position(|x| x == f));
let new_files_index =
match (current_files_index, arrow_up_pressed, arrow_down_pressed) {
(Some(i), true, false) => (i + files.len() - 1) % files.len(),
(Some(i), false, true) => (i + 1) % files.len(),
(Some(i), true, true) => i,
(None, true, _) => files.len() - 1,
(None, false, true) => 0,
_ => return,
};
self.app_state.selected_file = Some(files[new_files_index].clone());
}
}
// Check for hotkey chord triggers // Check for hotkey chord triggers
let slots_to_play: Vec<String> = ctx.input(|i| { let slots_to_play: Vec<String> = ctx.input(|i| {
let mut result = vec![]; let mut result = vec![];
@@ -287,3 +217,70 @@ impl SoundpadGui {
// }); // });
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use egui::{Key, Modifiers};
#[test]
fn test_chord_from_event() {
// Valid modifier + key
let mut mods = Modifiers::NONE;
mods.ctrl = true;
let chord = chord_from_event(&mods, &Key::A);
assert_eq!(chord, Some("Ctrl+A".to_string()));
// Multiple modifiers
mods.shift = true;
let chord = chord_from_event(&mods, &Key::F1);
assert_eq!(chord, Some("Ctrl+Shift+F1".to_string()));
// Missing modifiers (requires at least one modifier)
let no_mods = Modifiers::NONE;
let chord = chord_from_event(&no_mods, &Key::A);
assert_eq!(chord, None);
// Invalid keys (e.g. Escape or Enter shouldn't be accepted by chord_from_event)
mods.shift = false;
let chord = chord_from_event(&mods, &Key::Escape);
assert_eq!(chord, None);
}
#[test]
fn test_parse_chord() {
// Valid Ctrl+A
let res = parse_chord("Ctrl+A");
assert!(res.is_some());
let (mods, key) = res.unwrap();
assert!(mods.ctrl);
assert!(!mods.alt);
assert!(!mods.shift);
assert_eq!(key, Key::A);
// Valid Ctrl+Shift+F12
let res = parse_chord("Ctrl+Shift+F12");
assert!(res.is_some());
let (mods, key) = res.unwrap();
assert!(mods.ctrl);
assert!(mods.shift);
assert!(!mods.alt);
assert_eq!(key, Key::F12);
// Valid Ctrl+Alt+Shift+Super+B
let res = parse_chord("Ctrl+Alt+Shift+Super+B");
assert!(res.is_some());
let (mods, key) = res.unwrap();
assert!(mods.ctrl);
assert!(mods.alt);
assert!(mods.shift);
assert!(mods.command); // Super maps to command in egui Modifiers
assert_eq!(key, Key::B);
// Invalid keys/chords
assert!(parse_chord("").is_none());
assert!(parse_chord("Ctrl+").is_none());
assert!(parse_chord("Ctrl+Escape").is_none());
assert!(parse_chord("Invalid+A").is_none());
}
}
+91 -26
View File
@@ -1,11 +1,12 @@
mod draw;
mod input; mod input;
mod update; mod update;
mod views;
use eframe::{HardwareAcceleration, NativeOptions, icon_data::from_png_bytes, run_native}; use anyhow::{Result, anyhow};
use eframe::{NativeOptions, icon_data::from_png_bytes, run_native};
use egui::{Context, FontData, FontDefinitions, FontFamily, FontTweak, Vec2, ViewportBuilder}; use egui::{Context, FontData, FontDefinitions, FontFamily, FontTweak, Vec2, ViewportBuilder};
use itertools::Itertools; use itertools::Itertools;
use pwsp::{ use pwsp_lib::{
types::{ types::{
audio_player::PlayerState, audio_player::PlayerState,
config::GuiConfig, config::GuiConfig,
@@ -20,7 +21,7 @@ use pwsp::{
}; };
use rfd::FileDialog; use rfd::FileDialog;
use std::{ use std::{
error::Error, cmp::Ordering,
fs, fs,
path::{Path, PathBuf}, path::{Path, PathBuf},
sync::{Arc, Mutex}, sync::{Arc, Mutex},
@@ -110,14 +111,14 @@ impl SoundpadGui {
self.app_state.current_dir = Some(path.clone()); self.app_state.current_dir = Some(path.clone());
match path.read_dir() { match path.read_dir() {
Ok(read_dir) => { Ok(read_dir) => {
self.app_state.files = read_dir self.app_state.listed_files = read_dir
.filter_map(|res| res.ok()) .filter_map(|res| res.ok())
.map(|entry| entry.path()) .map(|entry| entry.path())
.collect(); .collect();
} }
Err(e) => { Err(e) => {
eprintln!("Failed to read directory {:?}: {}", path, e); eprintln!("Failed to read directory {:?}: {}", path, e);
self.app_state.files.clear(); self.app_state.listed_files.clear();
} }
} }
} }
@@ -157,8 +158,25 @@ impl SoundpadGui {
} }
pub fn get_filtered_files(&self) -> Vec<PathBuf> { pub fn get_filtered_files(&self) -> Vec<PathBuf> {
let mut files: Vec<PathBuf> = self.app_state.files.iter().cloned().collect(); let mut files: Vec<PathBuf> = self.app_state.listed_files.iter().cloned().collect();
files.sort(); let sort_order = self
.app_state
.current_dir
.as_ref()
.map(|d| self.config.get_sort_order(d))
.unwrap_or_default();
files.sort_by(|a, b| {
let a_is_dir = a.is_dir();
let b_is_dir = b.is_dir();
if a_is_dir && !b_is_dir {
Ordering::Less
} else if !a_is_dir && b_is_dir {
Ordering::Greater
} else {
sort_order.compare(a, b)
}
});
let search_query = self.app_state.search_query.to_lowercase(); let search_query = self.app_state.search_query.to_lowercase();
let search_query = search_query.trim(); let search_query = search_query.trim();
@@ -167,7 +185,7 @@ impl SoundpadGui {
.into_iter() .into_iter()
.filter(|entry_path| { .filter(|entry_path| {
if entry_path.is_dir() { if entry_path.is_dir() {
return false; return true;
} }
if !SUPPORTED_EXTENSIONS.contains( if !SUPPORTED_EXTENSIONS.contains(
@@ -181,11 +199,7 @@ impl SoundpadGui {
} }
if !search_query.is_empty() { if !search_query.is_empty() {
let file_name = entry_path let file_name = entry_path.file_name().unwrap_or_default().to_string_lossy();
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
if !file_name.to_lowercase().contains(search_query) { if !file_name.to_lowercase().contains(search_query) {
return false; return false;
@@ -198,14 +212,10 @@ impl SoundpadGui {
} }
} }
fn add_font( fn add_font(font_name: &str, font_bytes: &[u8], fonts: &mut FontDefinitions) -> Result<()> {
font_name: &str,
font_bytes: &[u8],
fonts: &mut FontDefinitions,
) -> Result<(), Box<dyn Error>> {
let font_data = FontData::from_owned(font_bytes.to_vec()).tweak(FontTweak { let font_data = FontData::from_owned(font_bytes.to_vec()).tweak(FontTweak {
scale: 1.0, scale: 1.0,
hinting_override: Some(true), hinting: Some(true),
..Default::default() ..Default::default()
}); });
@@ -227,12 +237,13 @@ fn add_font(
Ok(()) Ok(())
} }
fn load_system_fonts(fonts: &mut FontDefinitions) -> Result<(), Box<dyn Error>> { fn load_system_fonts(fonts: &mut FontDefinitions) -> Result<()> {
let (_, en_sans) = find_for_locale("en", FontStyle::Sans); let (_, en_sans) = find_for_locale("en", FontStyle::Sans);
let (_, en_serif) = find_for_locale("en", FontStyle::Serif); let (_, en_serif) = find_for_locale("en", FontStyle::Serif);
let (_, ja_sans) = find_for_locale("ja", FontStyle::Sans); let (_, ja_sans) = find_for_locale("ja", FontStyle::Sans);
let (_, ar_sans) = find_for_locale("ar", FontStyle::Sans);
let system_fonts = [en_sans, en_serif, ja_sans].concat(); let system_fonts = [en_sans, en_serif, ja_sans, ar_sans].concat();
for font in system_fonts.iter().rev() { for font in system_fonts.iter().rev() {
let font_bytes = match &font.source { let font_bytes = match &font.source {
@@ -246,13 +257,11 @@ fn load_system_fonts(fonts: &mut FontDefinitions) -> Result<(), Box<dyn Error>>
Ok(()) Ok(())
} }
pub async fn run() -> Result<(), Box<dyn Error>> { pub async fn run() -> Result<()> {
const ICON: &[u8] = include_bytes!("../../assets/icon.png"); const ICON: &[u8] = include_bytes!("../../assets/icon.png");
let options = NativeOptions { let options = NativeOptions {
vsync: true,
centered: true, centered: true,
hardware_acceleration: HardwareAcceleration::Preferred,
viewport: ViewportBuilder::default() viewport: ViewportBuilder::default()
.with_app_id("ru.arabianq.pwsp") .with_app_id("ru.arabianq.pwsp")
@@ -283,6 +292,62 @@ pub async fn run() -> Result<(), Box<dyn Error>> {
} }
Ok(()) Ok(())
} }
Err(e) => Err(e.into()), Err(e) => Err(anyhow!(e.to_string())),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_filtered_files() {
let mut gui = SoundpadGui {
app_state: AppState::default(),
config: GuiConfig::default(),
audio_player_state: AudioPlayerState::default(),
audio_player_state_shared: Arc::new(Mutex::new(AudioPlayerState::default())),
};
// Create some dummy paths
// We will mock path properties using standard Rust PathBuf
let dir_a = PathBuf::from("a_dir");
let file_b = PathBuf::from("b_file.mp3");
let file_c = PathBuf::from("c_file.wav");
let file_txt = PathBuf::from("invalid.txt");
gui.app_state.listed_files.insert(dir_a.clone());
gui.app_state.listed_files.insert(file_b.clone());
gui.app_state.listed_files.insert(file_c.clone());
gui.app_state.listed_files.insert(file_txt.clone());
// Note: is_dir() check in get_filtered_files relies on physical filesystem properties.
// On the real OS filesystem, these paths don't exist, so they are treated as files.
// Unsupported extensions (like .txt) will be filtered out.
// So we expect only file_b and file_c, sorted alphabetically.
let filtered = gui.get_filtered_files();
assert_eq!(filtered.len(), 2);
assert_eq!(filtered[0], file_b);
assert_eq!(filtered[1], file_c);
// Test search query
gui.app_state.search_query = "c_fi".to_string();
let filtered_search = gui.get_filtered_files();
assert_eq!(filtered_search.len(), 1);
assert_eq!(filtered_search[0], file_c);
// Test sort order descending
gui.app_state.current_dir = Some(PathBuf::from("dummy_dir"));
gui.config.dirs_settings.insert(
PathBuf::from("dummy_dir"),
pwsp_lib::types::config::DirSettings {
sort_order: pwsp_lib::types::config::SortOrder::AlphabeticalDesc,
},
);
gui.app_state.search_query = String::new();
let filtered_desc = gui.get_filtered_files();
assert_eq!(filtered_desc.len(), 2);
assert_eq!(filtered_desc[0], file_c);
assert_eq!(filtered_desc[1], file_b);
} }
} }
@@ -1,14 +1,31 @@
use crate::gui::SoundpadGui; use crate::gui::SoundpadGui;
use eframe::{App, Frame as EFrame}; use eframe::{App, Frame as EFrame};
use egui::{CentralPanel, Context}; use egui::{CentralPanel, Context, ThemePreference};
use pwsp::{ use pwsp_lib::{
types::socket::Request, types::{config::PreferredTheme, socket::Request},
utils::{daemon::get_daemon_config, gui::make_request_async}, utils::{daemon::get_daemon_config, gui::make_request_async},
}; };
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
impl App for SoundpadGui { impl App for SoundpadGui {
fn logic(&mut self, ctx: &Context, _frame: &mut EFrame) { fn logic(&mut self, ctx: &Context, _frame: &mut EFrame) {
// Update theme
let current_theme = match ctx.options(|r| r.theme_preference) {
ThemePreference::System => PreferredTheme::System,
ThemePreference::Light => PreferredTheme::Light,
ThemePreference::Dark => PreferredTheme::Dark,
};
if !self.config.preferred_theme.eq(&current_theme) {
ctx.options_mut(|w| {
w.theme_preference = match self.config.preferred_theme {
PreferredTheme::System => ThemePreference::System,
PreferredTheme::Light => ThemePreference::Light,
PreferredTheme::Dark => ThemePreference::Dark,
}
})
}
// Remove directories // Remove directories
for path in self.app_state.dirs_to_remove.drain() { for path in self.app_state.dirs_to_remove.drain() {
self.app_state.dirs.retain(|x| x != &path); self.app_state.dirs.retain(|x| x != &path);
@@ -16,7 +33,7 @@ impl App for SoundpadGui {
&& current_dir == &path && current_dir == &path
{ {
self.app_state.current_dir = None; self.app_state.current_dir = None;
self.app_state.files.clear(); self.app_state.listed_files.clear();
} }
} }
@@ -104,7 +121,7 @@ impl App for SoundpadGui {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut EFrame) { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut EFrame) {
// Draw UI // Draw UI
CentralPanel::default().show_inside(ui, |ui| { CentralPanel::default().show(ui, |ui| {
if !self.audio_player_state.is_daemon_running { if !self.audio_player_state.is_daemon_running {
self.draw_waiting_for_daemon(ui); self.draw_waiting_for_daemon(ui);
return; return;
+517
View File
@@ -0,0 +1,517 @@
use crate::gui::SoundpadGui;
use egui::{
Align, AtomExt, Button, CollapsingHeader, Color32, CursorIcon, Layout, RichText, ScrollArea,
Sense, TextEdit, Ui, Vec2,
};
use egui_dnd::dnd;
use egui_material_icons::icons::*;
use pwsp_lib::types::{
config::{GuiConfig, SortOrder},
gui::{AppState, AudioPlayerState},
};
use rust_i18n::t;
use std::{cmp::Ordering, path::Path, path::PathBuf};
pub(crate) enum FileAction {
Play(PathBuf, bool),
StopAndPlay(u32, PathBuf, bool),
AssignHotkey(PathBuf),
}
impl SoundpadGui {
pub fn draw_body(&mut self, ui: &mut Ui) {
let left_panel_width = self
.config
.left_panel_width
.max(100.0)
.min(ui.available_width() - 100.0);
let dirs_size = Vec2::new(left_panel_width, ui.available_height() - 40.0);
ui.horizontal(|ui| {
self.draw_dirs(ui, dirs_size);
let (rect, response) = ui.allocate_at_least(
Vec2::new(ui.spacing().item_spacing.x, ui.available_height()),
Sense::click_and_drag(),
);
if ui.is_rect_visible(rect) {
let stroke = ui.visuals().widgets.noninteractive.bg_stroke;
ui.painter().vline(rect.center().x, rect.y_range(), stroke);
}
let vertical_separator_response =
response.on_hover_and_drag_cursor(CursorIcon::ResizeHorizontal);
if vertical_separator_response.dragged() {
self.config.left_panel_width += vertical_separator_response.drag_delta().x;
self.config.left_panel_width = self.config.left_panel_width.clamp(100.0, 500.0);
}
if vertical_separator_response.drag_stopped() {
self.config.save_to_file().ok();
}
let files_size = Vec2::new(ui.available_width(), ui.available_height() - 40.0);
self.draw_files(ui, files_size);
});
}
fn draw_dirs(&mut self, ui: &mut Ui, area_size: Vec2) {
ui.vertical(|ui| {
ui.set_min_width(area_size.x);
ui.set_min_height(area_size.y);
ScrollArea::vertical().id_salt(0).show(ui, |ui| {
ui.set_min_width(area_size.x);
let mut dirs = std::mem::take(&mut self.app_state.dirs);
let mut dir_to_open = None;
dnd(ui, "dnd_directories").show_vec(&mut dirs, |ui, item, handle, _state| {
let path = item;
ui.horizontal(|ui| {
handle.ui(ui, |ui| {
ui.label(ICON_DRAG_INDICATOR.codepoint);
});
let name = path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| path.to_string_lossy().to_string());
let mut dir_button =
Button::new(RichText::new(name.clone()).atom_max_width(area_size.x))
.frame(false);
if let Some(current_dir) = &self.app_state.current_dir
&& current_dir.eq(&*path)
{
dir_button = dir_button.selected(true);
}
let dir_button_response = ui.add(dir_button);
if dir_button_response.clicked() {
dir_to_open = Some(path.clone());
}
let delete_dir_button = Button::new(ICON_DELETE).frame(false);
let delete_dir_button_response =
ui.add_sized([18.0, 18.0], delete_dir_button);
if delete_dir_button_response.clicked() {
self.app_state.dirs_to_remove.insert(path.clone());
}
// Context menu
dir_button_response.context_menu(|ui| {
if ui
.button(format!(
"{} {}",
ICON_OPEN_IN_NEW.codepoint,
t!("gui.context.dirs.open")
))
.clicked()
{
dir_to_open = Some(path.clone());
}
if ui
.button(format!(
"{} {}",
ICON_OPEN_IN_BROWSER.codepoint,
t!("gui.context.dirs.open_in_fm")
))
.clicked()
&& let Err(e) = opener::open(&path)
{
eprintln!("Failed to open file manager: {}", e);
}
ui.separator();
if ui
.button(format!(
"{} {}",
ICON_DELETE.codepoint,
t!("gui.context.dirs.remove")
))
.clicked()
{
self.app_state.dirs_to_remove.insert(path.clone());
}
ui.separator();
ui.label(t!("gui.context.dirs.sort_by"));
let current_order = self
.config
.dirs_settings
.get(path)
.map(|s| s.sort_order)
.unwrap_or_default();
let mut new_order = None;
if ui
.radio(
current_order == SortOrder::AlphabeticalAsc,
t!("gui.sort.alpha_asc"),
)
.clicked()
{
new_order = Some(SortOrder::AlphabeticalAsc);
}
if ui
.radio(
current_order == SortOrder::AlphabeticalDesc,
t!("gui.sort.alpha_desc"),
)
.clicked()
{
new_order = Some(SortOrder::AlphabeticalDesc);
}
if ui
.radio(
current_order == SortOrder::DateModifiedNewest,
t!("gui.sort.date_newest"),
)
.clicked()
{
new_order = Some(SortOrder::DateModifiedNewest);
}
if ui
.radio(
current_order == SortOrder::DateModifiedOldest,
t!("gui.sort.date_oldest"),
)
.clicked()
{
new_order = Some(SortOrder::DateModifiedOldest);
}
if let Some(order) = new_order {
self.config
.dirs_settings
.entry(path.clone())
.or_default()
.sort_order = order;
self.config.save_to_file().ok();
self.app_state.dir_cache.remove(path);
self.open_dir(path);
}
});
});
});
self.app_state.dirs = dirs;
if let Some(path) = dir_to_open {
self.open_dir(&path);
}
ui.horizontal(|ui| {
let add_dirs_button = Button::new(ICON_ADD).frame(false);
let add_dirs_button_response = ui.add_sized([18.0, 18.0], add_dirs_button);
if add_dirs_button_response.clicked() {
self.add_dirs();
}
});
ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
let play_file_button = Button::new(t!("gui.play_file_button"));
let play_file_button_response = ui.add(play_file_button);
if play_file_button_response.clicked() {
self.open_file();
}
});
});
});
}
fn draw_files_search_field(&mut self, ui: &mut Ui) {
ui.horizontal(|ui| {
let search_field_response = ui.add_sized(
[ui.available_width(), 22.0],
TextEdit::singleline(&mut self.app_state.search_query)
.hint_text(t!("gui.search_placeholder")),
);
if self.app_state.force_focus_search {
search_field_response.request_focus();
self.app_state.force_focus_search = false;
}
self.app_state.search_field_id = Some(search_field_response.id);
});
}
fn draw_files_list(&mut self, ui: &mut Ui, area_size: Vec2) {
ScrollArea::vertical().id_salt(1).show(ui, |ui| {
ui.set_min_width(area_size.x);
ui.set_min_height(area_size.y);
ui.vertical(|ui| {
let mut actions = Vec::new();
let files = self.get_filtered_files();
for entry_path in files {
Self::draw_tree_node(
ui,
entry_path,
&self.config,
&mut self.app_state,
&self.audio_player_state,
&mut actions,
);
}
for action in actions {
match action {
FileAction::Play(path, concurrent) => self.play_file(&path, concurrent),
FileAction::StopAndPlay(id, path, concurrent) => {
self.stop(Some(id));
self.play_file(&path, concurrent);
}
FileAction::AssignHotkey(path) => {
self.app_state.assigning_hotkey_for_file = Some(path);
self.app_state.hotkey_capture_active = true;
}
}
}
});
});
}
fn draw_files(&mut self, ui: &mut Ui, area_size: Vec2) {
ui.vertical(|ui| {
self.draw_files_search_field(ui);
ui.separator();
self.draw_files_list(ui, area_size);
});
}
fn draw_tree_node_dir(
ui: &mut Ui,
path: std::path::PathBuf,
config: &GuiConfig,
app_state: &mut AppState,
audio_player_state: &AudioPlayerState,
actions: &mut Vec<FileAction>,
) {
let dir_name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
CollapsingHeader::new(dir_name)
.id_salt(&path)
.show(ui, |ui| {
let children = if let Some(cached) = app_state.dir_cache.get(&path) {
cached.clone()
} else {
let mut read = Vec::new();
if let Ok(entries) = std::fs::read_dir(&path) {
for entry in entries.filter_map(|e| e.ok()) {
let child_path = entry.path();
if !child_path.is_dir()
&& !crate::gui::SUPPORTED_EXTENSIONS.contains(
&child_path
.extension()
.unwrap_or_default()
.to_str()
.unwrap_or_default(),
)
{
continue;
}
read.push(child_path);
}
}
let sort_order = config.get_sort_order(&path);
read.sort_by(|a, b| {
let a_is_dir = a.is_dir();
let b_is_dir = b.is_dir();
if a_is_dir && !b_is_dir {
Ordering::Less
} else if !a_is_dir && b_is_dir {
Ordering::Greater
} else {
sort_order.compare(a, b)
}
});
app_state.dir_cache.insert(path.clone(), read.clone());
read
};
let search_query = app_state.search_query.to_lowercase();
let search_query = search_query.trim();
for child in children {
if !child.is_dir() && !search_query.is_empty() {
let file_name = child
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
if !file_name.to_lowercase().contains(search_query) {
continue;
}
}
Self::draw_tree_node(ui, child, config, app_state, audio_player_state, actions);
}
});
}
fn draw_tree_node_file(
ui: &mut Ui,
path: std::path::PathBuf,
app_state: &mut AppState,
audio_player_state: &AudioPlayerState,
actions: &mut Vec<FileAction>,
) {
let file_name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
ui.horizontal(|ui| {
// Hotkey badge
let mut hotkey_badge = None;
for slot in &app_state.hotkey_config.slots {
if slot.action.name == "play"
&& let Some(file_path_str) = slot.action.args.get("file_path")
&& Path::new(file_path_str) == path
{
if let Some(chord) = &slot.key_chord {
hotkey_badge = Some(format!("[{}]", chord));
} else {
hotkey_badge = Some(format!("[{}]", slot.slot));
}
break;
}
}
if let Some(badge) = &hotkey_badge {
ui.label(
RichText::new(badge)
.small()
.monospace()
.color(Color32::from_rgb(100, 200, 100)),
);
}
let file_button_text = RichText::new(&file_name);
let file_button = Button::new(file_button_text).frame(false).truncate();
let file_button_response = ui.add(file_button);
if file_button_response.clicked() {
ui.input(|i| {
if i.modifiers.ctrl {
actions.push(FileAction::Play(path.clone(), true));
} else if i.modifiers.shift
&& let Some(last_track) = audio_player_state.tracks.last()
{
actions.push(FileAction::StopAndPlay(last_track.id, path.clone(), true));
} else {
actions.push(FileAction::Play(path.clone(), false));
}
});
}
// Context menu
file_button_response.context_menu(|ui| {
if ui
.button(format!(
"{} {}",
ICON_BOLT.codepoint,
t!("gui.context.files.play_solo")
))
.clicked()
{
actions.push(FileAction::Play(path.clone(), false));
}
if ui
.button(format!(
"{} {}",
ICON_ADD.codepoint,
t!("gui.context.files.add_new")
))
.clicked()
{
actions.push(FileAction::Play(path.clone(), true));
}
if ui
.button(format!(
"{} {}",
ICON_SWAP_HORIZ.codepoint,
t!("gui.context.files.replace_last")
))
.clicked()
&& let Some(last_track) = audio_player_state.tracks.last()
{
actions.push(FileAction::StopAndPlay(last_track.id, path.clone(), true));
}
ui.separator();
if ui
.button(format!(
"{} {}",
ICON_OPEN_IN_BROWSER.codepoint,
t!("gui.context.files.show_in_fm")
))
.clicked()
&& let Err(e) = opener::reveal(&path)
{
eprintln!("Failed to open file manager: {}", e);
}
ui.separator();
if ui
.button(format!(
"{} {}",
ICON_KEYBOARD.codepoint,
t!("gui.context.files.asign_hotkey")
))
.clicked()
{
actions.push(FileAction::AssignHotkey(path.clone()));
ui.close();
}
ui.separator();
if ui
.button(format!(
"{} {}",
ICON_FILE_COPY.codepoint,
t!("gui.context.files.copy_cli_command")
))
.clicked()
{
ui.ctx().copy_text(format!(
"pwsp-cli action play \"{}\"",
path.to_string_lossy()
.replace('\\', "\\\\")
.replace('"', "\\\"")
));
}
});
});
}
fn draw_tree_node(
ui: &mut Ui,
path: std::path::PathBuf,
config: &GuiConfig,
app_state: &mut AppState,
audio_player_state: &AudioPlayerState,
actions: &mut Vec<FileAction>,
) {
if path.is_dir() {
Self::draw_tree_node_dir(ui, path, config, app_state, audio_player_state, actions);
} else {
Self::draw_tree_node_file(ui, path, app_state, audio_player_state, actions);
}
}
}
+92
View File
@@ -0,0 +1,92 @@
use crate::gui::SoundpadGui;
use egui::{AtomExt, Button, ComboBox, Label, RichText, Slider, Ui, Vec2};
use egui_material_icons::icons::*;
use rust_i18n::t;
use std::time::Instant;
impl SoundpadGui {
pub fn draw_footer(&mut self, ui: &mut Ui) {
ui.add_space(5.0);
ui.horizontal(|ui| {
self.draw_mic_selection(ui);
self.draw_master_volume(ui);
ui.add_space(ui.available_width() - 18.0 * 2.0 - ui.spacing().item_spacing.x * 2.0);
self.draw_hotkeys_button(ui);
self.draw_settings_button(ui);
});
}
fn draw_mic_selection(&mut self, ui: &mut Ui) {
let mics = &self.audio_player_state.all_inputs_sorted;
let mut selected_input = self.audio_player_state.current_input.to_owned();
let prev_input = selected_input.to_owned();
ComboBox::from_label(t!("gui.choose_mic_select"))
.height(30.0)
.selected_text(
self.audio_player_state
.all_inputs
.get(&selected_input)
.unwrap_or(&String::new()),
)
.show_ui(ui, |ui| {
for (name, nick) in mics {
ui.selectable_value(&mut selected_input, name.clone(), nick);
}
});
if selected_input != prev_input {
self.set_input(selected_input);
}
}
fn draw_master_volume(&mut self, ui: &mut Ui) {
let volume_icon = Self::get_volume_icon(self.audio_player_state.volume);
let volume_label = Label::new(RichText::new(volume_icon).size(18.0));
ui.add_sized([18.0, 18.0], volume_label)
.on_hover_text(format!(
"Master Volume: {:.0}%",
self.audio_player_state.volume * 100.0
));
let should_update_volume = !self.app_state.volume_dragged
&& self
.app_state
.ignore_volume_update_until
.map(|t| Instant::now() > t)
.unwrap_or(true);
if should_update_volume {
self.app_state.volume_slider_value = self.audio_player_state.volume;
}
let volume_slider = Slider::new(&mut self.app_state.volume_slider_value, 0.0..=1.0)
.show_value(false)
.step_by(0.01);
let volume_slider_response = ui.add_sized([150.0, 18.0], volume_slider);
if volume_slider_response.drag_stopped() {
self.app_state.volume_dragged = true;
}
}
fn draw_hotkeys_button(&mut self, ui: &mut Ui) {
let hotkeys_button =
Button::new(ICON_KEYBOARD.atom_size(Vec2::new(18.0, 18.0))).frame(false);
let hotkeys_button_response = ui.add_sized([18.0, 18.0], hotkeys_button);
if hotkeys_button_response.clicked() {
self.app_state.show_hotkeys = true;
}
hotkeys_button_response.on_hover_text("Hotkeys (H)");
}
fn draw_settings_button(&mut self, ui: &mut Ui) {
let settings_button =
Button::new(ICON_SETTINGS.atom_size(Vec2::new(18.0, 18.0))).frame(false);
let settings_button_response = ui.add_sized([18.0, 18.0], settings_button);
if settings_button_response.clicked() {
self.app_state.show_settings = true;
}
}
}
+195
View File
@@ -0,0 +1,195 @@
use crate::gui::SoundpadGui;
use egui::{Button, CollapsingHeader, FontFamily, Label, RichText, Slider, Ui};
use egui_material_icons::icons::*;
use pwsp_lib::types::{audio_player::TrackInfo, gui::AppState};
use pwsp_lib::utils::gui::format_time_pair;
use std::time::Instant;
pub(crate) enum TrackAction {
Pause(u32),
Resume(u32),
ToggleLoop(u32),
Stop(u32),
}
impl SoundpadGui {
pub fn draw_header(&mut self, ui: &mut Ui) {
ui.vertical_centered_justified(|ui| {
if self.audio_player_state.tracks.is_empty() {
ui.label("No tracks playing");
return;
}
let mut action = None;
for track in &self.audio_player_state.tracks {
CollapsingHeader::new(
RichText::new(
track
.path
.file_stem()
.unwrap_or_default()
.to_str()
.unwrap_or_default(),
)
.family(FontFamily::Monospace),
)
.default_open(true)
.show(ui, |ui| {
if let Some(act) = Self::draw_track_control(ui, &mut self.app_state, track) {
action = Some(act);
}
});
ui.separator();
}
if let Some(action) = action {
match action {
TrackAction::Pause(id) => self.pause(Some(id)),
TrackAction::Resume(id) => self.resume(Some(id)),
TrackAction::ToggleLoop(id) => self.toggle_loop(Some(id)),
TrackAction::Stop(id) => self.stop(Some(id)),
}
}
});
}
fn draw_playback_controls(ui: &mut Ui, track: &TrackInfo) -> Option<TrackAction> {
let mut action = None;
let play_button = Button::new(if track.paused {
ICON_PLAY_ARROW
} else {
ICON_PAUSE
})
.corner_radius(15.0);
if ui.add_sized([30.0, 30.0], play_button).clicked() {
action = Some(if track.paused {
TrackAction::Resume(track.id)
} else {
TrackAction::Pause(track.id)
});
}
let loop_button = Button::new(
RichText::new(if track.looped {
ICON_REPEAT_ONE
} else {
ICON_REPEAT
})
.size(18.0),
)
.frame(false);
if ui.add_sized([15.0, 30.0], loop_button).clicked() {
action = Some(TrackAction::ToggleLoop(track.id));
}
action
}
fn draw_position_control(
ui: &mut Ui,
ui_state: &mut pwsp_lib::types::gui::TrackUiState,
track: &TrackInfo,
default_slider_width: f32,
) {
let duration = track.duration.unwrap_or(1.0);
let position_slider = Slider::new(&mut ui_state.position_slider_value, 0.0..=duration)
.show_value(false)
.step_by(0.01);
let position_slider_width = ui.available_width()
- (30.0 * 3.0)
- default_slider_width
- (ui.spacing().item_spacing.x * 6.0);
ui.spacing_mut().slider_width = position_slider_width;
if ui.add_sized([30.0, 30.0], position_slider).drag_stopped() {
ui_state.position_dragged = true;
}
let time_label =
Label::new(RichText::new(format_time_pair(track.position, duration)).monospace());
ui.add_sized([30.0, 30.0], time_label);
}
fn draw_volume_control(
ui: &mut Ui,
ui_state: &mut pwsp_lib::types::gui::TrackUiState,
track: &TrackInfo,
default_slider_width: f32,
) {
let volume_icon = Self::get_volume_icon(track.volume);
let volume_label = Label::new(RichText::new(volume_icon).size(18.0));
ui.add_sized([30.0, 30.0], volume_label)
.on_hover_text(format!("Volume: {:.0}%", track.volume * 100.0));
let volume_slider = Slider::new(&mut ui_state.volume_slider_value, 0.0..=1.0)
.show_value(false)
.step_by(0.01);
ui.spacing_mut().slider_width = default_slider_width - 30.0;
ui.spacing_mut().item_spacing.x = 0.0;
if ui.add_sized([30.0, 30.0], volume_slider).drag_stopped() {
ui_state.volume_dragged = true;
}
}
fn draw_stop_control(ui: &mut Ui, track: &TrackInfo) -> Option<TrackAction> {
let stop_button = Button::new(ICON_CLOSE).frame(false);
if ui.add_sized([30.0, 30.0], stop_button).clicked() {
Some(TrackAction::Stop(track.id))
} else {
None
}
}
fn draw_track_control(
ui: &mut Ui,
app_state: &mut AppState,
track: &TrackInfo,
) -> Option<TrackAction> {
let ui_state = app_state.track_ui_states.entry(track.id).or_default();
let should_update_position = !ui_state.position_dragged
&& ui_state
.ignore_position_update_until
.map(|t| Instant::now() > t)
.unwrap_or(true);
if should_update_position {
ui_state.position_slider_value = track.position;
}
let should_update_volume = !ui_state.volume_dragged
&& ui_state
.ignore_volume_update_until
.map(|t| Instant::now() > t)
.unwrap_or(true);
if should_update_volume {
ui_state.volume_slider_value = track.volume;
}
let mut action = None;
ui.horizontal_top(|ui| {
if let Some(act) = Self::draw_playback_controls(ui, track) {
action = Some(act);
}
let default_slider_width = ui.spacing().slider_width;
Self::draw_position_control(ui, ui_state, track, default_slider_width);
Self::draw_volume_control(ui, ui_state, track, default_slider_width);
if let Some(act) = Self::draw_stop_control(ui, track) {
action = Some(act);
}
});
action
}
}
+32
View File
@@ -0,0 +1,32 @@
use crate::gui::SoundpadGui;
use egui::{Color32, RichText, Ui};
use rust_i18n::t;
impl SoundpadGui {
pub fn draw_hotkey_capture(&mut self, ui: &mut Ui) {
ui.vertical_centered(|ui| {
ui.add_space(ui.available_height() / 3.0);
ui.label(
RichText::new(t!("gui.hotkeys.capture.header"))
.size(18.0)
.color(Color32::YELLOW)
.monospace(),
);
ui.add_space(10.0);
let target = if let Some(slot) = &self.app_state.assigning_hotkey_slot {
format!("{} '{}'", t!("gui.hotkeys.capture.for"), slot)
} else if let Some(path) = &self.app_state.assigning_hotkey_for_file {
format!(
"{} '{}'",
t!("gui.hotkeys.capture.for"),
path.file_name().unwrap_or_default().to_string_lossy()
)
} else {
String::new()
};
ui.label(RichText::new(target).size(16.0));
ui.add_space(10.0);
ui.label(t!("gui.hotkeys.capture.cancel"));
});
}
}
+304
View File
@@ -0,0 +1,304 @@
use crate::gui::SoundpadGui;
use egui::{Button, Color32, Label, RichText, TextEdit, Ui};
use egui_extras::{Column, TableBuilder};
use egui_material_icons::icons::*;
use pwsp_lib::types::socket::Request;
use pwsp_lib::utils::gui::make_request_async;
use rust_i18n::t;
use std::path::Path;
pub(crate) enum HotkeyAction {
Remove(String),
Capture(String),
ClearChord(String),
Play(String),
}
impl SoundpadGui {
pub fn draw_hotkeys(&mut self, ui: &mut Ui) {
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 5.0;
self.draw_hotkeys_header(ui);
ui.separator();
self.draw_hotkeys_search(ui);
ui.separator();
ui.add_space(5.0);
let action = self.draw_hotkeys_table(ui);
if let Some(action) = action {
self.handle_hotkey_action(action);
}
});
}
fn draw_hotkeys_header(&mut self, ui: &mut Ui) {
ui.horizontal(|ui| {
let back_button = Button::new(ICON_ARROW_BACK).frame(false);
if ui.add(back_button).clicked() {
self.app_state.show_hotkeys = false;
}
ui.vertical_centered(|ui| {
ui.label(
RichText::new(t!("gui.hotkeys.header"))
.color(Color32::WHITE)
.monospace(),
);
});
});
}
fn draw_hotkeys_search(&mut self, ui: &mut Ui) {
ui.horizontal(|ui| {
ui.menu_button(
format!(
"{} {}",
ICON_ADD.codepoint,
t!("gui.hotkeys.add_command_select")
),
|ui| {
let mut selected_cmd = None;
if ui.button(t!("gui.hotkeys.toggle_pause_command")).clicked() {
selected_cmd = Some(("cmd_toggle_pause", Request::toggle_pause(None)));
}
if ui.button(t!("gui.hotkeys.stop_playback_command")).clicked() {
selected_cmd = Some(("cmd_stop", Request::stop(None)));
}
if ui
.button(t!("gui.hotkeys.pause_playback_command"))
.clicked()
{
selected_cmd = Some(("cmd_pause", Request::pause(None)));
}
if ui
.button(t!("gui.hotkeys.resume_playback_command"))
.clicked()
{
selected_cmd = Some(("cmd_resume", Request::resume(None)));
}
if ui.button(t!("gui.hotkeys.toggle_loop_command")).clicked() {
selected_cmd = Some(("cmd_toggle_loop", Request::toggle_loop(None)));
}
if let Some((slot_name, req)) = selected_cmd {
make_request_async(Request::set_hotkey_action(slot_name, &req));
self.app_state
.hotkey_config
.set_slot(slot_name.to_string(), req);
self.app_state.assigning_hotkey_slot = Some(slot_name.to_string());
self.app_state.hotkey_capture_active = true;
ui.close();
}
},
);
ui.add_space(10.0);
ui.add(
TextEdit::singleline(&mut self.app_state.hotkey_search_query)
.hint_text(t!("gui.hotkeys.search_placeholder"))
.desired_width(f32::INFINITY),
);
});
}
fn draw_hotkeys_table(&mut self, ui: &mut Ui) -> Option<HotkeyAction> {
let conflicts = self.app_state.hotkey_config.find_conflicts();
let conflict_slots: std::collections::HashSet<&str> =
conflicts.into_iter().flat_map(|(a, b)| [a, b]).collect();
let search = self.app_state.hotkey_search_query.to_lowercase();
let mut action: Option<HotkeyAction> = None;
let slots: Vec<_> = self
.app_state
.hotkey_config
.slots
.iter()
.filter(|s| {
if search.is_empty() {
return true;
}
s.slot.to_lowercase().contains(&search)
|| format!("{:?}", s.action).to_lowercase().contains(&search)
|| s.key_chord
.as_deref()
.unwrap_or("")
.to_lowercase()
.contains(&search)
})
.collect();
let available_width = ui.available_width();
let col_width = (available_width / 4.0).max(80.0);
TableBuilder::new(ui)
.striped(true)
.column(Column::exact(col_width).clip(true)) // Slot
.column(Column::exact(col_width).clip(true)) // Sound / Action name
.column(Column::exact(col_width).clip(true)) // Key Chord
.column(Column::exact(col_width).clip(true)) // Actions
.header(30.0, |mut header| {
header.col(|ui| {
ui.label(
RichText::new(t!("gui.hotkeys.column_slot"))
.strong()
.monospace(),
);
});
header.col(|ui| {
ui.label(
RichText::new(t!("gui.hotkeys.column_sound"))
.strong()
.monospace(),
);
});
header.col(|ui| {
ui.label(
RichText::new(t!("gui.hotkeys.column_key_chord"))
.strong()
.monospace(),
);
});
header.col(|ui| {
ui.label(
RichText::new(t!("gui.hotkeys.column_actions"))
.strong()
.monospace(),
);
});
})
.body(|mut body| {
if slots.is_empty() {
body.row(30.0, |mut row| {
row.col(|_| {});
row.col(|ui| {
ui.label(RichText::new(t!("gui.hotkeys.no_hotkeys_configured")));
});
row.col(|_| {});
row.col(|_| {});
});
return;
}
for slot in &slots {
body.row(30.0, |mut row| {
// Column 1: Slot
row.col(|ui| {
ui.horizontal(|ui| {
if conflict_slots.contains(slot.slot.as_str()) {
ui.label(
RichText::new(ICON_WARNING.codepoint)
.color(Color32::from_rgb(255, 165, 0)),
)
.on_hover_text("Key chord conflict");
}
ui.add(
Label::new(RichText::new(&slot.slot).monospace()).truncate(),
);
});
});
// Column 2: Sound / Action name
row.col(|ui| {
let action_name = match slot.action.name.as_str() {
"play" => {
if let Some(file_path_str) = slot.action.args.get("file_path") {
Path::new(file_path_str)
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string()
} else {
"Play".to_string()
}
}
"toggle_pause" => "Toggle Pause".to_string(),
"pause" => "Pause Playback".to_string(),
"resume" => "Resume Playback".to_string(),
"stop" => "Stop Playback".to_string(),
"toggle_loop" => "Toggle Loop".to_string(),
other => other.to_string(),
};
ui.add(Label::new(RichText::new(action_name).monospace()).truncate());
});
// Column 3: Key Chord
row.col(|ui| {
let chord_text = slot.key_chord.as_deref().unwrap_or("(none)");
ui.add(
Label::new(RichText::new(chord_text).monospace().color(
if slot.key_chord.is_some() {
Color32::from_rgb(100, 200, 100)
} else {
Color32::GRAY
},
))
.truncate(),
);
});
// Column 4: Actions
row.col(|ui| {
ui.horizontal(|ui| {
if ui
.add(Button::new(ICON_DELETE).frame(false))
.on_hover_text("Remove slot")
.clicked()
{
action = Some(HotkeyAction::Remove(slot.slot.clone()));
}
if ui
.add(Button::new(ICON_KEYBOARD).frame(false))
.on_hover_text("Set key chord")
.clicked()
{
action = Some(HotkeyAction::Capture(slot.slot.clone()));
}
if slot.key_chord.is_some()
&& ui
.add(Button::new(ICON_BACKSPACE).frame(false))
.on_hover_text("Clear key chord")
.clicked()
{
action = Some(HotkeyAction::ClearChord(slot.slot.clone()));
}
if ui
.add(Button::new(ICON_PLAY_ARROW).frame(false))
.on_hover_text("Play")
.clicked()
{
action = Some(HotkeyAction::Play(slot.slot.clone()));
}
});
});
});
}
});
action
}
fn handle_hotkey_action(&mut self, action: HotkeyAction) {
match action {
HotkeyAction::Remove(slot) => {
make_request_async(Request::clear_hotkey(&slot));
self.app_state.hotkey_config.remove_slot(&slot);
}
HotkeyAction::Capture(slot) => {
self.app_state.assigning_hotkey_slot = Some(slot);
self.app_state.hotkey_capture_active = true;
}
HotkeyAction::ClearChord(slot) => {
make_request_async(Request::clear_hotkey_key(&slot));
self.app_state.hotkey_config.set_key_chord(&slot, None);
}
HotkeyAction::Play(slot) => {
self.play_hotkey_slot(&slot);
}
}
}
}
+55
View File
@@ -0,0 +1,55 @@
use crate::gui::SoundpadGui;
use egui::Ui;
use egui_material_icons::icons::*;
mod body;
mod footer;
mod header;
mod hotkey_capture;
mod hotkeys;
mod settings;
mod waiting_for_daemon;
impl SoundpadGui {
pub(crate) fn get_volume_icon(volume: f32) -> &'static str {
if volume > 0.7 {
ICON_VOLUME_UP.codepoint
} else if volume <= 0.0 {
ICON_VOLUME_OFF.codepoint
} else if volume < 0.3 {
ICON_VOLUME_MUTE.codepoint
} else {
ICON_VOLUME_DOWN.codepoint
}
}
pub fn draw(&mut self, ui: &mut Ui) {
self.draw_header(ui);
self.draw_body(ui);
ui.separator();
self.draw_footer(ui);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_volume_icon() {
assert_eq!(SoundpadGui::get_volume_icon(0.8), ICON_VOLUME_UP.codepoint);
assert_eq!(SoundpadGui::get_volume_icon(0.0), ICON_VOLUME_OFF.codepoint);
assert_eq!(
SoundpadGui::get_volume_icon(-0.1),
ICON_VOLUME_OFF.codepoint
);
assert_eq!(
SoundpadGui::get_volume_icon(0.2),
ICON_VOLUME_MUTE.codepoint
);
assert_eq!(
SoundpadGui::get_volume_icon(0.5),
ICON_VOLUME_DOWN.codepoint
);
}
}
+99
View File
@@ -0,0 +1,99 @@
use crate::gui::SoundpadGui;
use egui::{Align, Button, Color32, ComboBox, Layout, RichText, Ui};
use egui_material_icons::icons::ICON_ARROW_BACK;
use pwsp_lib::types::config::PreferredTheme;
use rust_i18n::t;
impl SoundpadGui {
pub fn draw_settings(&mut self, ui: &mut Ui) {
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 5.0;
// --------- Back Button and Title ----------
ui.horizontal_top(|ui| {
let back_button = Button::new(ICON_ARROW_BACK).frame(false);
let back_button_response = ui.add(back_button);
if back_button_response.clicked() {
self.app_state.show_settings = false;
}
ui.add_space(ui.available_width() / 2.0 - 40.0);
ui.label(
RichText::new(t!("gui.settings.header"))
.color(Color32::WHITE)
.monospace(),
);
});
// --------------------------------
ui.separator();
ui.add_space(20.0);
// --------- Checkboxes ----------
let save_volume_response = ui.checkbox(
&mut self.config.save_volume,
t!("gui.settings.remember_volume"),
);
let save_input_response =
ui.checkbox(&mut self.config.save_input, t!("gui.settings.remember_mic"));
let save_scale_response = ui.checkbox(
&mut self.config.save_scale_factor,
t!("gui.settings.remember_ui_scale"),
);
let pause_on_exit_response = ui.checkbox(
&mut self.config.pause_on_exit,
t!("gui.settings.pause_on_window_close"),
);
if save_volume_response.changed()
|| save_input_response.changed()
|| save_scale_response.changed()
|| pause_on_exit_response.changed()
{
self.config.save_to_file().ok();
}
// --------------------------------
ui.separator();
// ---------- Selectors -----------
let mut selected_theme = self.config.preferred_theme.clone();
ComboBox::from_label(t!("gui.settings.theme.label"))
.selected_text(match self.config.preferred_theme {
PreferredTheme::System => t!("gui.settings.theme.system"),
PreferredTheme::Light => t!("gui.settings.theme.light"),
PreferredTheme::Dark => t!("gui.settings.theme.dark"),
})
.show_ui(ui, |ui| {
ui.selectable_value(
&mut selected_theme,
PreferredTheme::System,
t!("gui.settings.theme.system"),
);
ui.selectable_value(
&mut selected_theme,
PreferredTheme::Light,
t!("gui.settings.theme.light"),
);
ui.selectable_value(
&mut selected_theme,
PreferredTheme::Dark,
t!("gui.settings.theme.dark"),
);
});
if selected_theme != self.config.preferred_theme {
self.config.preferred_theme = selected_theme;
self.config.save_to_file().ok();
}
// --------------------------------
ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
ui.label(t!(
"gui.settings.version",
version = env!("CARGO_PKG_VERSION")
));
});
});
}
}
@@ -0,0 +1,14 @@
use crate::gui::SoundpadGui;
use egui::{RichText, Ui};
impl SoundpadGui {
pub fn draw_waiting_for_daemon(&mut self, ui: &mut Ui) {
ui.centered_and_justified(|ui| {
ui.label(
RichText::new("Waiting for PWSP daemon to start...")
.size(34.0)
.monospace(),
);
});
}
}
+76
View File
@@ -0,0 +1,76 @@
mod gui;
use anyhow::{Context, Result, anyhow};
use pwsp_lib::utils::gui::ensure_pwsp_audio_dir;
use rust_i18n::i18n;
use std::{
env,
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
i18n!("locales", fallback = "en");
#[tokio::main]
async fn main() -> Result<()> {
let locale = sys_locale::get_locale().unwrap_or(String::from("en-US"));
rust_i18n::set_locale(&locale);
let args = env::args().skip(1).collect::<Vec<String>>();
if let Some(uri) = args.first() {
match download_audio_from_url(uri).await {
Ok(path) => println!("Successfully downloaded to: {:?}", path),
Err(e) => eprintln!("Error downloading file: {}", e),
}
} else {
gui::run().await?;
}
Ok(())
}
async fn download_audio_from_url(uri: &str) -> Result<PathBuf> {
let prefix = "soundpad://sound/url/";
let target_url = uri
.strip_prefix(prefix)
.ok_or_else(|| anyhow!("URI does not containt an expected prefix: {}", prefix))?;
let file_name_encoded = match target_url.split('/').next_back() {
Some(path) => path.to_string(),
None => {
let id = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time went back")
.as_nanos();
format!("downloaded_audio_{}.mp3", id)
}
};
let file_name = percent_encoding::percent_decode_str(&file_name_encoded.clone())
.decode_utf8()
.unwrap_or_else(|_| file_name_encoded.into())
.into_owned();
let normalized_file_name = file_name.replace('\\', "/");
let sanitized_file_name = Path::new(&normalized_file_name)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("downloaded_audio.mp3");
let save_path = ensure_pwsp_audio_dir().join(sanitized_file_name);
let response = reqwest::get(target_url)
.await?
.error_for_status()
.context("Failed to fetch file")?;
let bytes = response.bytes().await?;
tokio::fs::write(&save_path, bytes)
.await
.context("Failed to save file to disk")?;
Ok(save_path)
}
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "pwsp-lib"
version.workspace = true
edition.workspace = true
authors.workspace = true
homepage.workspace = true
repository.workspace = true
license.workspace = true
[dependencies]
tokio.workspace = true
async-trait.workspace = true
serde.workspace = true
serde_json.workspace = true
dirs.workspace = true
itertools.workspace = true
evdev.workspace = true
anyhow.workspace = true
rustix.workspace = true
rodio.workspace = true
pipewire.workspace = true
egui.workspace = true
reqwest.workspace = true
View File
@@ -1,10 +1,11 @@
use crate::{ use crate::{
types::pipewire::{DeviceType, Terminate}, types::pipewire::DeviceType,
utils::{ utils::{
daemon::get_daemon_config, daemon::get_daemon_config,
pipewire::{create_link, get_device, link_player_to_virtual_mic}, pipewire::{PwTerminator, create_link, get_device, link_player_to_virtual_mic},
}, },
}; };
use anyhow::{Result, anyhow};
use rodio::{Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source}; use rodio::{Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{ use std::{
@@ -57,15 +58,15 @@ pub struct AudioPlayer {
pub tracks: HashMap<u32, PlayingSound>, pub tracks: HashMap<u32, PlayingSound>,
pub next_id: u32, pub next_id: u32,
input_link_sender: Option<pipewire::channel::Sender<Terminate>>, input_link_sender: Option<PwTerminator>,
player_link_sender: Option<pipewire::channel::Sender<Terminate>>, player_link_sender: Option<PwTerminator>,
pub input_device_name: Option<String>, pub input_device_name: Option<String>,
pub volume: f32, // Master volume pub volume: f32, // Master volume
} }
impl AudioPlayer { impl AudioPlayer {
pub async fn new() -> Result<Self, Box<dyn Error>> { pub async fn new() -> Result<Self> {
let daemon_config = get_daemon_config(); let daemon_config = get_daemon_config();
let default_volume = daemon_config.default_volume.unwrap_or(1.0); let default_volume = daemon_config.default_volume.unwrap_or(1.0);
@@ -88,13 +89,15 @@ impl AudioPlayer {
Ok(audio_player) Ok(audio_player)
} }
fn ensure_stream(&mut self) -> Result<&MixerDeviceSink, Box<dyn Error>> { fn ensure_stream(&mut self) -> Result<&MixerDeviceSink> {
if self.stream_handle.is_none() { if self.stream_handle.is_none() {
let mut sink = DeviceSinkBuilder::open_default_sink()?; let mut sink = DeviceSinkBuilder::open_default_sink()?;
sink.log_on_drop(false); sink.log_on_drop(false);
self.stream_handle = Some(sink); self.stream_handle = Some(sink);
} }
Ok(self.stream_handle.as_ref().unwrap()) self.stream_handle
.as_ref()
.ok_or_else(|| anyhow!("Failed to initialize stream_handle"))
} }
fn drop_stream(&mut self) { fn drop_stream(&mut self) {
@@ -105,28 +108,20 @@ impl AudioPlayer {
} }
fn abort_link_thread(&mut self) { fn abort_link_thread(&mut self) {
if let Some(sender) = &self.input_link_sender { if self.input_link_sender.is_some() {
if sender.send(Terminate {}).is_ok() {
println!("Sent terminate signal to input link thread"); println!("Sent terminate signal to input link thread");
self.input_link_sender = None; self.input_link_sender = None;
} else {
eprintln!("Failed to send terminate signal to input link thread");
}
} }
} }
fn abort_player_link_thread(&mut self) { fn abort_player_link_thread(&mut self) {
if let Some(sender) = &self.player_link_sender { if self.player_link_sender.is_some() {
if sender.send(Terminate {}).is_ok() {
println!("Sent terminate signal to player link thread"); println!("Sent terminate signal to player link thread");
self.player_link_sender = None; self.player_link_sender = None;
} else {
eprintln!("Failed to send terminate signal to player link thread");
}
} }
} }
async fn link_player(&mut self) -> Result<(), Box<dyn Error>> { async fn link_player(&mut self) -> Result<()> {
if self.player_link_sender.is_some() { if self.player_link_sender.is_some() {
return Ok(()); return Ok(());
} }
@@ -140,7 +135,7 @@ impl AudioPlayer {
} }
} }
async fn link_devices(&mut self) -> Result<(), Box<dyn Error>> { async fn link_devices(&mut self) -> Result<()> {
self.abort_link_thread(); self.abort_link_thread();
let input_device; let input_device;
@@ -184,7 +179,7 @@ impl AudioPlayer {
return Ok(()); return Ok(());
}; };
self.input_link_sender = Some(create_link(output_fl, output_fr, input_fl, input_fr)?); self.input_link_sender = Some(create_link(output_fl, output_fr, input_fl, input_fr).await?);
Ok(()) Ok(())
} }
@@ -289,7 +284,7 @@ impl AudioPlayer {
0.0 0.0
} }
pub fn seek(&mut self, position: f32, id: Option<u32>) -> Result<(), Box<dyn Error>> { pub fn seek(&mut self, position: f32, id: Option<u32>) -> Result<()> {
let position = if position < 0.0 { 0.0 } else { position }; let position = if position < 0.0 { 0.0 } else { position };
if let Some(id) = id { if let Some(id) = id {
@@ -305,22 +300,18 @@ impl AudioPlayer {
Ok(()) Ok(())
} }
pub fn get_duration(&mut self, id: Option<u32>) -> Result<f32, Box<dyn Error>> { pub fn get_duration(&mut self, id: Option<u32>) -> Result<f32> {
if let Some(id) = id { if let Some(id) = id {
if let Some(sound) = self.tracks.get(&id) { if let Some(sound) = self.tracks.get(&id) {
return sound.duration.ok_or("Unknown duration".into()); return sound.duration.ok_or(anyhow!("Unknown duration"));
} }
} else if let Some(sound) = self.tracks.values().last() { } else if let Some(sound) = self.tracks.values().last() {
return sound.duration.ok_or("Unknown duration".into()); return sound.duration.ok_or(anyhow!("Unknown duration"));
} }
Err("No track playing".into()) Err(anyhow!("No track playing"))
} }
pub async fn play( pub async fn play(&mut self, file_path: &Path, concurrent: bool) -> Result<u32> {
&mut self,
file_path: &Path,
concurrent: bool,
) -> Result<u32, Box<dyn Error>> {
let path_buf = file_path.to_path_buf(); let path_buf = file_path.to_path_buf();
let decoder_result = let decoder_result =
@@ -350,7 +341,11 @@ impl AudioPlayer {
let duration = source.total_duration().map(|d| d.as_secs_f32()); let duration = source.total_duration().map(|d| d.as_secs_f32());
let mixer = self.stream_handle.as_ref().unwrap().mixer(); let mixer = self
.stream_handle
.as_ref()
.ok_or_else(|| anyhow::anyhow!("stream_handle is unexpectedly missing"))?
.mixer();
let sink = Player::connect_new(mixer); let sink = Player::connect_new(mixer);
sink.set_volume(self.volume); // Default volume is 1.0 * master sink.set_volume(self.volume); // Default volume is 1.0 * master
sink.append(source); sink.append(source);
@@ -369,7 +364,7 @@ impl AudioPlayer {
Ok(id) Ok(id)
} }
Err(err) => Err(err as Box<dyn Error>), Err(err) => Err(anyhow!(err)),
} }
} }
@@ -472,11 +467,11 @@ impl AudioPlayer {
} }
} }
pub async fn set_current_input_device(&mut self, name: &str) -> Result<(), Box<dyn Error>> { pub async fn set_current_input_device(&mut self, name: &str) -> Result<()> {
let input_device = get_device(name).await?; let input_device = get_device(name).await?;
if input_device.device_type != DeviceType::Input { if input_device.device_type != DeviceType::Input {
return Err("Selected device is not an input device".into()); return Err(anyhow!("Selected device is not an input device"));
} }
self.input_device_name = Some(name.to_string()); self.input_device_name = Some(name.to_string());
+360
View File
@@ -0,0 +1,360 @@
use crate::{
types::socket::Request,
utils::{config::get_config_path, gui::ensure_pwsp_audio_dir},
};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::{
cmp::Ordering,
collections::HashMap,
fs,
path::{Path, PathBuf},
time::SystemTime,
};
#[derive(Default, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DaemonConfig {
pub default_input_name: Option<String>,
pub default_volume: Option<f32>,
}
impl DaemonConfig {
pub fn save_to_file(&self) -> Result<()> {
let config_path = get_config_path()?.join("daemon.json");
if let Some(config_dir) = config_path.parent()
&& !config_path.exists()
{
fs::create_dir_all(config_dir)?;
}
let config_json = serde_json::to_string_pretty(self)?;
fs::write(config_path, config_json.as_bytes())?;
Ok(())
}
pub fn load_from_file() -> Result<DaemonConfig> {
let config_path = get_config_path()?.join("daemon.json");
let bytes = fs::read(config_path)?;
match serde_json::from_slice::<DaemonConfig>(&bytes) {
Ok(config) => Ok(config),
Err(_) => Ok(DaemonConfig::default()),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum PreferredTheme {
System,
Light,
Dark,
}
#[derive(Default, Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum SortOrder {
#[default]
AlphabeticalAsc,
AlphabeticalDesc,
DateModifiedNewest,
DateModifiedOldest,
}
#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct DirSettings {
pub sort_order: SortOrder,
}
#[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: Vec<PathBuf>,
pub dirs_settings: HashMap<PathBuf, DirSettings>,
pub preferred_theme: PreferredTheme,
}
impl SortOrder {
pub fn compare(&self, a: &Path, b: &Path) -> Ordering {
match self {
SortOrder::AlphabeticalAsc => a.cmp(b),
SortOrder::AlphabeticalDesc => b.cmp(a),
SortOrder::DateModifiedNewest => {
let a_time = fs::metadata(a)
.and_then(|m| m.modified())
.unwrap_or(SystemTime::UNIX_EPOCH);
let b_time = fs::metadata(b)
.and_then(|m| m.modified())
.unwrap_or(SystemTime::UNIX_EPOCH);
b_time.cmp(&a_time)
}
SortOrder::DateModifiedOldest => {
let a_time = fs::metadata(a)
.and_then(|m| m.modified())
.unwrap_or(SystemTime::UNIX_EPOCH);
let b_time = fs::metadata(b)
.and_then(|m| m.modified())
.unwrap_or(SystemTime::UNIX_EPOCH);
a_time.cmp(&b_time)
}
}
}
}
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: vec![ensure_pwsp_audio_dir()],
preferred_theme: PreferredTheme::System,
dirs_settings: HashMap::new(),
}
}
}
impl GuiConfig {
pub fn get_sort_order(&self, path: &Path) -> SortOrder {
let mut current = Some(path);
while let Some(p) = current {
if let Some(settings) = self.dirs_settings.get(p) {
return settings.sort_order;
}
current = p.parent();
}
SortOrder::default()
}
pub fn save_to_file(&mut self) -> Result<()> {
let config_path = get_config_path()?.join("gui.json");
if let Some(config_dir) = config_path.parent()
&& !config_path.exists()
{
fs::create_dir_all(config_dir)?;
}
// Do not save scale factor if user does not want to
if !self.save_scale_factor {
self.scale_factor = 1.0;
}
let config_json = serde_json::to_string_pretty(self)?;
fs::write(config_path, config_json.as_bytes())?;
Ok(())
}
pub fn load_from_file() -> Result<GuiConfig> {
let config_path = get_config_path()?.join("gui.json");
let bytes = fs::read(config_path)?;
match serde_json::from_slice::<GuiConfig>(&bytes) {
Ok(config) => Ok(config),
Err(_) => Ok(GuiConfig::default()),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HotkeySlot {
pub slot: String,
pub action: Request,
pub key_chord: Option<String>,
}
#[derive(Default, Clone, Debug, Serialize, Deserialize)]
pub struct HotkeyConfig {
#[serde(default)]
pub slots: Vec<HotkeySlot>,
}
impl HotkeyConfig {
pub fn config_path() -> Result<PathBuf> {
Ok(get_config_path()?.join("hotkeys.json"))
}
pub fn load() -> Result<HotkeyConfig> {
let path = Self::config_path()?;
if !path.exists() {
return Ok(HotkeyConfig::default());
}
let bytes = fs::read(&path)?;
match serde_json::from_slice::<HotkeyConfig>(&bytes) {
Ok(config) => Ok(config),
Err(e) => Err(e.into()),
}
}
pub fn save(&self) -> Result<()> {
let path = Self::config_path()?;
if let Some(dir) = path.parent()
&& !dir.exists()
{
fs::create_dir_all(dir)?;
}
let json = serde_json::to_string_pretty(self)?;
fs::write(path, json.as_bytes())?;
Ok(())
}
pub fn find_slot(&self, slot: &str) -> Option<&HotkeySlot> {
self.slots.iter().find(|s| s.slot == slot)
}
pub fn find_slot_mut(&mut self, slot: &str) -> Option<&mut HotkeySlot> {
self.slots.iter_mut().find(|s| s.slot == slot)
}
pub fn set_slot(&mut self, slot: String, action: Request) {
if let Some(existing) = self.find_slot_mut(&slot) {
existing.action = action;
} else {
self.slots.push(HotkeySlot {
slot,
action,
key_chord: None,
});
}
}
pub fn set_key_chord(&mut self, slot: &str, key_chord: Option<String>) -> bool {
if let Some(existing) = self.find_slot_mut(slot) {
existing.key_chord = key_chord;
true
} else {
false
}
}
pub fn remove_slot(&mut self, slot: &str) -> bool {
let len = self.slots.len();
self.slots.retain(|s| s.slot != slot);
self.slots.len() != len
}
/// Returns pairs of slot names that share the same key chord.
pub fn find_conflicts(&self) -> Vec<(&str, &str)> {
let mut conflicts = vec![];
let mut chord_map: HashMap<&str, Vec<&str>> = HashMap::new();
for s in &self.slots {
if let Some(chord) = &s.key_chord {
chord_map.entry(chord.as_str()).or_default().push(&s.slot);
}
}
for slots in chord_map.values() {
if slots.len() > 1 {
for i in 0..slots.len() {
for j in (i + 1)..slots.len() {
conflicts.push((slots[i], slots[j]));
}
}
}
}
conflicts
}
/// Find which slot(s) have the given key chord.
pub fn slots_for_chord(&self, chord: &str) -> Vec<&HotkeySlot> {
self.slots
.iter()
.filter(|s| s.key_chord.as_deref() == Some(chord))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_gui_config_default() {
let config = GuiConfig::default();
assert_eq!(config.scale_factor, 1.0);
assert_eq!(config.left_panel_width, 280.0);
assert!(!config.save_volume);
assert_eq!(config.preferred_theme, PreferredTheme::System);
}
#[test]
fn test_hotkey_config_operations() {
let mut config = HotkeyConfig::default();
assert!(config.slots.is_empty());
let req = Request::ping();
config.set_slot("slot1".to_string(), req.clone());
assert_eq!(config.slots.len(), 1);
assert_eq!(config.slots[0].slot, "slot1");
assert_eq!(config.slots[0].action, req);
assert!(config.slots[0].key_chord.is_none());
// Test find_slot
let found = config.find_slot("slot1");
assert!(found.is_some());
assert_eq!(found.unwrap().slot, "slot1");
// Test set_key_chord
let updated = config.set_key_chord("slot1", Some("Ctrl+A".to_string()));
assert!(updated);
assert_eq!(config.slots[0].key_chord.as_deref(), Some("Ctrl+A"));
// Test set_key_chord for non-existent slot
let updated_non_existent = config.set_key_chord("slot2", Some("Ctrl+B".to_string()));
assert!(!updated_non_existent);
// Test find_slot_mut
let found_mut = config.find_slot_mut("slot1");
assert!(found_mut.is_some());
found_mut.unwrap().key_chord = Some("Ctrl+B".to_string());
assert_eq!(config.slots[0].key_chord.as_deref(), Some("Ctrl+B"));
// Test slots_for_chord
let slots = config.slots_for_chord("Ctrl+B");
assert_eq!(slots.len(), 1);
assert_eq!(slots[0].slot, "slot1");
let empty_slots = config.slots_for_chord("Ctrl+A");
assert!(empty_slots.is_empty());
// Test remove_slot
let removed = config.remove_slot("slot1");
assert!(removed);
assert!(config.slots.is_empty());
let removed_non_existent = config.remove_slot("slot1");
assert!(!removed_non_existent);
}
#[test]
fn test_hotkey_config_conflicts() {
let mut config = HotkeyConfig::default();
config.set_slot("slot1".to_string(), Request::ping());
config.set_slot("slot2".to_string(), Request::ping());
config.set_slot("slot3".to_string(), Request::ping());
config.set_key_chord("slot1", Some("Ctrl+A".to_string()));
config.set_key_chord("slot2", Some("Ctrl+A".to_string())); // Conflict with slot1
config.set_key_chord("slot3", Some("Ctrl+B".to_string()));
let conflicts = config.find_conflicts();
assert_eq!(conflicts.len(), 1);
assert!(conflicts.contains(&("slot1", "slot2")) || conflicts.contains(&("slot2", "slot1")));
}
}
@@ -43,15 +43,18 @@ pub struct AppState {
pub dirs: Vec<PathBuf>, pub dirs: Vec<PathBuf>,
pub dirs_to_remove: HashSet<PathBuf>, pub dirs_to_remove: HashSet<PathBuf>,
pub selected_file: Option<PathBuf>, pub listed_files: HashSet<PathBuf>,
pub files: HashSet<PathBuf>, pub listed_dirs: HashSet<PathBuf>,
pub dir_cache: HashMap<PathBuf, Vec<PathBuf>>,
pub show_hotkeys: bool, pub show_hotkeys: bool,
pub hotkey_capture_active: bool,
pub hotkey_config: HotkeyConfig, pub hotkey_config: HotkeyConfig,
pub hotkey_search_query: String, pub hotkey_search_query: String,
pub assigning_hotkey_slot: Option<String>, pub assigning_hotkey_slot: Option<String>,
pub assigning_hotkey_for_file: Option<PathBuf>, pub assigning_hotkey_for_file: Option<PathBuf>,
pub hotkey_capture_active: bool,
} }
#[derive(Default, Debug, Clone)] #[derive(Default, Debug, Clone)]
+155
View File
@@ -0,0 +1,155 @@
#[derive(Debug)]
pub struct Terminate {}
#[derive(Debug, Clone, Ord, PartialOrd, Eq, PartialEq)]
pub struct Port {
pub node_id: u32,
pub port_id: u32,
pub name: String,
}
#[derive(Debug, Clone, Ord, PartialOrd, Eq, PartialEq)]
pub enum DeviceType {
Input,
Output,
}
#[derive(Debug, Clone, Ord, PartialOrd, Eq, PartialEq)]
pub struct AudioDevice {
pub id: u32,
pub nick: String,
pub name: String,
pub device_type: DeviceType,
pub input_fl: Option<Port>,
pub input_fr: Option<Port>,
pub output_fl: Option<Port>,
pub output_fr: Option<Port>,
}
impl AudioDevice {
pub fn new(
id: u32,
nick: Option<&str>,
description: Option<&str>,
name: Option<&str>,
device_type: DeviceType,
) -> Self {
Self {
id,
nick: nick
.or(description)
.or(name)
.unwrap_or_default()
.to_string(),
name: name.unwrap_or_default().to_string(),
device_type,
input_fl: None,
input_fr: None,
output_fl: None,
output_fr: None,
}
}
pub fn add_port(&mut self, port: Port) {
match port.name.as_str() {
"input_FL" => self.input_fl = Some(port),
"input_FR" => self.input_fr = Some(port),
"output_FL" | "capture_FL" => self.output_fl = Some(port),
"output_FR" | "capture_FR" => self.output_fr = Some(port),
"input_MONO" => {
self.input_fl = Some(port.clone());
self.input_fr = Some(port);
}
"output_MONO" | "capture_MONO" => {
self.output_fl = Some(port.clone());
self.output_fr = Some(port);
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_audio_device_new() {
let device = AudioDevice::new(
1,
Some("NickName"),
Some("Description"),
Some("Name"),
DeviceType::Input,
);
assert_eq!(device.id, 1);
assert_eq!(device.nick, "NickName");
assert_eq!(device.name, "Name");
assert_eq!(device.device_type, DeviceType::Input);
// Fallbacks for nick
let device_no_nick =
AudioDevice::new(2, None, Some("Desc"), Some("Name"), DeviceType::Output);
assert_eq!(device_no_nick.nick, "Desc");
let device_no_desc = AudioDevice::new(3, None, None, Some("Name"), DeviceType::Output);
assert_eq!(device_no_desc.nick, "Name");
}
#[test]
fn test_audio_device_add_port() {
let mut device = AudioDevice::new(1, None, None, Some("device-name"), DeviceType::Input);
let port_fl = Port {
node_id: 1,
port_id: 10,
name: "input_FL".to_string(),
};
let port_fr = Port {
node_id: 1,
port_id: 11,
name: "input_FR".to_string(),
};
device.add_port(port_fl.clone());
device.add_port(port_fr.clone());
assert_eq!(device.input_fl, Some(port_fl));
assert_eq!(device.input_fr, Some(port_fr));
// Test output ports
let port_out_fl = Port {
node_id: 1,
port_id: 12,
name: "output_FL".to_string(),
};
let port_out_fr = Port {
node_id: 1,
port_id: 13,
name: "capture_FR".to_string(),
};
device.add_port(port_out_fl.clone());
device.add_port(port_out_fr.clone());
assert_eq!(device.output_fl, Some(port_out_fl));
assert_eq!(device.output_fr, Some(port_out_fr));
// Test MONO ports
let mut device_mono =
AudioDevice::new(2, None, None, Some("mono-device"), DeviceType::Input);
let port_mono = Port {
node_id: 2,
port_id: 20,
name: "input_MONO".to_string(),
};
device_mono.add_port(port_mono.clone());
assert_eq!(device_mono.input_fl, Some(port_mono.clone()));
assert_eq!(device_mono.input_fr, Some(port_mono));
}
}
@@ -238,3 +238,88 @@ impl Response {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_response_new() {
let res = Response::new(true, "success-msg");
assert!(res.status);
assert_eq!(res.message, "success-msg");
}
#[test]
fn test_request_constructors() {
// test ping
let req_ping = Request::ping();
assert_eq!(req_ping.name, "ping");
assert!(req_ping.args.is_empty());
// test kill
let req_kill = Request::kill();
assert_eq!(req_kill.name, "kill");
// test pause (with and without id)
let req_pause_no_id = Request::pause(None);
assert_eq!(req_pause_no_id.name, "pause");
assert!(req_pause_no_id.args.is_empty());
let req_pause_with_id = Request::pause(Some(42));
assert_eq!(req_pause_with_id.name, "pause");
assert_eq!(
req_pause_with_id.args.get("id").map(|s| s.as_str()),
Some("42")
);
// test play
let req_play = Request::play("/path/to/sound.mp3", true);
assert_eq!(req_play.name, "play");
assert_eq!(
req_play.args.get("file_path").map(|s| s.as_str()),
Some("/path/to/sound.mp3")
);
assert_eq!(
req_play.args.get("concurrent").map(|s| s.as_str()),
Some("true")
);
// test set_volume
let req_volume = Request::set_volume(0.8, Some(10));
assert_eq!(req_volume.name, "set_volume");
assert_eq!(
req_volume.args.get("volume").map(|s| s.as_str()),
Some("0.8")
);
assert_eq!(req_volume.args.get("id").map(|s| s.as_str()), Some("10"));
// test set_hotkey_action_and_key
let action = Request::ping();
let req_hotkey_action_and_key =
Request::set_hotkey_action_and_key("slot1", &action, "Ctrl+P");
assert_eq!(req_hotkey_action_and_key.name, "set_hotkey_action_and_key");
assert_eq!(
req_hotkey_action_and_key
.args
.get("slot")
.map(|s| s.as_str()),
Some("slot1")
);
assert_eq!(
req_hotkey_action_and_key
.args
.get("key_chord")
.map(|s| s.as_str()),
Some("Ctrl+P")
);
let action_json = serde_json::to_string(&action).unwrap();
assert_eq!(
req_hotkey_action_and_key
.args
.get("action")
.map(|s| s.as_str()),
Some(action_json.as_str())
);
}
}
+7
View File
@@ -0,0 +1,7 @@
use anyhow::{Context, Result};
use std::path::PathBuf;
pub fn get_config_path() -> Result<PathBuf> {
let config_path = dirs::config_dir().context("Failed to obtain config dir")?;
Ok(config_path.join("pwsp"))
}
@@ -4,9 +4,10 @@ use crate::types::{
socket::{MAX_MESSAGE_SIZE, Request, Response}, socket::{MAX_MESSAGE_SIZE, Request, Response},
}; };
use std::os::unix::fs::PermissionsExt; use anyhow::Result;
use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt};
use std::path::PathBuf; use std::path::PathBuf;
use std::{error::Error, fs}; use std::{env, error::Error, fs};
use tokio::{ use tokio::{
io::{AsyncReadExt, AsyncWriteExt}, io::{AsyncReadExt, AsyncWriteExt},
net::UnixStream, net::UnixStream,
@@ -36,29 +37,59 @@ pub fn get_daemon_config() -> DaemonConfig {
}) })
} }
pub fn get_runtime_dir() -> PathBuf { fn get_current_uid() -> u32 {
dirs::runtime_dir().unwrap_or(PathBuf::from("/run/pwsp")) rustix::process::geteuid().as_raw()
} }
pub fn create_runtime_dir() -> Result<(), Box<dyn Error>> { pub fn get_runtime_dir() -> PathBuf {
dirs::runtime_dir().unwrap_or_else(|| {
let uid = get_current_uid();
env::temp_dir().join(format!("pwsp-{}", uid))
})
}
pub fn create_runtime_dir() -> Result<()> {
let runtime_dir = get_runtime_dir(); let runtime_dir = get_runtime_dir();
if !runtime_dir.exists() {
fs::create_dir_all(&runtime_dir)?; if runtime_dir.exists() {
let meta = fs::symlink_metadata(&runtime_dir)?;
if meta.is_symlink() {
return Err(anyhow::anyhow!("Runtime directory is a symlink"));
}
let uid = get_current_uid();
if meta.uid() != uid {
return Err(anyhow::anyhow!(
"Runtime directory is owned by another user"
));
}
if meta.permissions().mode() & 0o777 != 0o700 {
return Err(anyhow::anyhow!(
"Runtime directory has incorrect permissions"
));
}
} else {
fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(&runtime_dir)?;
} }
fs::set_permissions(&runtime_dir, fs::Permissions::from_mode(0o700))?;
Ok(()) Ok(())
} }
pub fn is_daemon_running() -> Result<bool, Box<dyn Error>> { pub fn is_daemon_running() -> Result<bool> {
let lock_file = fs::File::create(get_runtime_dir().join("daemon.lock"))?; let lock_file = fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(get_runtime_dir().join("daemon.lock"))?;
match lock_file.try_lock() { match lock_file.try_lock() {
Ok(_) => Ok(false), Ok(_) => Ok(false),
Err(_) => Ok(true), Err(_) => Ok(true),
} }
} }
pub async fn wait_for_daemon() -> Result<(), Box<dyn Error>> { pub async fn wait_for_daemon() -> Result<()> {
if is_daemon_running()? { if is_daemon_running()? {
return Ok(()); return Ok(());
} }
@@ -199,3 +199,79 @@ pub async fn start_global_hotkey_listener() {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_modifier_state() {
let mut state = ModifierState::new();
assert!(!state.any_active());
// Press Ctrl
state.update(KeyCode::KEY_LEFTCTRL, true);
assert!(state.ctrl);
assert!(state.any_active());
// Release Ctrl
state.update(KeyCode::KEY_LEFTCTRL, false);
assert!(!state.ctrl);
assert!(!state.any_active());
// Press multiple modifiers
state.update(KeyCode::KEY_RIGHTALT, true);
state.update(KeyCode::KEY_LEFTSHIFT, true);
assert!(state.alt);
assert!(state.shift);
assert!(state.any_active());
// Update a non-modifier key
state.update(KeyCode::KEY_A, true);
// Modifier states should remain unchanged
assert!(state.alt);
assert!(state.shift);
}
#[test]
fn test_is_modifier() {
assert!(ModifierState::is_modifier(KeyCode::KEY_LEFTCTRL));
assert!(ModifierState::is_modifier(KeyCode::KEY_RIGHTCTRL));
assert!(ModifierState::is_modifier(KeyCode::KEY_LEFTALT));
assert!(ModifierState::is_modifier(KeyCode::KEY_RIGHTALT));
assert!(ModifierState::is_modifier(KeyCode::KEY_LEFTSHIFT));
assert!(ModifierState::is_modifier(KeyCode::KEY_RIGHTSHIFT));
assert!(ModifierState::is_modifier(KeyCode::KEY_LEFTMETA));
assert!(ModifierState::is_modifier(KeyCode::KEY_RIGHTMETA));
assert!(!ModifierState::is_modifier(KeyCode::KEY_A));
assert!(!ModifierState::is_modifier(KeyCode::KEY_1));
}
#[test]
fn test_evdev_key_name() {
assert_eq!(evdev_key_name(KeyCode::KEY_A), Some("A"));
assert_eq!(evdev_key_name(KeyCode::KEY_Z), Some("Z"));
assert_eq!(evdev_key_name(KeyCode::KEY_0), Some("0"));
assert_eq!(evdev_key_name(KeyCode::KEY_F1), Some("F1"));
assert_eq!(evdev_key_name(KeyCode::KEY_F12), Some("F12"));
assert_eq!(evdev_key_name(KeyCode::KEY_ENTER), None);
}
#[test]
fn test_build_chord() {
let mut modifiers = ModifierState::new();
assert_eq!(build_chord(&modifiers, "A"), "A");
modifiers.ctrl = true;
assert_eq!(build_chord(&modifiers, "A"), "Ctrl+A");
modifiers.shift = true;
assert_eq!(build_chord(&modifiers, "B"), "Ctrl+Shift+B");
modifiers.alt = true;
modifiers.meta = true;
assert_eq!(build_chord(&modifiers, "F5"), "Ctrl+Alt+Shift+Super+F5");
}
}
+28 -3
View File
@@ -7,8 +7,9 @@ use crate::{
}, },
utils::daemon::{is_daemon_running, make_request}, utils::daemon::{is_daemon_running, make_request},
}; };
use anyhow::{Result, anyhow};
use std::{ use std::{
error::Error, path::PathBuf,
sync::{Arc, Mutex}, sync::{Arc, Mutex},
time::Instant, time::Instant,
}; };
@@ -22,11 +23,11 @@ pub fn get_gui_config() -> GuiConfig {
}) })
} }
pub fn make_request_sync(request: Request) -> Result<Response, Box<dyn Error>> { pub fn make_request_sync(request: Request) -> Result<Response> {
tokio::task::block_in_place(|| { tokio::task::block_in_place(|| {
tokio::runtime::Handle::current() tokio::runtime::Handle::current()
.block_on(make_request(request)) .block_on(make_request(request))
.map_err(|e| e as Box<dyn Error>) .map_err(|e| anyhow!(e))
}) })
} }
@@ -36,6 +37,17 @@ pub fn make_request_async(request: Request) {
}); });
} }
pub fn ensure_pwsp_audio_dir() -> PathBuf {
let audio_dir = dirs::audio_dir().unwrap_or("~/Music".into());
let pwsp_audio_dir = audio_dir.join("PWSP");
if !pwsp_audio_dir.exists() {
std::fs::create_dir_all(&pwsp_audio_dir).ok();
}
pwsp_audio_dir
}
pub fn format_time_pair(position: f32, duration: f32) -> String { pub fn format_time_pair(position: f32, duration: f32) -> String {
fn format_time(seconds: f32) -> String { fn format_time(seconds: f32) -> String {
let total_seconds = seconds.round() as u32; let total_seconds = seconds.round() as u32;
@@ -127,3 +139,16 @@ pub fn start_app_state_thread(audio_player_state_shared: Arc<Mutex<AudioPlayerSt
} }
}); });
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_time_pair() {
assert_eq!(format_time_pair(0.0, 0.0), "00:00/00:00");
assert_eq!(format_time_pair(5.4, 10.0), "00:05/00:10");
assert_eq!(format_time_pair(59.9, 125.1), "01:00/02:05");
assert_eq!(format_time_pair(3600.0, 7205.0), "60:00/120:05");
}
}
+407
View File
@@ -0,0 +1,407 @@
use crate::types::pipewire::{AudioDevice, DeviceType, Port};
use anyhow::{Result, anyhow};
use pipewire::{
context::ContextRc, link::Link, main_loop::MainLoopRc, properties::properties,
registry::GlobalObject, spa::utils::dict::DictRef,
};
use std::{cell::RefCell, collections::HashMap, rc::Rc, sync::OnceLock, thread};
use tokio::sync::oneshot;
pub enum PwCommand {
GetDevices {
resp: oneshot::Sender<(Vec<AudioDevice>, Vec<AudioDevice>)>,
},
CreateVirtualMic {
resp: oneshot::Sender<Result<u32, String>>,
},
CreateLink {
output_fl: Port,
output_fr: Port,
input_fl: Port,
input_fr: Port,
resp: oneshot::Sender<Result<(u32, u32), String>>,
},
DestroyObject {
id: u32,
},
}
struct AppState {
input_devices: HashMap<u32, AudioDevice>,
output_devices: HashMap<u32, AudioDevice>,
ports: HashMap<u32, Port>,
proxies: HashMap<u32, Box<dyn std::any::Any>>,
proxy_id_counter: u32,
ready_tx: Option<std::sync::mpsc::Sender<()>>,
}
pub struct PipewireManager {
pub sender: pipewire::channel::Sender<PwCommand>,
}
static MANAGER: OnceLock<PipewireManager> = OnceLock::new();
pub fn get_manager() -> &'static PipewireManager {
MANAGER.get_or_init(|| {
let (pw_sender, pw_receiver) = pipewire::channel::channel::<PwCommand>();
let (ready_tx, ready_rx) = std::sync::mpsc::channel();
thread::spawn(move || {
let (main_loop, context) = setup_pipewire_context().expect("Failed to setup pipewire");
// Leak main_loop and context so their borrows can be 'static
let main_loop = Box::leak(Box::new(main_loop));
let context = Box::leak(Box::new(context));
// Leak to fix lifetime issues since this thread lives forever
let core = Box::leak(Box::new(
context
.connect(None)
.expect("Failed to connect to pipewire"),
));
let registry = Box::leak(Box::new(
core.get_registry().expect("Failed to get registry"),
));
let state = Rc::new(RefCell::new(AppState {
input_devices: HashMap::new(),
output_devices: HashMap::new(),
ports: HashMap::new(),
proxies: HashMap::new(),
proxy_id_counter: 10000,
ready_tx: Some(ready_tx),
}));
let state_for_registry_add = state.clone();
let state_for_registry_remove = state.clone();
let _listener = registry
.add_listener_local()
.global(move |global| {
let (device, port) = parse_global_object(global);
let mut s = state_for_registry_add.borrow_mut();
if let Some(device) = device {
match device.device_type {
DeviceType::Input => {
s.input_devices.insert(device.id, device);
}
DeviceType::Output => {
s.output_devices.insert(device.id, device);
}
}
} else if let Some(port) = port {
let node_id = port.node_id;
s.ports.insert(port.port_id, port.clone());
if let Some(d) = s.input_devices.get_mut(&node_id) {
d.add_port(port.clone());
} else if let Some(d) = s.output_devices.get_mut(&node_id) {
d.add_port(port);
}
}
})
.global_remove(move |id| {
let mut s = state_for_registry_remove.borrow_mut();
s.input_devices.remove(&id);
s.output_devices.remove(&id);
s.ports.retain(|_, port| port.node_id != id);
s.ports.remove(&id);
})
.register();
// sync to signal ready
let state_for_sync = state.clone();
let _core_listener = core
.add_listener_local()
.done(move |id, _seq| {
if id == 0 {
let mut s = state_for_sync.borrow_mut();
if let Some(tx) = s.ready_tx.take() {
let _ = tx.send(());
}
}
})
.register();
let _pending = core.sync(0).expect("sync failed");
let state_for_cmd = state.clone();
let _receiver = pw_receiver.attach(main_loop.loop_(), move |cmd| {
let mut s = state_for_cmd.borrow_mut();
match cmd {
PwCommand::GetDevices { resp } => {
let mut inputs: Vec<AudioDevice> =
s.input_devices.values().cloned().collect();
let mut outputs: Vec<AudioDevice> =
s.output_devices.values().cloned().collect();
inputs.sort_by_key(|a| a.id);
outputs.sort_by_key(|a| a.id);
let _ = resp.send((inputs, outputs));
}
PwCommand::CreateVirtualMic { resp } => {
let props = properties!(
"factory.name" => "support.null-audio-sink",
"node.name" => "pwsp-virtual-mic",
"node.description" => "PWSP Virtual Mic",
"media.class" => "Audio/Source/Virtual",
"audio.position" => "[ FL FR ]",
"audio.channels" => "2",
"object.linger" => "false",
);
match core.create_object::<pipewire::node::Node>("adapter", &props) {
Ok(node) => {
s.proxy_id_counter += 1;
let id = s.proxy_id_counter;
s.proxies.insert(id, Box::new(node));
let _ = resp.send(Ok(id));
}
Err(e) => {
let _ = resp.send(Err(e.to_string()));
}
}
}
PwCommand::CreateLink {
output_fl,
output_fr,
input_fl,
input_fr,
resp,
} => {
let props_fl = properties! {
"link.output.node" => format!("{}", output_fl.node_id).as_str(),
"link.output.port" => format!("{}", output_fl.port_id).as_str(),
"link.input.node" => format!("{}", input_fl.node_id).as_str(),
"link.input.port" => format!("{}", input_fl.port_id).as_str(),
};
let props_fr = properties! {
"link.output.node" => format!("{}", output_fr.node_id).as_str(),
"link.output.port" => format!("{}", output_fr.port_id).as_str(),
"link.input.node" => format!("{}", input_fr.node_id).as_str(),
"link.input.port" => format!("{}", input_fr.port_id).as_str(),
};
let link_fl = match core.create_object::<Link>("link-factory", &props_fl) {
Ok(link) => link,
Err(e) => {
let _ = resp.send(Err(e.to_string()));
return;
}
};
let link_fr = match core.create_object::<Link>("link-factory", &props_fr) {
Ok(link) => link,
Err(e) => {
let _ = resp.send(Err(e.to_string()));
return;
}
};
s.proxy_id_counter += 1;
let id_fl = s.proxy_id_counter;
s.proxies.insert(id_fl, Box::new(link_fl));
s.proxy_id_counter += 1;
let id_fr = s.proxy_id_counter;
s.proxies.insert(id_fr, Box::new(link_fr));
let _ = resp.send(Ok((id_fl, id_fr)));
}
PwCommand::DestroyObject { id } => {
s.proxies.remove(&id);
}
}
});
main_loop.run();
});
// Wait for the pipewire thread to be fully up and processed initial events
let _ = ready_rx.recv();
PipewireManager { sender: pw_sender }
})
}
pub fn setup_pipewire_context() -> Result<(MainLoopRc, ContextRc), String> {
pipewire::init();
let main_loop = MainLoopRc::new(None).map_err(|e| e.to_string())?;
let context = ContextRc::new(&main_loop, None).map_err(|e| e.to_string())?;
Ok((main_loop, context))
}
fn parse_global_object(
global_object: &GlobalObject<&DictRef>,
) -> (Option<AudioDevice>, Option<Port>) {
let props = match global_object.props {
Some(p) => p,
None => return (None, None),
};
if let Some(media_class) = props.get("media.class") {
let node_id = global_object.id;
let node_nick = props.get("node.nick");
let node_name = props.get("node.name");
let node_description = props.get("node.description");
if media_class.starts_with("Audio/Source") {
let input_device = AudioDevice::new(
node_id,
node_nick,
node_description,
node_name,
DeviceType::Input,
);
return (Some(input_device), None);
} else if media_class.starts_with("Stream/Output/Audio") {
let output_device = AudioDevice::new(
node_id,
node_nick,
node_description,
node_name,
DeviceType::Output,
);
return (Some(output_device), None);
}
return (None, None);
}
if props.get("port.direction").is_some()
&& let (Some(node_id), Some(port_id), Some(port_name)) = (
props.get("node.id").and_then(|id| id.parse::<u32>().ok()),
props.get("port.id").and_then(|id| id.parse::<u32>().ok()),
props.get("port.name"),
)
{
let port = Port {
node_id,
port_id,
name: port_name.to_string(),
};
return (None, Some(port));
}
(None, None)
}
pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
let (tx, rx) = oneshot::channel();
let manager = get_manager();
manager
.sender
.send(PwCommand::GetDevices { resp: tx })
.map_err(|_| anyhow!("Failed to send GetDevices to manager"))?;
let res = rx
.await
.map_err(|e| anyhow!("Failed to receive response: {}", e))?;
Ok(res)
}
pub async fn get_device(device_name: &str) -> Result<AudioDevice> {
let (input_devices, output_devices) = get_all_devices().await?;
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(|| anyhow!("Device not found"))
}
pub struct PwTerminator {
ids: Vec<u32>,
}
impl Drop for PwTerminator {
fn drop(&mut self) {
let manager = get_manager();
for id in &self.ids {
let _ = manager.sender.send(PwCommand::DestroyObject { id: *id });
}
}
}
pub async fn create_virtual_mic() -> Result<PwTerminator> {
let (tx, rx) = oneshot::channel();
let manager = get_manager();
manager
.sender
.send(PwCommand::CreateVirtualMic { resp: tx })
.map_err(|_| anyhow!("Failed to send CreateVirtualMic to manager"))?;
let res = rx
.await
.map_err(|e| anyhow!("Failed to receive response: {}", e))?;
let id = res.map_err(|e| anyhow!(e))?;
Ok(PwTerminator { ids: vec![id] })
}
pub async fn link_player_to_virtual_mic() -> Result<PwTerminator> {
let pwsp_daemon_output = match get_device("pwsp-daemon").await {
Ok(device) => device,
Err(_) => {
return Err(anyhow!(
"Could not find alsa_playback.pwsp-daemon device, skipping device linking"
));
}
};
let pwsp_daemon_input = match get_device("pwsp-virtual-mic").await {
Ok(device) => device,
Err(_) => {
return Err(anyhow!(
"Could not find pwsp-virtual-mic device, skipping device linking"
));
}
};
let output_fl = match pwsp_daemon_output.output_fl {
Some(port) => port,
None => return Err(anyhow!("Failed to get pwsp-daemon output_fl")),
};
let output_fr = match pwsp_daemon_output.output_fr {
Some(port) => port,
None => return Err(anyhow!("Failed to get pwsp-daemon output_fr")),
};
let input_fl = match pwsp_daemon_input.input_fl {
Some(port) => port,
None => return Err(anyhow!("Failed to get pwsp-virtual-mic input_fl")),
};
let input_fr = match pwsp_daemon_input.input_fr {
Some(port) => port,
None => return Err(anyhow!("Failed to get pwsp-virtual-mic input_fr")),
};
create_link(output_fl, output_fr, input_fl, input_fr).await
}
pub async fn create_link(
output_fl: Port,
output_fr: Port,
input_fl: Port,
input_fr: Port,
) -> Result<PwTerminator> {
let (tx, rx) = oneshot::channel();
let manager = get_manager();
manager
.sender
.send(PwCommand::CreateLink {
output_fl,
output_fr,
input_fl,
input_fr,
resp: tx,
})
.map_err(|_| anyhow!("Failed to send CreateLink to manager"))?;
let res = rx
.await
.map_err(|e| anyhow!("Failed to receive response: {}", e))?;
let (id_fl, id_fr) = res.map_err(|e| anyhow!(e))?;
Ok(PwTerminator {
ids: vec![id_fl, id_fr],
})
}
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env python3
import sys
import os
import re
import subprocess
import shutil
from datetime import datetime
# Helper to print errors and exit
def fatal(msg):
print(f"Error: {msg}", file=sys.stderr)
sys.exit(1)
# Get the root directory of the project
root_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
os.chdir(root_dir)
# Read current version from Cargo.toml
cargo_toml_path = "Cargo.toml"
if not os.path.exists(cargo_toml_path):
fatal("Cargo.toml not found in the root directory.")
with open(cargo_toml_path, "r", encoding="utf-8") as f:
cargo_toml_content = f.read()
# We want to match version in [workspace.package]
# First, let's find the [workspace.package] section
workspace_package_match = re.search(
r"\[workspace\.package\](.*?)(?=\n\[|$)", cargo_toml_content, re.DOTALL
)
if not workspace_package_match:
fatal("Could not find [workspace.package] section in Cargo.toml.")
workspace_package_sec = workspace_package_match.group(1)
version_match = re.search(r'version\s*=\s*"([^"]+)"', workspace_package_sec)
if not version_match:
fatal("Could not find version in [workspace.package] in Cargo.toml.")
current_version = version_match.group(1)
print(f"Current version detected: {current_version}")
# Get new version
if len(sys.argv) < 2:
try:
new_version = input(f"Enter new version: ").strip()
except (KeyboardInterrupt, EOFError):
print()
sys.exit(0)
if not new_version:
fatal("No version provided.")
else:
new_version = sys.argv[1].strip()
if not re.match(r"^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$", new_version):
fatal(f"Invalid version format: '{new_version}'. Should be like '1.10.1'.")
# 1. Update Cargo.toml
print("Updating Cargo.toml...")
def replace_version_in_workspace(match):
section_content = match.group(1)
updated_section_content = re.sub(
r'(version\s*=\s*")[^"]+(")', rf"\g<1>{new_version}\g<2>", section_content
)
return f"[workspace.package]{updated_section_content}"
new_cargo_toml = re.sub(
r"\[workspace\.package\](.*?)(?=\n\[|$)",
replace_version_in_workspace,
cargo_toml_content,
flags=re.DOTALL,
)
with open(cargo_toml_path, "w", encoding="utf-8") as f:
f.write(new_cargo_toml)
# Update Cargo.lock using cargo
print("Updating Cargo.lock using cargo generate-lockfile...")
try:
subprocess.run(["cargo", "generate-lockfile"], check=True)
except Exception as e:
print(f"Warning: Failed to update Cargo.lock using cargo: {e}")
# 2. Update packages/aur/bin/PKGBUILD
pkgbuild_bin_path = "packages/aur/bin/PKGBUILD"
if os.path.exists(pkgbuild_bin_path):
print(f"Updating {pkgbuild_bin_path}...")
with open(pkgbuild_bin_path, "r", encoding="utf-8") as f:
content = f.read()
content = re.sub(r"pkgver=[^\n]+", f"pkgver={new_version}", content)
content = re.sub(r"pkgrel=[^\n]+", "pkgrel=1", content)
with open(pkgbuild_bin_path, "w", encoding="utf-8") as f:
f.write(content)
# 3. Update packages/aur/standart/PKGBUILD
pkgbuild_std_path = "packages/aur/standart/PKGBUILD"
if os.path.exists(pkgbuild_std_path):
print(f"Updating {pkgbuild_std_path}...")
with open(pkgbuild_std_path, "r", encoding="utf-8") as f:
content = f.read()
content = re.sub(r"pkgver=[^\n]+", f"pkgver={new_version}", content)
content = re.sub(r"pkgrel=[^\n]+", "pkgrel=1", content)
with open(pkgbuild_std_path, "w", encoding="utf-8") as f:
f.write(content)
# Update AUR .SRCINFO files
def update_srcinfo(directory, pkgbuild_path, srcinfo_path):
if not os.path.exists(srcinfo_path):
return
print(f"Updating {srcinfo_path}...")
if shutil.which("makepkg"):
try:
print(f"Running makepkg --printsrcinfo in {directory}...")
result = subprocess.run(
["makepkg", "--printsrcinfo"],
cwd=directory,
capture_output=True,
text=True,
check=True,
)
with open(srcinfo_path, "w", encoding="utf-8") as f:
f.write(result.stdout)
return
except Exception as e:
print(
f"Warning: makepkg failed in {directory}: {e}. Falling back to text replacement."
)
# Text replacement fallback
with open(srcinfo_path, "r", encoding="utf-8") as f:
content = f.read()
content = re.sub(r"pkgver\s*=\s*[^\n]+", f"pkgver = {new_version}", content)
content = re.sub(r"pkgrel\s*=\s*[^\n]+", "pkgrel = 1", content)
content = content.replace(current_version, new_version)
with open(srcinfo_path, "w", encoding="utf-8") as f:
f.write(content)
update_srcinfo("packages/aur/bin", pkgbuild_bin_path, "packages/aur/bin/.SRCINFO")
update_srcinfo(
"packages/aur/standart", pkgbuild_std_path, "packages/aur/standart/.SRCINFO"
)
# 4. Update packages/flatpak/ru.arabianq.pwsp.metainfo.xml
flatpak_xml_path = "packages/flatpak/ru.arabianq.pwsp.metainfo.xml"
if os.path.exists(flatpak_xml_path):
print(f"Updating {flatpak_xml_path}...")
with open(flatpak_xml_path, "r", encoding="utf-8") as f:
content = f.read()
today_str = datetime.today().strftime("%Y-%m-%d")
content = re.sub(
r'<release\s+version="[^"]+"\s+date="[^"]+"\s*/?>',
f'<release version="{new_version}" date="{today_str}" />',
content,
)
with open(flatpak_xml_path, "w", encoding="utf-8") as f:
f.write(content)
# 5. Update packages/rpm/pwsp.spec
rpm_spec_path = "packages/rpm/pwsp.spec"
if os.path.exists(rpm_spec_path):
print(f"Updating {rpm_spec_path}...")
with open(rpm_spec_path, "r", encoding="utf-8") as f:
content = f.read()
content = re.sub(r"Version:\s*[^\n]+", f"Version: {new_version}", content)
with open(rpm_spec_path, "w", encoding="utf-8") as f:
f.write(content)
print(f"Successfully updated all versions to {new_version}!")
-183
View File
@@ -1,183 +0,0 @@
use pwsp::{
types::socket::{MAX_MESSAGE_SIZE, Request, Response},
utils::{
commands::parse_command,
daemon::{
create_runtime_dir, get_audio_player, get_daemon_config, get_runtime_dir,
is_daemon_running,
},
global_hotkeys::start_global_hotkey_listener,
pipewire::create_virtual_mic,
},
};
use std::os::unix::fs::PermissionsExt;
use std::{error::Error, fs, time::Duration};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::UnixListener,
time::sleep,
};
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
create_runtime_dir()?;
if is_daemon_running()? {
return Err("Another instance is already running.".into());
}
get_daemon_config(); // Initialize daemon config
create_virtual_mic()?;
if let Err(err) = get_audio_player().await {
eprintln!("Failed to initialize audio player: {}", err);
} // Initialize audio player
tokio::spawn(async {
start_global_hotkey_listener().await;
});
let runtime_dir = get_runtime_dir();
let lock_file = fs::File::create(runtime_dir.join("daemon.lock"))?;
lock_file.lock()?;
let socket_path = runtime_dir.join("daemon.sock");
if let Err(e) = fs::remove_file(&socket_path)
&& 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?;
tokio::spawn(async move {
// ---------- Read request (start) ----------
let mut len_bytes = [0u8; 4];
if stream.read_exact(&mut len_bytes).await.is_err() {
eprintln!("Failed to read message length from client!");
return;
}
let request_len = u32::from_le_bytes(len_bytes) as usize;
if request_len > MAX_MESSAGE_SIZE {
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 = 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) ----------
let command = parse_command(&request);
let response: Response;
if let Some(command) = command {
response = command.execute().await;
} else {
response = Response::new(false, "Unknown command");
}
// ---------- Generate response (end) ----------
// ---------- Send response (start) ----------
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() {
eprintln!("Failed to write response length to client!");
return;
}
if stream.write_all(&response_data).await.is_err() {
eprintln!("Failed to write response to client!");
return;
}
// ---------- Send response (end) ----------
if response.status && response.message.eq("killed") {
std::process::exit(0);
}
});
}
}
async fn player_loop() {
let mut device_check_counter: u32 = 0;
loop {
let is_idle = match get_audio_player().await {
Ok(player_mutex) => {
let mut audio_player = player_mutex.lock().await;
let check_devices = device_check_counter == 0;
audio_player.update(check_devices).await;
audio_player.tracks.is_empty()
}
Err(_err) => true,
};
if is_idle {
device_check_counter = 0;
sleep(Duration::from_secs(2)).await;
} else {
// Check devices every ~5 seconds (50 * 100ms) while playing
device_check_counter = (device_check_counter + 1) % 50;
sleep(Duration::from_millis(100)).await;
}
}
}
-953
View File
@@ -1,953 +0,0 @@
use crate::gui::SoundpadGui;
use egui::{
Align, AtomExt, Button, CollapsingHeader, Color32, ComboBox, CursorIcon, FontFamily, Label,
Layout, RichText, ScrollArea, Sense, Slider, TextEdit, Ui, Vec2,
};
use egui_dnd::dnd;
use egui_extras::{Column, TableBuilder};
use egui_material_icons::icons::*;
use pwsp::types::socket::Request;
use pwsp::types::{audio_player::TrackInfo, gui::AppState};
use pwsp::utils::gui::{format_time_pair, make_request_async};
use std::{path::Path, time::Instant};
enum TrackAction {
Pause(u32),
Resume(u32),
ToggleLoop(u32),
Stop(u32),
}
enum HotkeyAction {
Remove(String),
Capture(String),
ClearChord(String),
Play(String),
}
impl SoundpadGui {
fn get_volume_icon(volume: f32) -> &'static str {
if volume > 0.7 {
ICON_VOLUME_UP.codepoint
} else if volume <= 0.0 {
ICON_VOLUME_OFF.codepoint
} else if volume < 0.3 {
ICON_VOLUME_MUTE.codepoint
} else {
ICON_VOLUME_DOWN.codepoint
}
}
pub fn draw(&mut self, ui: &mut Ui) {
self.draw_header(ui);
self.draw_body(ui);
ui.separator();
self.draw_footer(ui);
}
pub fn draw_waiting_for_daemon(&mut self, ui: &mut Ui) {
ui.centered_and_justified(|ui| {
ui.label(
RichText::new("Waiting for PWSP daemon to start...")
.size(34.0)
.monospace(),
);
});
}
pub fn draw_hotkey_capture(&mut self, ui: &mut Ui) {
ui.vertical_centered(|ui| {
ui.add_space(ui.available_height() / 3.0);
ui.label(
RichText::new("Press a key combination (e.g. Ctrl+Alt+1)")
.size(18.0)
.color(Color32::YELLOW)
.monospace(),
);
ui.add_space(10.0);
let target = if let Some(slot) = &self.app_state.assigning_hotkey_slot {
format!("for slot '{}'", slot)
} else if let Some(path) = &self.app_state.assigning_hotkey_for_file {
format!(
"for '{}'",
path.file_name().unwrap_or_default().to_string_lossy()
)
} else {
String::new()
};
ui.label(RichText::new(target).size(16.0));
ui.add_space(10.0);
ui.label("Press Escape to cancel");
});
}
pub fn draw_settings(&mut self, ui: &mut Ui) {
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 5.0;
// --------- Back Button and Title ----------
ui.horizontal_top(|ui| {
let back_button = Button::new(ICON_ARROW_BACK).frame(false);
let back_button_response = ui.add(back_button);
if back_button_response.clicked() {
self.app_state.show_settings = false;
}
ui.add_space(ui.available_width() / 2.0 - 40.0);
ui.label(RichText::new("Settings").color(Color32::WHITE).monospace());
});
// --------------------------------
ui.separator();
ui.add_space(20.0);
// --------- Checkboxes ----------
let save_volume_response =
ui.checkbox(&mut self.config.save_volume, "Always remember volume");
let save_input_response =
ui.checkbox(&mut self.config.save_input, "Always remember microphone");
let save_scale_response = ui.checkbox(
&mut self.config.save_scale_factor,
"Always remember UI scale factor",
);
let pause_on_exit_response = ui.checkbox(
&mut self.config.pause_on_exit,
"Pause audio playback when the window is closed",
);
if save_volume_response.changed()
|| save_input_response.changed()
|| save_scale_response.changed()
|| pause_on_exit_response.changed()
{
self.config.save_to_file().ok();
}
// --------------------------------
ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
ui.label(format!("GUI version: {}", env!("CARGO_PKG_VERSION")));
});
});
}
pub fn draw_hotkeys(&mut self, ui: &mut Ui) {
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 5.0;
self.draw_hotkeys_header(ui);
ui.separator();
self.draw_hotkeys_search(ui);
ui.separator();
ui.add_space(5.0);
let action = self.draw_hotkeys_table(ui);
if let Some(action) = action {
self.handle_hotkey_action(action);
}
});
}
fn draw_hotkeys_header(&mut self, ui: &mut Ui) {
ui.horizontal(|ui| {
let back_button = Button::new(ICON_ARROW_BACK).frame(false);
if ui.add(back_button).clicked() {
self.app_state.show_hotkeys = false;
}
ui.vertical_centered(|ui| {
ui.label(RichText::new("Hotkeys").color(Color32::WHITE).monospace());
});
});
}
fn draw_hotkeys_search(&mut self, ui: &mut Ui) {
ui.horizontal(|ui| {
ui.menu_button(format!("{} Add Command", ICON_ADD.codepoint), |ui| {
let mut selected_cmd = None;
if ui.button("Toggle Pause").clicked() {
selected_cmd = Some(("cmd_toggle_pause", Request::toggle_pause(None)));
}
if ui.button("Stop Playback").clicked() {
selected_cmd = Some(("cmd_stop", Request::stop(None)));
}
if ui.button("Pause Playback").clicked() {
selected_cmd = Some(("cmd_pause", Request::pause(None)));
}
if ui.button("Resume Playback").clicked() {
selected_cmd = Some(("cmd_resume", Request::resume(None)));
}
if ui.button("Toggle Loop").clicked() {
selected_cmd = Some(("cmd_toggle_loop", Request::toggle_loop(None)));
}
if let Some((slot_name, req)) = selected_cmd {
make_request_async(Request::set_hotkey_action(slot_name, &req));
self.app_state
.hotkey_config
.set_slot(slot_name.to_string(), req);
self.app_state.assigning_hotkey_slot = Some(slot_name.to_string());
self.app_state.hotkey_capture_active = true;
ui.close();
}
});
ui.add_space(10.0);
ui.add(
TextEdit::singleline(&mut self.app_state.hotkey_search_query)
.hint_text("Search hotkeys...")
.desired_width(f32::INFINITY),
);
});
}
fn draw_hotkeys_table(&mut self, ui: &mut Ui) -> Option<HotkeyAction> {
let conflicts = self.app_state.hotkey_config.find_conflicts();
let conflict_slots: std::collections::HashSet<&str> =
conflicts.into_iter().flat_map(|(a, b)| [a, b]).collect();
let search = self.app_state.hotkey_search_query.to_lowercase();
let mut action: Option<HotkeyAction> = None;
let slots: Vec<_> = self
.app_state
.hotkey_config
.slots
.iter()
.filter(|s| {
if search.is_empty() {
return true;
}
s.slot.to_lowercase().contains(&search)
|| format!("{:?}", s.action).to_lowercase().contains(&search)
|| s.key_chord
.as_deref()
.unwrap_or("")
.to_lowercase()
.contains(&search)
})
.collect();
let available_width = ui.available_width();
let col_width = (available_width / 4.0).max(80.0);
TableBuilder::new(ui)
.striped(true)
.column(Column::exact(col_width).clip(true)) // Slot
.column(Column::exact(col_width).clip(true)) // Sound / Action name
.column(Column::exact(col_width).clip(true)) // Key Chord
.column(Column::exact(col_width).clip(true)) // Actions
.header(30.0, |mut header| {
header.col(|ui| {
ui.label(
RichText::new("Slot")
.strong()
.monospace()
.color(Color32::LIGHT_GRAY),
);
});
header.col(|ui| {
ui.label(
RichText::new("Sound")
.strong()
.monospace()
.color(Color32::LIGHT_GRAY),
);
});
header.col(|ui| {
ui.label(
RichText::new("Key Chord")
.strong()
.monospace()
.color(Color32::LIGHT_GRAY),
);
});
header.col(|ui| {
ui.label(
RichText::new("Actions")
.strong()
.monospace()
.color(Color32::LIGHT_GRAY),
);
});
})
.body(|mut body| {
if slots.is_empty() {
body.row(30.0, |mut row| {
row.col(|_| {});
row.col(|ui| {
ui.label(
RichText::new("No hotkey slots configured.").color(Color32::GRAY),
);
});
row.col(|_| {});
row.col(|_| {});
});
return;
}
for slot in &slots {
body.row(30.0, |mut row| {
// Column 1: Slot
row.col(|ui| {
ui.horizontal(|ui| {
if conflict_slots.contains(slot.slot.as_str()) {
ui.label(
RichText::new(ICON_WARNING.codepoint)
.color(Color32::from_rgb(255, 165, 0)),
)
.on_hover_text("Key chord conflict");
}
ui.add(
Label::new(RichText::new(&slot.slot).monospace()).truncate(),
);
});
});
// Column 2: Sound / Action name
row.col(|ui| {
let action_name = match slot.action.name.as_str() {
"play" => {
if let Some(file_path_str) = slot.action.args.get("file_path") {
Path::new(file_path_str)
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string()
} else {
"Play".to_string()
}
}
"toggle_pause" => "Toggle Pause".to_string(),
"pause" => "Pause Playback".to_string(),
"resume" => "Resume Playback".to_string(),
"stop" => "Stop Playback".to_string(),
"toggle_loop" => "Toggle Loop".to_string(),
other => other.to_string(),
};
ui.add(Label::new(RichText::new(action_name).monospace()).truncate());
});
// Column 3: Key Chord
row.col(|ui| {
let chord_text = slot.key_chord.as_deref().unwrap_or("(none)");
ui.add(
Label::new(RichText::new(chord_text).monospace().color(
if slot.key_chord.is_some() {
Color32::from_rgb(100, 200, 100)
} else {
Color32::GRAY
},
))
.truncate(),
);
});
// Column 4: Actions
row.col(|ui| {
ui.horizontal(|ui| {
if ui
.add(Button::new(ICON_DELETE).frame(false))
.on_hover_text("Remove slot")
.clicked()
{
action = Some(HotkeyAction::Remove(slot.slot.clone()));
}
if ui
.add(Button::new(ICON_KEYBOARD).frame(false))
.on_hover_text("Set key chord")
.clicked()
{
action = Some(HotkeyAction::Capture(slot.slot.clone()));
}
if slot.key_chord.is_some()
&& ui
.add(Button::new(ICON_BACKSPACE).frame(false))
.on_hover_text("Clear key chord")
.clicked()
{
action = Some(HotkeyAction::ClearChord(slot.slot.clone()));
}
if ui
.add(Button::new(ICON_PLAY_ARROW).frame(false))
.on_hover_text("Play")
.clicked()
{
action = Some(HotkeyAction::Play(slot.slot.clone()));
}
});
});
});
}
});
action
}
fn handle_hotkey_action(&mut self, action: HotkeyAction) {
match action {
HotkeyAction::Remove(slot) => {
make_request_async(Request::clear_hotkey(&slot));
self.app_state.hotkey_config.remove_slot(&slot);
}
HotkeyAction::Capture(slot) => {
self.app_state.assigning_hotkey_slot = Some(slot);
self.app_state.hotkey_capture_active = true;
}
HotkeyAction::ClearChord(slot) => {
make_request_async(Request::clear_hotkey_key(&slot));
self.app_state.hotkey_config.set_key_chord(&slot, None);
}
HotkeyAction::Play(slot) => {
self.play_hotkey_slot(&slot);
}
}
}
fn draw_header(&mut self, ui: &mut Ui) {
ui.vertical_centered_justified(|ui| {
if self.audio_player_state.tracks.is_empty() {
ui.label("No tracks playing");
return;
}
let mut action = None;
for track in &self.audio_player_state.tracks {
CollapsingHeader::new(
RichText::new(
track
.path
.file_stem()
.unwrap_or_default()
.to_str()
.unwrap_or_default(),
)
.color(Color32::WHITE)
.family(FontFamily::Monospace),
)
.default_open(true)
.show(ui, |ui| {
if let Some(act) = Self::draw_track_control(ui, &mut self.app_state, track) {
action = Some(act);
}
});
ui.separator();
}
if let Some(action) = action {
match action {
TrackAction::Pause(id) => self.pause(Some(id)),
TrackAction::Resume(id) => self.resume(Some(id)),
TrackAction::ToggleLoop(id) => self.toggle_loop(Some(id)),
TrackAction::Stop(id) => self.stop(Some(id)),
}
}
});
}
fn draw_playback_controls(ui: &mut Ui, track: &TrackInfo) -> Option<TrackAction> {
let mut action = None;
let play_button = Button::new(if track.paused {
ICON_PLAY_ARROW
} else {
ICON_PAUSE
})
.corner_radius(15.0);
if ui.add_sized([30.0, 30.0], play_button).clicked() {
action = Some(if track.paused {
TrackAction::Resume(track.id)
} else {
TrackAction::Pause(track.id)
});
}
let loop_button = Button::new(
RichText::new(if track.looped {
ICON_REPEAT_ONE
} else {
ICON_REPEAT
})
.size(18.0),
)
.frame(false);
if ui.add_sized([15.0, 30.0], loop_button).clicked() {
action = Some(TrackAction::ToggleLoop(track.id));
}
action
}
fn draw_position_control(
ui: &mut Ui,
ui_state: &mut pwsp::types::gui::TrackUiState,
track: &TrackInfo,
default_slider_width: f32,
) {
let duration = track.duration.unwrap_or(1.0);
let position_slider = Slider::new(&mut ui_state.position_slider_value, 0.0..=duration)
.show_value(false)
.step_by(0.01);
let position_slider_width = ui.available_width()
- (30.0 * 3.0)
- default_slider_width
- (ui.spacing().item_spacing.x * 6.0);
ui.spacing_mut().slider_width = position_slider_width;
if ui.add_sized([30.0, 30.0], position_slider).drag_stopped() {
ui_state.position_dragged = true;
}
let time_label =
Label::new(RichText::new(format_time_pair(track.position, duration)).monospace());
ui.add_sized([30.0, 30.0], time_label);
}
fn draw_volume_control(
ui: &mut Ui,
ui_state: &mut pwsp::types::gui::TrackUiState,
track: &TrackInfo,
default_slider_width: f32,
) {
let volume_icon = Self::get_volume_icon(track.volume);
let volume_label = Label::new(RichText::new(volume_icon).size(18.0));
ui.add_sized([30.0, 30.0], volume_label)
.on_hover_text(format!("Volume: {:.0}%", track.volume * 100.0));
let volume_slider = Slider::new(&mut ui_state.volume_slider_value, 0.0..=1.0)
.show_value(false)
.step_by(0.01);
ui.spacing_mut().slider_width = default_slider_width - 30.0;
ui.spacing_mut().item_spacing.x = 0.0;
if ui.add_sized([30.0, 30.0], volume_slider).drag_stopped() {
ui_state.volume_dragged = true;
}
}
fn draw_stop_control(ui: &mut Ui, track: &TrackInfo) -> Option<TrackAction> {
let stop_button = Button::new(ICON_CLOSE).frame(false);
if ui.add_sized([30.0, 30.0], stop_button).clicked() {
Some(TrackAction::Stop(track.id))
} else {
None
}
}
fn draw_track_control(
ui: &mut Ui,
app_state: &mut AppState,
track: &TrackInfo,
) -> Option<TrackAction> {
let ui_state = app_state.track_ui_states.entry(track.id).or_default();
let should_update_position = !ui_state.position_dragged
&& ui_state
.ignore_position_update_until
.map(|t| Instant::now() > t)
.unwrap_or(true);
if should_update_position {
ui_state.position_slider_value = track.position;
}
let should_update_volume = !ui_state.volume_dragged
&& ui_state
.ignore_volume_update_until
.map(|t| Instant::now() > t)
.unwrap_or(true);
if should_update_volume {
ui_state.volume_slider_value = track.volume;
}
let mut action = None;
ui.horizontal_top(|ui| {
if let Some(act) = Self::draw_playback_controls(ui, track) {
action = Some(act);
}
let default_slider_width = ui.spacing().slider_width;
Self::draw_position_control(ui, ui_state, track, default_slider_width);
Self::draw_volume_control(ui, ui_state, track, default_slider_width);
if let Some(act) = Self::draw_stop_control(ui, track) {
action = Some(act);
}
});
action
}
fn draw_body(&mut self, ui: &mut Ui) {
let left_panel_width = self
.config
.left_panel_width
.max(100.0)
.min(ui.available_width() - 100.0);
let dirs_size = Vec2::new(left_panel_width, ui.available_height() - 40.0);
ui.horizontal(|ui| {
self.draw_dirs(ui, dirs_size);
let (rect, response) = ui.allocate_at_least(
Vec2::new(ui.spacing().item_spacing.x, ui.available_height()),
Sense::click_and_drag(),
);
if ui.is_rect_visible(rect) {
let stroke = ui.visuals().widgets.noninteractive.bg_stroke;
ui.painter().vline(rect.center().x, rect.y_range(), stroke);
}
let vertical_separator_response =
response.on_hover_and_drag_cursor(CursorIcon::ResizeHorizontal);
if vertical_separator_response.dragged() {
self.config.left_panel_width += vertical_separator_response.drag_delta().x;
self.config.left_panel_width = self.config.left_panel_width.clamp(100.0, 500.0);
}
if vertical_separator_response.drag_stopped() {
self.config.save_to_file().ok();
}
let files_size = Vec2::new(ui.available_width(), ui.available_height() - 40.0);
self.draw_files(ui, files_size);
});
}
fn draw_dirs(&mut self, ui: &mut Ui, area_size: Vec2) {
ui.vertical(|ui| {
ui.set_min_width(area_size.x);
ui.set_min_height(area_size.y);
ScrollArea::vertical().id_salt(0).show(ui, |ui| {
ui.set_min_width(area_size.x);
let mut dirs = 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 mut dir_button_text = RichText::new(name.clone());
if let Some(current_dir) = &self.app_state.current_dir
&& current_dir.eq(&path)
{
dir_button_text = dir_button_text.color(Color32::WHITE);
}
let dir_button =
Button::new(dir_button_text.atom_max_width(area_size.x)).frame(false);
let dir_button_response = ui.add(dir_button);
if dir_button_response.clicked() {
self.open_dir(&path);
}
let delete_dir_button = Button::new(ICON_DELETE).frame(false);
let delete_dir_button_response =
ui.add_sized([18.0, 18.0], delete_dir_button);
if delete_dir_button_response.clicked() {
self.app_state.dirs_to_remove.insert(path.clone());
}
// Context menu
dir_button_response.context_menu(|ui| {
if ui
.button(format!("{} {}", ICON_OPEN_IN_NEW.codepoint, "Show"))
.clicked()
{
self.open_dir(&path);
}
if ui
.button(format!(
"{} {}",
ICON_OPEN_IN_BROWSER.codepoint, "Open in File Manager"
))
.clicked()
&& let Err(e) = opener::open(&path)
{
eprintln!("Failed to open file manager: {}", e);
}
ui.separator();
if ui
.button(format!("{} {}", ICON_DELETE.codepoint, "Remove"))
.clicked()
{
self.app_state.dirs_to_remove.insert(path.clone());
}
});
});
});
self.app_state.dirs = dirs;
ui.horizontal(|ui| {
let add_dirs_button = Button::new(ICON_ADD).frame(false);
let add_dirs_button_response = ui.add_sized([18.0, 18.0], add_dirs_button);
if add_dirs_button_response.clicked() {
self.add_dirs();
}
});
ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
let play_file_button = Button::new("Play file");
let play_file_button_response = ui.add(play_file_button);
if play_file_button_response.clicked() {
self.open_file();
}
});
});
});
}
fn draw_files(&mut self, ui: &mut Ui, area_size: Vec2) {
ui.vertical(|ui| {
ui.horizontal(|ui| {
let search_field_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();
ScrollArea::vertical().id_salt(1).show(ui, |ui| {
ui.set_min_width(area_size.x);
ui.set_min_height(area_size.y);
ui.vertical(|ui| {
let files = self.get_filtered_files();
for entry_path in files {
let file_name = entry_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
ui.horizontal(|ui| {
// Hotkey badge
let hotkey_badge = self.get_hotkey_badge(&entry_path);
if let Some(badge) = &hotkey_badge {
ui.label(
RichText::new(badge)
.small()
.monospace()
.color(Color32::from_rgb(100, 200, 100)),
);
}
let mut file_button_text = RichText::new(&file_name);
if let Some(current_file) = &self.app_state.selected_file
&& current_file.eq(&entry_path)
{
file_button_text = file_button_text.color(Color32::WHITE);
}
let file_button = Button::new(file_button_text).frame(false).truncate();
let file_button_response = ui.add(file_button);
if file_button_response.clicked() {
ui.input(|i| {
if i.modifiers.ctrl {
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()
&& let Err(e) = opener::reveal(&entry_path)
{
eprintln!("Failed to open file manager: {}", e);
}
ui.separator();
if ui
.button(format!(
"{} {}",
ICON_KEYBOARD.codepoint, "Assign Hotkey"
))
.clicked()
{
self.app_state.assigning_hotkey_for_file =
Some(entry_path.clone());
self.app_state.hotkey_capture_active = true;
ui.close();
}
});
});
}
});
});
});
}
fn get_hotkey_badge(&self, path: &Path) -> Option<String> {
for slot in &self.app_state.hotkey_config.slots {
if slot.action.name == "play"
&& let Some(file_path_str) = slot.action.args.get("file_path")
&& Path::new(file_path_str) == path
{
if let Some(chord) = &slot.key_chord {
return Some(format!("[{}]", chord));
} else {
return Some(format!("[{}]", slot.slot));
}
}
}
None
}
fn draw_footer(&mut self, ui: &mut Ui) {
ui.add_space(5.0);
ui.horizontal(|ui| {
// ---------- Microphone selection ----------
let 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
.get(&selected_input)
.unwrap_or(&String::new()),
)
.show_ui(ui, |ui| {
for (name, nick) in mics {
ui.selectable_value(&mut selected_input, name.clone(), nick);
}
});
if selected_input != prev_input {
self.set_input(selected_input);
}
// --------------------------------
// ---------- 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 * 2.0 - ui.spacing().item_spacing.x * 2.0);
// ---------- Hotkeys button ----------
let hotkeys_button =
Button::new(ICON_KEYBOARD.atom_size(Vec2::new(18.0, 18.0))).frame(false);
let hotkeys_button_response = ui.add_sized([18.0, 18.0], hotkeys_button);
if hotkeys_button_response.clicked() {
self.app_state.show_hotkeys = true;
}
hotkeys_button_response.on_hover_text("Hotkeys (H)");
// --------------------------------
// ---------- Settings button ----------
let settings_button =
Button::new(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;
}
// --------------------------------
});
}
}
-8
View File
@@ -1,8 +0,0 @@
mod gui;
use std::error::Error;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
gui::run().await
}
-205
View File
@@ -1,205 +0,0 @@
use crate::{types::socket::Request, utils::config::get_config_path};
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, error::Error, fs, path::PathBuf};
#[derive(Default, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DaemonConfig {
pub default_input_name: Option<String>,
pub default_volume: Option<f32>,
}
impl DaemonConfig {
pub fn save_to_file(&self) -> Result<(), Box<dyn Error>> {
let config_path = get_config_path()?.join("daemon.json");
if let Some(config_dir) = config_path.parent()
&& !config_path.exists()
{
fs::create_dir_all(config_dir)?;
}
let config_json = serde_json::to_string_pretty(self)?;
fs::write(config_path, config_json.as_bytes())?;
Ok(())
}
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)?;
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: 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: vec![],
}
}
}
impl GuiConfig {
pub fn save_to_file(&mut self) -> Result<(), Box<dyn Error>> {
let config_path = get_config_path()?.join("gui.json");
if let Some(config_dir) = config_path.parent()
&& !config_path.exists()
{
fs::create_dir_all(config_dir)?;
}
// Do not save scale factor if user does not want to
if !self.save_scale_factor {
self.scale_factor = 1.0;
}
let config_json = serde_json::to_string_pretty(self)?;
fs::write(config_path, config_json.as_bytes())?;
Ok(())
}
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)?;
match serde_json::from_slice::<GuiConfig>(&bytes) {
Ok(config) => Ok(config),
Err(_) => Ok(GuiConfig::default()),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HotkeySlot {
pub slot: String,
pub action: Request,
pub key_chord: Option<String>,
}
#[derive(Default, Clone, Debug, Serialize, Deserialize)]
pub struct HotkeyConfig {
#[serde(default)]
pub slots: Vec<HotkeySlot>,
}
impl HotkeyConfig {
pub fn config_path() -> Result<PathBuf, Box<dyn Error>> {
Ok(get_config_path()?.join("hotkeys.json"))
}
pub fn load() -> Result<HotkeyConfig, Box<dyn Error>> {
let path = Self::config_path()?;
if !path.exists() {
return Ok(HotkeyConfig::default());
}
let bytes = fs::read(&path)?;
match serde_json::from_slice::<HotkeyConfig>(&bytes) {
Ok(config) => Ok(config),
Err(e) => Err(e.into()),
}
}
pub fn save(&self) -> Result<(), Box<dyn Error>> {
let path = Self::config_path()?;
if let Some(dir) = path.parent()
&& !dir.exists()
{
fs::create_dir_all(dir)?;
}
let json = serde_json::to_string_pretty(self)?;
fs::write(path, json.as_bytes())?;
Ok(())
}
pub fn find_slot(&self, slot: &str) -> Option<&HotkeySlot> {
self.slots.iter().find(|s| s.slot == slot)
}
pub fn find_slot_mut(&mut self, slot: &str) -> Option<&mut HotkeySlot> {
self.slots.iter_mut().find(|s| s.slot == slot)
}
pub fn set_slot(&mut self, slot: String, action: Request) {
if let Some(existing) = self.find_slot_mut(&slot) {
existing.action = action;
} else {
self.slots.push(HotkeySlot {
slot,
action,
key_chord: None,
});
}
}
pub fn set_key_chord(&mut self, slot: &str, key_chord: Option<String>) -> bool {
if let Some(existing) = self.find_slot_mut(slot) {
existing.key_chord = key_chord;
true
} else {
false
}
}
pub fn remove_slot(&mut self, slot: &str) -> bool {
let len = self.slots.len();
self.slots.retain(|s| s.slot != slot);
self.slots.len() != len
}
/// Returns pairs of slot names that share the same key chord.
pub fn find_conflicts(&self) -> Vec<(&str, &str)> {
let mut conflicts = vec![];
let mut chord_map: HashMap<&str, Vec<&str>> = HashMap::new();
for s in &self.slots {
if let Some(chord) = &s.key_chord {
chord_map.entry(chord.as_str()).or_default().push(&s.slot);
}
}
for slots in chord_map.values() {
if slots.len() > 1 {
for i in 0..slots.len() {
for j in (i + 1)..slots.len() {
conflicts.push((slots[i], slots[j]));
}
}
}
}
conflicts
}
/// Find which slot(s) have the given key chord.
pub fn slots_for_chord(&self, chord: &str) -> Vec<&HotkeySlot> {
self.slots
.iter()
.filter(|s| s.key_chord.as_deref() == Some(chord))
.collect()
}
}
-31
View File
@@ -1,31 +0,0 @@
#[derive(Debug)]
pub struct Terminate {}
#[derive(Debug, Clone, Ord, PartialOrd, Eq, PartialEq)]
pub struct Port {
pub node_id: u32,
pub port_id: u32,
pub name: String,
}
#[derive(Debug, Clone, Ord, PartialOrd, Eq, PartialEq)]
pub enum DeviceType {
Input,
Output,
}
#[derive(Debug, Clone, Ord, PartialOrd, Eq, PartialEq)]
pub struct AudioDevice {
pub id: u32,
pub nick: String,
pub name: String,
pub device_type: DeviceType,
pub input_fl: Option<Port>,
pub input_fr: Option<Port>,
pub output_fl: Option<Port>,
pub output_fr: Option<Port>,
}
-6
View File
@@ -1,6 +0,0 @@
use std::{error::Error, path::PathBuf};
pub fn get_config_path() -> Result<PathBuf, Box<dyn Error>> {
let config_path = dirs::config_dir().expect("Failed to obtain config dir");
Ok(config_path.join("pwsp"))
}
-426
View File
@@ -1,426 +0,0 @@
use crate::types::pipewire::{AudioDevice, DeviceType, Port, Terminate};
use pipewire::{
context::ContextRc, link::Link, main_loop::MainLoopRc, properties::properties,
registry::GlobalObject, spa::utils::dict::DictRef,
};
use std::{collections::HashMap, error::Error, thread};
use tokio::{
sync::mpsc,
time::{Duration, timeout},
};
pub fn setup_pipewire_context() -> Result<(MainLoopRc, ContextRc), String> {
pipewire::init();
let main_loop = MainLoopRc::new(None).map_err(|e| e.to_string())?;
let context = ContextRc::new(&main_loop, None).map_err(|e| e.to_string())?;
Ok((main_loop, context))
}
fn parse_global_object(
global_object: &GlobalObject<&DictRef>,
) -> (Option<AudioDevice>, Option<Port>) {
// Only objects with props can be devices/ports
if let Some(props) = global_object.props {
// Only objects with media.class can be devices
if let Some(media_class) = props.get("media.class") {
let node_id = global_object.id;
let node_nick = props.get("node.nick");
let node_name = props.get("node.name");
let node_description = props.get("node.description");
// Check if the device is an input or output
return if media_class.starts_with("Audio/Source") {
let input_device = AudioDevice {
id: node_id,
nick: node_nick
.unwrap_or(node_description.unwrap_or(node_name.unwrap_or_default()))
.to_string(),
name: node_name.unwrap_or_default().to_string(),
device_type: DeviceType::Input,
input_fl: None,
input_fr: None,
output_fl: None,
output_fr: None,
};
(Some(input_device), None)
} else if media_class.starts_with("Stream/Output/Audio") {
let output_device = AudioDevice {
id: node_id,
nick: node_nick
.unwrap_or(node_description.unwrap_or(node_name.unwrap_or_default()))
.to_string(),
name: node_name.unwrap_or_default().to_string(),
device_type: DeviceType::Output,
input_fl: None,
input_fr: None,
output_fl: None,
output_fr: None,
};
(Some(output_device), None)
} else {
(None, None)
};
// Check if the object is a port
} else if props.get("port.direction").is_some()
&& let (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(),
};
return (None, Some(port));
}
}
(None, None)
}
async fn pw_get_global_objects_thread(
main_sender: mpsc::Sender<(Option<AudioDevice>, Option<Port>)>,
pw_receiver: pipewire::channel::Receiver<Terminate>,
init_sender: std::sync::mpsc::SyncSender<Result<(), String>>,
) {
let (main_loop, context) = match setup_pipewire_context() {
Ok(res) => res,
Err(e) => {
let _ = init_sender.send(Err(e));
return;
}
};
// Stop main loop on Terminate message
let _receiver = pw_receiver.attach(main_loop.loop_(), {
let _main_loop = main_loop.clone();
move |_| _main_loop.quit()
});
let core = match context.connect(None) {
Ok(core) => core,
Err(e) => {
let _ = init_sender.send(Err(format!("Failed to connect to pipewire context: {}", e)));
return;
}
};
let registry = match core.get_registry() {
Ok(registry) => registry,
Err(e) => {
let _ = init_sender.send(Err(format!(
"Failed to get registry from pipewire context: {}",
e
)));
return;
}
};
let _listener = registry
.add_listener_local()
.global(move |global| {
// Try to parse every global object pipewire finds
let (device, port) = parse_global_object(global);
// Send message to the main thread
let sender_clone = main_sender.clone();
tokio::task::spawn(async move {
sender_clone.send((device, port)).await.ok();
});
})
.register();
// Signal successful initialization
if init_sender.send(Ok(())).is_err() {
return;
}
main_loop.run();
}
pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>), Box<dyn Error>> {
// Channels to communicate with pipewire thread
let (main_sender, mut main_receiver) = mpsc::channel(10);
let (pw_sender, pw_receiver) = pipewire::channel::channel();
let (init_sender, init_receiver) = std::sync::mpsc::sync_channel(0);
// Spawn pipewire thread in background
let _pw_thread = tokio::spawn(async move {
pw_get_global_objects_thread(main_sender, pw_receiver, init_sender).await
});
// Wait for initialization to complete
if let Err(e) = init_receiver.recv()? {
return Err(e.into());
}
let mut input_devices: HashMap<u32, AudioDevice> = HashMap::new();
let mut output_devices: HashMap<u32, AudioDevice> = HashMap::new();
let mut ports: Vec<Port> = vec![];
loop {
// If we don't receive a message in 100ms, we can assume that pipewire thread is finished
match timeout(Duration::from_millis(100), main_receiver.recv()).await {
Ok(Some((device, port))) => {
if let Some(device) = device {
match device.device_type {
DeviceType::Input => {
input_devices.insert(device.id, device);
}
DeviceType::Output => {
output_devices.insert(device.id, device);
}
}
} else if let Some(port) = port {
ports.push(port);
}
}
Ok(None) | Err(_) => {
// Pipewire thread is finished and we can collect our devices
let _ = pw_sender.send(Terminate {});
for port in ports {
let node_id = port.node_id;
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),
"output_FL" => input_device.output_fl = Some(port),
"output_FR" => input_device.output_fr = Some(port),
"capture_FL" => input_device.output_fl = Some(port),
"capture_FR" => input_device.output_fr = Some(port),
"input_MONO" => {
input_device.input_fl = Some(port.clone());
input_device.input_fr = Some(port)
}
"capture_MONO" => {
input_device.output_fl = Some(port.clone());
input_device.output_fr = Some(port);
}
_ => {}
}
} else if 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),
"output_FL" => output_device.output_fl = Some(port),
"output_FR" => output_device.output_fr = Some(port),
"capture_FL" => output_device.output_fl = Some(port),
"capture_FR" => output_device.output_fr = Some(port),
"output_MONO" => {
output_device.output_fl = Some(port.clone());
output_device.output_fr = Some(port)
}
"capture_MONO" => {
output_device.output_fl = Some(port.clone());
output_device.output_fr = Some(port)
}
_ => {}
}
}
}
let mut input_devices: Vec<AudioDevice> = input_devices.values().cloned().collect();
let mut output_devices: Vec<AudioDevice> =
output_devices.values().cloned().collect();
input_devices.sort_by_key(|a| a.id);
output_devices.sort_by_key(|a| a.id);
return Ok((input_devices, output_devices));
}
}
}
}
pub async fn get_device(device_name: &str) -> Result<AudioDevice, Box<dyn Error>> {
let (input_devices, output_devices) = get_all_devices().await?;
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 (init_sender, init_receiver) = std::sync::mpsc::sync_channel(0);
let _pw_thread = thread::spawn(move || {
let (main_loop, context) = match setup_pipewire_context() {
Ok(res) => res,
Err(e) => {
let _ = init_sender.send(Err(e));
return;
}
};
let core = match context.connect(None) {
Ok(core) => core,
Err(e) => {
let _ =
init_sender.send(Err(format!("Failed to connect to pipewire context: {}", e)));
return;
}
};
let props = properties!(
"factory.name" => "support.null-audio-sink",
"node.name" => "pwsp-virtual-mic",
"node.description" => "PWSP Virtual Mic",
"media.class" => "Audio/Source/Virtual",
"audio.position" => "[ FL FR ]",
"audio.channels" => "2",
"object.linger" => "false", // Destroy the node on app exit
);
let _node = match core.create_object::<pipewire::node::Node>("adapter", &props) {
Ok(node) => node,
Err(e) => {
let _ = init_sender.send(Err(format!("Failed to create virtual mic: {}", e)));
return;
}
};
let _receiver = pw_receiver.attach(main_loop.loop_(), {
let _main_loop = main_loop.clone();
move |_| _main_loop.quit()
});
println!("Virtual mic created");
if init_sender.send(Ok(())).is_err() {
return;
}
main_loop.run();
});
if let Err(e) = init_receiver.recv()? {
return Err(e.into());
}
Ok(pw_sender)
}
pub async fn link_player_to_virtual_mic()
-> Result<pipewire::channel::Sender<Terminate>, Box<dyn Error>> {
let pwsp_daemon_output = match get_device("pwsp-daemon").await {
Ok(device) => device,
Err(_) => {
return Err(
"Could not find alsa_playback.pwsp-daemon device, skipping device linking".into(),
);
}
};
let pwsp_daemon_input = match get_device("pwsp-virtual-mic").await {
Ok(device) => device,
Err(_) => {
return Err("Could not find pwsp-virtual-mic device, skipping device linking".into());
}
};
let output_fl = match pwsp_daemon_output.output_fl {
Some(port) => port,
None => return Err("Failed to get pwsp-daemon output_fl".into()),
};
let output_fr = match pwsp_daemon_output.output_fr {
Some(port) => port,
None => return Err("Failed to get pwsp-daemon output_fr".into()),
};
let input_fl = match pwsp_daemon_input.input_fl {
Some(port) => port,
None => return Err("Failed to get pwsp-virtual-mic input_fl".into()),
};
let input_fr = match pwsp_daemon_input.input_fr {
Some(port) => port,
None => return Err("Failed to get pwsp-virtual-mic input_fr".into()),
};
create_link(output_fl, output_fr, input_fl, input_fr)
}
pub fn create_link(
output_fl: Port,
output_fr: Port,
input_fl: Port,
input_fr: Port,
) -> Result<pipewire::channel::Sender<Terminate>, Box<dyn Error>> {
let (pw_sender, pw_receiver) = pipewire::channel::channel::<Terminate>();
let (init_sender, init_receiver) = std::sync::mpsc::sync_channel(0);
let _pw_thread = thread::spawn(move || {
let (main_loop, context) = match setup_pipewire_context() {
Ok(res) => res,
Err(e) => {
let _ = init_sender.send(Err(e));
return;
}
};
let core = match context.connect(None) {
Ok(core) => core,
Err(e) => {
let _ =
init_sender.send(Err(format!("Failed to connect to pipewire context: {}", e)));
return;
}
};
let props_fl = properties! {
"link.output.node" => format!("{}", output_fl.node_id).as_str(),
"link.output.port" => format!("{}", output_fl.port_id).as_str(),
"link.input.node" => format!("{}", input_fl.node_id).as_str(),
"link.input.port" => format!("{}", input_fl.port_id).as_str(),
};
let props_fr = properties! {
"link.output.node" => format!("{}", output_fr.node_id).as_str(),
"link.output.port" => format!("{}", output_fr.port_id).as_str(),
"link.input.node" => format!("{}", input_fr.node_id).as_str(),
"link.input.port" => format!("{}", input_fr.port_id).as_str(),
};
let _link_fl = match core.create_object::<Link>("link-factory", &props_fl) {
Ok(link) => link,
Err(e) => {
let _ = init_sender.send(Err(format!("Failed to create link FL: {}", e)));
return;
}
};
let _link_fr = match core.create_object::<Link>("link-factory", &props_fr) {
Ok(link) => link,
Err(e) => {
let _ = init_sender.send(Err(format!("Failed to create link FR: {}", e)));
return;
}
};
let _receiver = pw_receiver.attach(main_loop.loop_(), {
let _main_loop = main_loop.clone();
move |_| _main_loop.quit()
});
println!(
"Link created: FL: {}-{} FR: {}-{}",
output_fl.node_id, input_fl.node_id, output_fr.node_id, input_fr.node_id
);
if init_sender.send(Ok(())).is_err() {
return;
}
main_loop.run();
});
if let Err(e) = init_receiver.recv()? {
return Err(e.into());
}
Ok(pw_sender)
}