Compare commits

...

81 Commits

Author SHA1 Message Date
dependabot[bot] 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 Aleksandr 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 Aleksandr 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 Aleksandr e91465365d feat: better testing (#131)
* add tests

* update github actions to include testing step

* optimization
2026-06-02 21:37:22 +03:00
Tarasov Aleksandr 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 Aleksandr 6841d8d1c3 refactor(gui): break down monolithic draw_footer into helper methods (#127)
Split the long continuous block in `draw_footer` into smaller,
modular methods (`draw_mic_selection`, `draw_master_volume`,
`draw_hotkeys_button`, `draw_settings_button`) for better
readability and maintainability.

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

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

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

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

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

* fix: flatpak

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

* feat(gui): implemented theme switching

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

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

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

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

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

* deps: update cargo-sources.json

---------

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

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

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

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

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

* deps: update cargo-sources.json

---------

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

Bumps pipewire from 0.9.2 to 0.10.0.

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

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

* deps: update cargo-sources.json

---------

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

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

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

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

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

* 🔒 Fix insecure fallback directory and secure file creation

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

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

* small refactor

---------

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

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

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

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

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

* add Russian locale

* add missing entries

* add Spanish locale

* add French locale

* add Chinese locale

* add Arabic locale

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

This reverts commit 911417af40.

* deps: update cargo-sources.json
2026-05-15 14:55:52 +03:00
Tarasov Aleksandr 3d4b59761b packages(rpm): add required dependencies 2026-05-13 23:47:25 +03:00
64 changed files with 6084 additions and 3009 deletions
+93 -16
View File
@@ -13,7 +13,15 @@ on:
jobs:
linux-build:
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:
- name: Install apt deps (jq/zip + dev-libs)
@@ -33,29 +41,38 @@ jobs:
fetch-depth: 0
- name: Setup Rust toolchain
uses: actions-rs/toolchain@v1
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: 1.94.1
toolchain: 1.96.0
- name: Run tests
run: cargo test --locked
- name: Build all binaries (debug-speed compilation into target/release)
env:
CARGO_PROFILE_RELEASE_OPT_LEVEL: 0
CARGO_PROFILE_RELEASE_DEBUG: "true"
CARGO_PROFILE_RELEASE_STRIP: "false"
CARGO_PROFILE_RELEASE_LTO: "false"
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: 256
run: cargo build --release --locked
- name: Extract all binary names
id: cargo-meta
run: |
set -euo pipefail
BIN_NAMES=$(cargo metadata --no-deps --format-version 1 \
| jq -r '.packages[0].targets[] | select(.kind[] | contains("bin")) | .name')
| jq -r '.packages[].targets[] | select(.kind[] | contains("bin")) | .name')
echo "bin_names<<EOF" >> $GITHUB_OUTPUT
echo "$BIN_NAMES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Build all release binaries
run: cargo build --release --locked
- name: Package all binaries into one archive
shell: bash
run: |
set -euo pipefail
COMMIT_SHA="${{ github.sha }}"
ARCHIVE_NAME="pwsp-${COMMIT_SHA}-linux-x64.zip"
ARCHIVE_NAME="pwsp-${COMMIT_SHA}-linux-${{ matrix.arch }}.zip"
echo "Creating archive: $ARCHIVE_NAME"
FILES=()
@@ -82,28 +99,87 @@ jobs:
- name: Upload archive as artifact
uses: actions/upload-artifact@v4
with:
name: archive
name: archive-${{ matrix.arch }}
path: pwsp-*.zip
retention-days: 7
- name: Install cargo-deb and create .deb
deb-build:
strategy:
matrix:
include:
- arch: x64
runner: ubuntu-latest
- arch: arm64
runner: ubuntu-24.04-arm
fail-fast: false
runs-on: ${{ matrix.runner }}
steps:
- name: Install apt deps (dev-libs)
run: |
sudo apt-get update
sudo apt-get install -y \
libpipewire-0.3-dev \
libclang-dev \
libasound2-dev \
libdbus-1-dev \
pkg-config
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: 1.96.0
- name: Build all binaries (debug-speed compilation into target/release)
env:
CARGO_PROFILE_RELEASE_OPT_LEVEL: 0
CARGO_PROFILE_RELEASE_DEBUG: "true"
CARGO_PROFILE_RELEASE_STRIP: "false"
CARGO_PROFILE_RELEASE_LTO: "false"
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: 256
run: cargo build --release --locked
- name: Cache cargo-deb
id: cache-cargo-deb
uses: actions/cache@v4
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 (debug binaries from target/release)
shell: bash
run: |
set -euo pipefail
cargo install --locked cargo-deb
export PATH="$HOME/.cargo/bin:$PATH"
cargo-deb
cargo-deb -p pwsp-gui --no-build --no-strip
- name: Upload .deb(s) as artifacts
uses: actions/upload-artifact@v4
with:
name: deb-packages
name: deb-packages-${{ matrix.arch }}
path: target/debian/*.deb
retention-days: 7
flatpak-build:
runs-on: ubuntu-latest
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:
image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-25.08
options: --privileged
@@ -114,8 +190,9 @@ jobs:
- name: Build Flatpak
uses: flatpak/flatpak-github-actions/flatpak-builder@v6
with:
bundle: ru.arabianq.pwsp.flatpak
bundle: ru.arabianq.pwsp_${{ matrix.arch }}.flatpak
manifest-path: packages/flatpak/ru.arabianq.pwsp.yaml
cache: true
branch: master
build-bundle: true
arch: ${{ matrix.arch }}
+61 -6
View File
@@ -2,9 +2,9 @@ name: Flatter
on:
push:
branches: [ main, master ]
branches: [main, master]
release:
types: [ published ]
types: [published]
workflow_dispatch:
inputs:
tag_name:
@@ -21,8 +21,8 @@ on:
default: "stable"
jobs:
flatter:
name: Flatter
flatter-x64:
name: Flatter (x86_64)
runs-on: ubuntu-latest
permissions:
contents: read
@@ -60,7 +60,61 @@ jobs:
echo "default-branch: ${{ steps.set_branch.outputs.branch }}" >> packages/flatpak/ru.arabianq.pwsp.yaml
- name: Install SDK Extensions
run: flatpak install -y flathub org.freedesktop.Sdk.Extension.rust-stable//25.08
run:
flatpak install -y flathub org.freedesktop.Sdk.Extension.rust-stable//25.08
org.freedesktop.Sdk.Extension.llvm20//25.08
- name: Build Flatpak
uses: andyholmes/flatter@main
with:
files: packages/flatpak/ru.arabianq.pwsp.yaml
gpg-sign: ${{ steps.gpg.outputs.fingerprint }}
upload-bundles: false
upload-pages-artifact: false
arch: x86_64
flatter-arm64:
name: Flatter (aarch64)
needs: flatter-x64
runs-on: ubuntu-24.04-arm
permissions:
contents: read
container:
image: ghcr.io/andyholmes/flatter/freedesktop:25.08
options: --privileged
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ inputs.tag_name || github.ref }}
- name: Setup GPG
id: gpg
uses: crazy-max/ghaction-import-gpg@v6
with:
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.GPG_PASSPHRASE }}
- name: Set Default Branch
id: set_branch
run: |
if [ "${{ github.event_name }}" == "release" ]; then
echo "branch=stable" >> $GITHUB_OUTPUT
elif [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
echo "branch=${{ inputs.build_branch }}" >> $GITHUB_OUTPUT
else
echo "branch=nightly" >> $GITHUB_OUTPUT
fi
- name: Modify Manifest
run: |
echo "branch: ${{ steps.set_branch.outputs.branch }}" >> packages/flatpak/ru.arabianq.pwsp.yaml
echo "default-branch: ${{ steps.set_branch.outputs.branch }}" >> packages/flatpak/ru.arabianq.pwsp.yaml
- name: Install SDK Extensions
run:
flatpak install -y flathub org.freedesktop.Sdk.Extension.rust-stable//25.08
org.freedesktop.Sdk.Extension.llvm20//25.08
- name: Build Flatpak
@@ -70,11 +124,12 @@ jobs:
gpg-sign: ${{ steps.gpg.outputs.fingerprint }}
upload-bundles: false
upload-pages-artifact: true
arch: aarch64
deploy:
name: Deploy to GitHub Pages
runs-on: ubuntu-latest
needs: flatter
needs: flatter-arm64
permissions:
pages: write
id-token: write
+117 -34
View File
@@ -64,7 +64,15 @@ jobs:
linux-release:
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:
- name: Install apt deps (jq/zip + dev-libs)
@@ -85,16 +93,16 @@ jobs:
fetch-depth: 0
- name: Setup Rust toolchain
uses: actions-rs/toolchain@v1
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: 1.94.1
toolchain: 1.96.0
- name: Extract all binary names
id: cargo-meta
run: |
set -euo pipefail
BIN_NAMES=$(cargo metadata --no-deps --format-version 1 \
| jq -r '.packages[0].targets[] | select(.kind[] | contains("bin")) | .name')
| jq -r '.packages[].targets[] | select(.kind[] | contains("bin")) | .name')
echo "bin_names<<EOF" >> $GITHUB_OUTPUT
echo "$BIN_NAMES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
@@ -107,7 +115,7 @@ jobs:
run: |
set -euo pipefail
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=()
@@ -131,48 +139,123 @@ jobs:
zip -j "$ARCHIVE_NAME" "${FILES[@]}"
- name: Upload release archive
uses: softprops/action-gh-release@v2
- name: Upload zip archive
uses: actions/upload-artifact@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
tag_name: ${{ needs.prepare.outputs.tag }}
files: |
pwsp-*.zip
name: zip-archive-${{ matrix.arch }}
path: pwsp-*.zip
retention-days: 1
- name: Install cargo-deb and create .deb
deb-release:
needs: prepare
strategy:
matrix:
include:
- arch: x64
runner: ubuntu-latest
- arch: arm64
runner: ubuntu-24.04-arm
fail-fast: false
runs-on: ${{ matrix.runner }}
steps:
- name: Install apt deps (dev-libs)
run: |
sudo apt-get update
sudo apt-get install -y \
libpipewire-0.3-dev \
libclang-dev \
libasound2-dev \
libdbus-1-dev \
pkg-config
- name: Checkout code at tag
uses: actions/checkout@v4
with:
ref: ${{ needs.prepare.outputs.tag }}
fetch-depth: 0
- name: Setup Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: 1.96.0
- name: Build all release binaries
run: cargo build --release --locked
- name: Cache cargo-deb
id: cache-cargo-deb
uses: actions/cache@v4
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 (release binaries)
shell: bash
run: |
set -euo pipefail
cargo install --locked cargo-deb
export PATH="$HOME/.cargo/bin:$PATH"
cargo-deb -p pwsp-gui --no-build
cargo-deb
- name: Upload deb package
uses: actions/upload-artifact@v4
with:
name: deb-package-${{ matrix.arch }}
path: target/debian/*.deb
retention-days: 1
- name: Upload .deb(s) to release
publish-release:
needs: [prepare, linux-release, deb-release]
runs-on: ubuntu-latest
steps:
- name: Download zip archive (x64)
uses: actions/download-artifact@v4
with:
name: zip-archive-x64
path: ./dist
- name: Download zip archive (arm64)
uses: actions/download-artifact@v4
with:
name: zip-archive-arm64
path: ./dist
- name: Download deb package (x64)
uses: actions/download-artifact@v4
with:
name: deb-package-x64
path: ./dist
- name: Download deb package (arm64)
uses: actions/download-artifact@v4
with:
name: deb-package-arm64
path: ./dist
- name: Upload artifacts to Release
uses: softprops/action-gh-release@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
tag_name: ${{ needs.prepare.outputs.tag }}
files: |
target/debian/*.deb
./dist/pwsp-*.zip
./dist/*.deb
flatpak-release:
needs: prepare
runs-on: ubuntu-latest
container:
image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-25.08
options: --privileged
- name: Install copr-cli
run: pip install copr-cli
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.prepare.outputs.tag }}
- name: Trigger Copr Build
env:
COPR_CONFIG: ${{ secrets.COPR_CONFIG }}
run: |
mkdir -p ~/.config
echo "$COPR_CONFIG" > ~/.config/copr
copr-cli buildscm --clone-url https://github.com/arabianq/pipewire-soundpad.git \
--commit ${{ needs.prepare.outputs.tag }} \
--spec packages/rpm/pwsp.spec \
arabianq/pwsp
- name: Build Flatpak
uses: flatpak/flatpak-github-actions/flatpak-builder@v6
with:
bundle: ru.arabianq.pwsp.flatpak
manifest-path: packages/flatpak/ru.arabianq.pwsp.yaml
cache: true
branch: master
build-bundle: true
Generated
+1040 -529
View File
File diff suppressed because it is too large Load Diff
+33 -58
View File
@@ -1,17 +1,28 @@
[package]
name = "pwsp"
version = "1.8.0"
[workspace]
members = [
"pwsp-lib",
"pwsp-daemon",
"pwsp-cli",
"pwsp-gui"
]
resolver = "2"
[workspace.package]
version = "1.12.0"
edition = "2024"
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"
repository = "https://github.com/arabianq/pipewire-soundpad"
license = "MIT"
description = "PWSP lets you play audio files through your microphone. Has both CLI and GUI clients."
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"] }
async-trait = "0.1.89"
@@ -26,21 +37,27 @@ clap = { version = "4.6.1", default-features = false, features = [
"error-context",
"derive",
] }
dirs = "6.0.0"
itertools = "0.14.0"
rodio = { git = "https://github.com/arabianq/rodio.git", rev = "1a08f281c352622bd82b87b8731585245802d9cf", default-features = false, features = [
"symphonia-all",
"symphonia-libopus",
"playback",
] }
pipewire = "0.9.2"
evdev = { version = "0.13.2", features = ["tokio"] }
rfd = { version = "0.17.2", default-features = false, features = [
"xdg-portal",
] }
opener = { version = "0.8.4", features = ["reveal"] }
system-fonts = "0.1.0"
system-fonts = "0.1.1"
anyhow = "1.0.102"
rustix = { version = "1.1.4", features = ["process"] }
rust-i18n = "4.0.0"
sys-locale = "0.3.2"
rodio = { git = "https://github.com/arabianq/rodio.git", rev = "c6a81b5a46e00a6a682c0c431dff62e86f57d819", default-features = false, features = [
"symphonia-all",
"symphonia-libopus",
"playback",
] }
pipewire = "0.10.0"
egui = { version = "0.34.2", default-features = false, features = [
"default_fonts",
@@ -56,17 +73,8 @@ egui_extras = "0.34.1"
egui_material_icons = "0.6.0"
egui_dnd = "0.15.0"
[[bin]]
name = "pwsp-daemon"
path = "src/bin/daemon.rs"
[[bin]]
name = "pwsp-cli"
path = "src/bin/cli.rs"
[[bin]]
name = "pwsp-gui"
path = "src/main.rs"
reqwest = "0.13.4"
percent-encoding = "2.3.2"
[profile.release]
strip = true
@@ -75,36 +83,3 @@ codegen-units = 1
opt-level = "z"
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
Copyright (c) 2025 Tarasov Alexander
Copyright (c) 2026 arabianq
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+4 -6
View File
@@ -1,7 +1,7 @@
<div align="center">
<h1>🎵 PipeWire Soundpad (PWSP)</h1>
<p><b>A simple, modern, and powerful soundboard for Linux, written in Rust.</b></p>
<img src="assets/screenshot.png" alt="PWSP Screenshot" width="700"/>
<img src="pwsp-gui/assets/screenshot.png" alt="PWSP Screenshot" width="700"/>
</div>
## 🌟 Overview
@@ -99,15 +99,13 @@ pwsp-cli --help # View all commands
| Action | Keyboard | Mouse |
| :----------------------------------- | :--------------------- | :------------------- |
| **Play Track** (Stops others) | `Enter` | `Left Click` |
| **Add Track** (Plays simultaneously) | `Ctrl + Enter` | `Ctrl + Left Click` |
| **Replace Last Track** | `Shift + Enter` | `Shift + Left Click` |
| **Play Track** (Stops others) | | `Left Click` |
| **Add Track** (Plays simultaneously) | | `Ctrl + Left Click` |
| **Replace Last Track** | | `Shift + Left Click` |
| **Pause / Resume** | `Space` | |
| **Stop All Tracks** | `Backspace` | |
| **Open / Close Settings** | `I` | |
| **Search** | `/` | |
| **Navigate Files** | `Ctrl + ↑ / ↓` | |
| **Navigate Directories** | `Ctrl + Shift + ↑ / ↓` | |
---
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

+17 -14
View File
@@ -1,17 +1,20 @@
pkgbase = pwsp-bin
pkgdesc = Lets you play audio files through your microphone (Pre-built binaries)
pkgver = 1.8.0
pkgrel = 1
url = https://github.com/arabianq/pipewire-soundpad
arch = x86_64
license = MIT
depends = pipewire
depends = alsa-lib
provides = pwsp
conflicts = pwsp
source = pwsp-bin-1.8.0.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.8.0/pwsp-v1.8.0-linux-x64.zip
source = pipewire-soundpad-1.8.0.tar.gz :: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.8.0.tar.gz
sha256sums = SKIP
sha256sums = SKIP
pkgdesc = Lets you play audio files through your microphone (Pre-built binaries)
pkgver = 1.12.0
pkgrel = 2
url = https://github.com/arabianq/pipewire-soundpad
arch = x86_64
arch = aarch64
license = MIT
depends = pipewire
depends = alsa-lib
provides = pwsp
conflicts = pwsp
source = pipewire-soundpad-1.12.0.tar.gz :: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.12.0.tar.gz
sha256sums = SKIP
source_x86_64 = pwsp-1.12.0-x86_64.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.12.0/pwsp-v1.12.0-linux-x64.zip
sha256sums_x86_64 = SKIP
source_aarch64 = pwsp-1.12.0-aarch64.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.12.0/pwsp-v1.12.0-linux-arm64.zip
sha256sums_aarch64 = SKIP
pkgname = pwsp-bin
+12 -10
View File
@@ -1,21 +1,23 @@
# Maintainer: Alexander Tarasov <a.tevg@ya.ru>
pkgname=pwsp-bin
_pkgname=pipewire-soundpad
pkgver=1.8.0
pkgrel=1
pkgver=1.12.0
pkgrel=2
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"
license=('MIT')
depends=('pipewire' 'alsa-lib')
provides=('pwsp')
conflicts=('pwsp')
source=("${pkgname}-${pkgver}.zip::https://github.com/arabianq/$_pkgname/releases/download/v$pkgver/pwsp-v$pkgver-linux-x64.zip"
"${_pkgname}-${pkgver}.tar.gz::https://github.com/arabianq/$_pkgname/archive/refs/tags/v$pkgver.tar.gz")
source_x86_64=("pwsp-${pkgver}-x86_64.zip::https://github.com/arabianq/$_pkgname/releases/download/v$pkgver/pwsp-v$pkgver-linux-x64.zip")
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'
'SKIP')
sha256sums=('SKIP')
sha256sums_x86_64=('SKIP')
sha256sums_aarch64=('SKIP')
package() {
_srcsrc="${srcdir}/${_pkgname}-${pkgver}"
@@ -24,9 +26,9 @@ package() {
install -Dm755 "${srcdir}/pwsp-daemon" "${pkgdir}/usr/bin/pwsp-daemon"
install -Dm755 "${srcdir}/pwsp-gui" "${pkgdir}/usr/bin/pwsp-gui"
install -Dm644 "$_srcsrc/assets/pwsp-gui.desktop" "${pkgdir}/usr/share/applications/pwsp-gui.desktop"
install -Dm644 "$_srcsrc/assets/icon.png" "${pkgdir}/usr/share/icons/hicolor/256x256/apps/icon.png"
install -Dm644 "$_srcsrc/assets/pwsp-daemon.service" "${pkgdir}/usr/lib/systemd/user/pwsp-daemon.service"
install -Dm644 "$_srcsrc/pwsp-gui/assets/pwsp-gui.desktop" "${pkgdir}/usr/share/applications/pwsp-gui.desktop"
install -Dm644 "$_srcsrc/pwsp-gui/assets/icon.png" "${pkgdir}/usr/share/icons/hicolor/256x256/apps/pwsp.png"
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"
}
+4 -3
View File
@@ -1,9 +1,10 @@
pkgbase = pwsp
pkgdesc = Lets you play audio files through your microphone
pkgver = 1.8.0
pkgver = 1.12.0
pkgrel = 1
url = https://github.com/arabianq/pipewire-soundpad
arch = any
arch = x86_64
arch = aarch64
license = MIT
makedepends = clang
makedepends = rust
@@ -11,7 +12,7 @@ pkgbase = pwsp
makedepends = cmake
makedepends = pipewire
makedepends = alsa-lib
source = https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.8.0.tar.gz
source = https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.12.0.tar.gz
sha256sums = SKIP
pkgname = pwsp
+5 -5
View File
@@ -1,10 +1,10 @@
# Maintainer: Alexander Tarasov <a.tevg@ya.ru>
pkgsubn=pwsp
pkgname=pwsp
pkgver=1.8.0
pkgver=1.12.0
pkgrel=1
pkgdesc="Lets you play audio files through your microphone"
arch=('any')
arch=('x86_64' 'aarch64')
url="https://github.com/arabianq/pipewire-soundpad"
license=('MIT')
makedepends=(clang rust cargo cmake pipewire alsa-lib)
@@ -40,8 +40,8 @@ package() {
install -Dm755 "target/release/pwsp-daemon" "${pkgdir}/usr/bin/pwsp-daemon"
install -Dm755 "target/release/pwsp-gui" "${pkgdir}/usr/bin/pwsp-gui"
install -Dm644 "assets/pwsp-gui.desktop" "${pkgdir}/usr/share/applications/pwsp-gui.desktop"
install -Dm644 "assets/icon.png" "${pkgdir}/usr/share/icons/hicolor/256x256/apps/icon.png"
install -Dm644 "pwsp-gui/assets/pwsp-gui.desktop" "${pkgdir}/usr/share/applications/pwsp-gui.desktop"
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 subprocess
import sys
if __name__ == "__main__":
if len(sys.argv) == 2 and sys.argv[1].startswith("soundpad://"):
subprocess.Popen(["pwsp-gui", sys.argv[1]])
sys.exit(0)
parser = argparse.ArgumentParser(
prog="PWSP Flatpak",
add_help=True,
exit_on_error=True
prog="PWSP Flatpak", add_help=True, exit_on_error=True
)
subparsers = parser.add_subparsers(dest="command")
cli_parser = subparsers.add_parser("cli", add_help=False, prefix_chars=" ")
cli_parser.add_argument("args", nargs=argparse.REMAINDER, help="Arguments for pwsp-cli")
cli_parser.add_argument(
"args", nargs=argparse.REMAINDER, help="Arguments for pwsp-cli"
)
daemon_parser = subparsers.add_parser("daemon", add_help=True)
daemon_group = daemon_parser.add_mutually_exclusive_group(required=True)
daemon_group.add_argument("--start", action="store_true", help="Start pwps-daemon")
daemon_group.add_argument("--start", action="store_true", help="Start pwsp-daemon")
daemon_group.add_argument("--kill", action="store_true", help="Kill pwsp-daemon")
args = parser.parse_args()
@@ -7,3 +7,4 @@ Terminal=false
Type=Application
Categories=AudioVideo;Audio;
Keywords=soundpad;pipewire;audio;
MimeType=x-scheme-handler/soundpad;
@@ -15,7 +15,7 @@
<launchable type="desktop-id">ru.arabianq.pwsp.desktop</launchable>
<screenshots>
<screenshot type="default">
<image>https://raw.githubusercontent.com/arabianq/pipewire-soundpad/master/assets/screenshot.png</image>
<image>https://raw.githubusercontent.com/arabianq/pipewire-soundpad/main/pwsp-gui/assets/screenshot.png</image>
</screenshot>
</screenshots>
<url type="homepage">https://pwsp.arabianq.ru</url>
@@ -25,7 +25,7 @@
<name>arabian</name>
</developer>
<releases>
<release version="1.8.0" date="2026-05-13" />
<release version="1.12.0" date="2026-06-04" />
</releases>
<content_rating type="oars-1.1" />
</component>
+1 -1
View File
@@ -35,7 +35,7 @@ modules:
- install -Dm755 target/release/pwsp-cli /app/bin/pwsp-cli
- install -Dm755 target/release/pwsp-gui /app/bin/pwsp-gui
- install -Dm755 packages/flatpak/pwsp-wrapper.py /app/bin/pwsp-wrapper.py
- install -Dm644 assets/icon.png /app/share/icons/hicolor/256x256/apps/ru.arabianq.pwsp.png
- install -Dm644 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.metainfo.xml /app/share/metainfo/ru.arabianq.pwsp.metainfo.xml
sources:
+83
View File
@@ -0,0 +1,83 @@
# 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
%else
BuildRequires: alsa-lib-devel
BuildRequires: dbus-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 }}}
+32 -6
View File
@@ -1,10 +1,19 @@
%bcond check 1
# prevent library files from being installed
%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
Version: 1.8.0
Version: 1.12.0
Release: %autorelease
Summary: Lets you play audio files through your microphone
@@ -16,9 +25,21 @@ Source: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags
BuildRequires: rust
BuildRequires: cargo
BuildRequires: pipewire-devel
%if 0%{?suse_version}
BuildRequires: alsa-devel
BuildRequires: dbus-1-devel
%else
BuildRequires: alsa-lib-devel
BuildRequires: dbus-devel
%endif
BuildRequires: clang-devel
BuildRequires: cmake
BuildRequires: pkgconfig
%if 0%{?suse_version} && 0%{?suse_version} <= 1500
BuildRequires: gcc13-c++
%endif
%global _description %{expand:
PWSP lets you play audio files through your microphone. Has both CLI and
@@ -30,17 +51,22 @@ GUI clients.}
%autosetup -n pipewire-soundpad-%{version} -p1
%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 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/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 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
%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 pwsp::{
use pwsp_lib::{
types::socket::Request,
utils::daemon::{make_request, wait_for_daemon},
};
use std::{error::Error, path::PathBuf};
use std::path::PathBuf;
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
@@ -146,7 +147,7 @@ enum SetCommands {
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
async fn main() -> Result<()> {
let cli = Cli::parse();
wait_for_daemon().await?;
@@ -204,9 +205,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
},
};
let response = make_request(request)
.await
.map_err(|e| e as Box<dyn Error>)?;
let response = make_request(request).await.map_err(|e| anyhow!(e))?;
println!("{} : {}", response.status, response.message);
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
+197
View File
@@ -0,0 +1,197 @@
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
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::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;
}
}
}
+69
View File
@@ -0,0 +1,69 @@
[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]
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]
Name=PWSP (Soundpad)
Comment=Let's you play audio files through you microphone
Exec=pwsp-gui %u
Exec=/usr/bin/pwsp-gui %u
Icon=pwsp
Terminal=false
Type=Application
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 egui::{Context, Id, Key, Modifiers};
use pwsp::types::socket::Request;
use pwsp::utils::gui::make_request_async;
use std::path::PathBuf;
use pwsp_lib::types::socket::Request;
use pwsp_lib::utils::gui::make_request_async;
/// Convert an egui Key + Modifiers to a normalized chord string like "Ctrl+Shift+A".
fn chord_from_event(modifiers: &Modifiers, key: &Key) -> Option<String> {
@@ -94,7 +92,7 @@ impl SoundpadGui {
}
pub fn handle_input(&mut self, ctx: &Context) {
let modifiers = self.modifiers(ctx);
let _modifiers = self.modifiers(ctx);
let search_focused = {
if let Some(focused_id) = self.get_focused(ctx)
&& let Some(search_id) = self.app_state.search_field_id
@@ -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
let slots_to_play: Vec<String> = ctx.input(|i| {
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());
}
}
+88 -17
View File
@@ -1,11 +1,12 @@
mod draw;
mod input;
mod update;
mod views;
use anyhow::{Result, anyhow};
use eframe::{HardwareAcceleration, NativeOptions, icon_data::from_png_bytes, run_native};
use egui::{Context, FontData, FontDefinitions, FontFamily, FontTweak, Vec2, ViewportBuilder};
use itertools::Itertools;
use pwsp::{
use pwsp_lib::{
types::{
audio_player::PlayerState,
config::GuiConfig,
@@ -20,7 +21,7 @@ use pwsp::{
};
use rfd::FileDialog;
use std::{
error::Error,
cmp::Ordering,
fs,
path::{Path, PathBuf},
sync::{Arc, Mutex},
@@ -110,14 +111,14 @@ impl SoundpadGui {
self.app_state.current_dir = Some(path.clone());
match path.read_dir() {
Ok(read_dir) => {
self.app_state.files = read_dir
self.app_state.listed_files = read_dir
.filter_map(|res| res.ok())
.map(|entry| entry.path())
.collect();
}
Err(e) => {
eprintln!("Failed to read directory {:?}: {}", path, e);
self.app_state.files.clear();
self.app_state.listed_files.clear();
}
}
}
@@ -157,8 +158,25 @@ impl SoundpadGui {
}
pub fn get_filtered_files(&self) -> Vec<PathBuf> {
let mut files: Vec<PathBuf> = self.app_state.files.iter().cloned().collect();
files.sort();
let mut files: Vec<PathBuf> = self.app_state.listed_files.iter().cloned().collect();
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 = search_query.trim();
@@ -167,7 +185,7 @@ impl SoundpadGui {
.into_iter()
.filter(|entry_path| {
if entry_path.is_dir() {
return false;
return true;
}
if !SUPPORTED_EXTENSIONS.contains(
@@ -198,11 +216,7 @@ impl SoundpadGui {
}
}
fn add_font(
font_name: &str,
font_bytes: &[u8],
fonts: &mut FontDefinitions,
) -> Result<(), Box<dyn Error>> {
fn add_font(font_name: &str, font_bytes: &[u8], fonts: &mut FontDefinitions) -> Result<()> {
let font_data = FontData::from_owned(font_bytes.to_vec()).tweak(FontTweak {
scale: 1.0,
hinting_override: Some(true),
@@ -227,12 +241,13 @@ fn add_font(
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_serif) = find_for_locale("en", FontStyle::Serif);
let (_, ja_sans) = find_for_locale("ja", FontStyle::Sans);
let (_, ar_sans) = find_for_locale("ar", FontStyle::Sans);
let system_fonts = [en_sans, en_serif, ja_sans].concat();
let system_fonts = [en_sans, en_serif, ja_sans, ar_sans].concat();
for font in system_fonts.iter().rev() {
let font_bytes = match &font.source {
@@ -246,7 +261,7 @@ fn load_system_fonts(fonts: &mut FontDefinitions) -> Result<(), Box<dyn Error>>
Ok(())
}
pub async fn run() -> Result<(), Box<dyn Error>> {
pub async fn run() -> Result<()> {
const ICON: &[u8] = include_bytes!("../../assets/icon.png");
let options = NativeOptions {
@@ -283,6 +298,62 @@ pub async fn run() -> Result<(), Box<dyn Error>> {
}
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 eframe::{App, Frame as EFrame};
use egui::{CentralPanel, Context};
use pwsp::{
types::socket::Request,
use egui::{CentralPanel, Context, ThemePreference};
use pwsp_lib::{
types::{config::PreferredTheme, socket::Request},
utils::{daemon::get_daemon_config, gui::make_request_async},
};
use std::time::{Duration, Instant};
impl App for SoundpadGui {
fn logic(&mut self, ctx: &Context, _frame: &mut EFrame) {
// Update theme
let current_theme = match ctx.options(|r| r.theme_preference) {
ThemePreference::System => PreferredTheme::System,
ThemePreference::Light => PreferredTheme::Light,
ThemePreference::Dark => PreferredTheme::Dark,
};
if !self.config.preferred_theme.eq(&current_theme) {
ctx.options_mut(|w| {
w.theme_preference = match self.config.preferred_theme {
PreferredTheme::System => ThemePreference::System,
PreferredTheme::Light => ThemePreference::Light,
PreferredTheme::Dark => ThemePreference::Dark,
}
})
}
// Remove directories
for path in self.app_state.dirs_to_remove.drain() {
self.app_state.dirs.retain(|x| x != &path);
@@ -16,7 +33,7 @@ impl App for SoundpadGui {
&& current_dir == &path
{
self.app_state.current_dir = None;
self.app_state.files.clear();
self.app_state.listed_files.clear();
}
}
+516
View File
@@ -0,0 +1,516 @@
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()) {
read.push(entry.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() {
if !crate::gui::SUPPORTED_EXTENSIONS.contains(
&child
.extension()
.unwrap_or_default()
.to_str()
.unwrap_or_default(),
) {
continue;
}
if !search_query.is_empty() {
let file_name = child
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
if !file_name.to_lowercase().contains(search_query) {
continue;
}
}
}
Self::draw_tree_node(ui, child, 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(),
);
});
}
}
+60
View File
@@ -0,0 +1,60 @@
mod gui;
use anyhow::{Context, Result};
use pwsp_lib::utils::gui::ensure_pwsp_audio_dir;
use rust_i18n::i18n;
use std::{env, path::PathBuf};
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::anyhow!("URI does not containt an expected prefix: {}", prefix))?;
let file_name_encoded = target_url
.split('/')
.next_back()
.unwrap_or("downloaded_audio.mp3");
let file_name = percent_encoding::percent_decode_str(file_name_encoded)
.decode_utf8()
.unwrap_or_else(|_| file_name_encoded.into())
.into_owned();
let save_path = ensure_pwsp_audio_dir().join(file_name);
let response = reqwest::get(target_url)
.await?
.error_for_status()
.context("Failed to fetch file")?;
let bytes = response.bytes().await?;
tokio::fs::write(&save_path, bytes)
.await
.context("Failed to save file to disk")?;
Ok(save_path)
}
+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
@@ -5,6 +5,7 @@ use crate::{
pipewire::{create_link, get_device, link_player_to_virtual_mic},
},
};
use anyhow::{Result, anyhow};
use rodio::{Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source};
use serde::{Deserialize, Serialize};
use std::{
@@ -65,7 +66,7 @@ pub struct 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 default_volume = daemon_config.default_volume.unwrap_or(1.0);
@@ -88,13 +89,15 @@ impl AudioPlayer {
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() {
let mut sink = DeviceSinkBuilder::open_default_sink()?;
sink.log_on_drop(false);
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) {
@@ -126,7 +129,7 @@ impl AudioPlayer {
}
}
async fn link_player(&mut self) -> Result<(), Box<dyn Error>> {
async fn link_player(&mut self) -> Result<()> {
if self.player_link_sender.is_some() {
return Ok(());
}
@@ -140,7 +143,7 @@ impl AudioPlayer {
}
}
async fn link_devices(&mut self) -> Result<(), Box<dyn Error>> {
async fn link_devices(&mut self) -> Result<()> {
self.abort_link_thread();
let input_device;
@@ -289,7 +292,7 @@ impl AudioPlayer {
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 };
if let Some(id) = id {
@@ -305,22 +308,18 @@ impl AudioPlayer {
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(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() {
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(
&mut self,
file_path: &Path,
concurrent: bool,
) -> Result<u32, Box<dyn Error>> {
pub async fn play(&mut self, file_path: &Path, concurrent: bool) -> Result<u32> {
let path_buf = file_path.to_path_buf();
let decoder_result =
@@ -350,7 +349,11 @@ impl AudioPlayer {
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);
sink.set_volume(self.volume); // Default volume is 1.0 * master
sink.append(source);
@@ -369,7 +372,7 @@ impl AudioPlayer {
Ok(id)
}
Err(err) => Err(err as Box<dyn Error>),
Err(err) => Err(anyhow!(err)),
}
}
@@ -472,11 +475,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?;
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());
+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_to_remove: HashSet<PathBuf>,
pub selected_file: Option<PathBuf>,
pub files: HashSet<PathBuf>,
pub listed_files: HashSet<PathBuf>,
pub listed_dirs: HashSet<PathBuf>,
pub dir_cache: HashMap<PathBuf, Vec<PathBuf>>,
pub show_hotkeys: bool,
pub hotkey_capture_active: bool,
pub hotkey_config: HotkeyConfig,
pub hotkey_search_query: String,
pub assigning_hotkey_slot: Option<String>,
pub assigning_hotkey_for_file: Option<PathBuf>,
pub hotkey_capture_active: bool,
}
#[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())
);
}
}
@@ -1,6 +1,7 @@
use std::{error::Error, path::PathBuf};
use anyhow::Result;
use std::path::PathBuf;
pub fn get_config_path() -> Result<PathBuf, Box<dyn Error>> {
pub fn get_config_path() -> Result<PathBuf> {
let config_path = dirs::config_dir().expect("Failed to obtain config dir");
Ok(config_path.join("pwsp"))
}
@@ -4,9 +4,10 @@ use crate::types::{
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::{error::Error, fs};
use std::{env, error::Error, fs};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::UnixStream,
@@ -36,29 +37,59 @@ pub fn get_daemon_config() -> DaemonConfig {
})
}
pub fn get_runtime_dir() -> PathBuf {
dirs::runtime_dir().unwrap_or(PathBuf::from("/run/pwsp"))
fn get_current_uid() -> u32 {
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();
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(())
}
pub fn is_daemon_running() -> Result<bool, Box<dyn Error>> {
let lock_file = fs::File::create(get_runtime_dir().join("daemon.lock"))?;
pub fn is_daemon_running() -> Result<bool> {
let lock_file = fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(get_runtime_dir().join("daemon.lock"))?;
match lock_file.try_lock() {
Ok(_) => Ok(false),
Err(_) => Ok(true),
}
}
pub async fn wait_for_daemon() -> Result<(), Box<dyn Error>> {
pub async fn wait_for_daemon() -> Result<()> {
if is_daemon_running()? {
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},
};
use anyhow::{Result, anyhow};
use std::{
error::Error,
path::PathBuf,
sync::{Arc, Mutex},
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::runtime::Handle::current()
.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 {
fn format_time(seconds: f32) -> String {
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");
}
}
@@ -1,9 +1,10 @@
use crate::types::pipewire::{AudioDevice, DeviceType, Port, Terminate};
use anyhow::{Result, anyhow};
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 std::{collections::HashMap, thread};
use tokio::{
sync::mpsc,
time::{Duration, timeout},
@@ -19,73 +20,61 @@ pub fn setup_pipewire_context() -> Result<(MainLoopRc, ContextRc), String> {
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");
let props = match global_object.props {
Some(p) => p,
None => return (None, None),
};
// 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,
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");
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 {
if media_class.starts_with("Audio/Source") {
let input_device = AudioDevice::new(
node_id,
port_id,
name: port_name.to_string(),
};
return (None, Some(port));
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)
}
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>>,
init_sender: tokio::sync::oneshot::Sender<Result<(), String>>,
) {
let (main_loop, context) = match setup_pipewire_context() {
Ok(res) => res,
@@ -142,11 +131,11 @@ async fn pw_get_global_objects_thread(
main_loop.run();
}
pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>), Box<dyn Error>> {
pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
// Channels to communicate with pipewire thread
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);
let (init_sender, init_receiver) = tokio::sync::oneshot::channel();
// Spawn pipewire thread in background
let _pw_thread = tokio::spawn(async move {
@@ -154,8 +143,8 @@ pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>), B
});
// Wait for initialization to complete
if let Err(e) = init_receiver.recv()? {
return Err(e.into());
if let Err(e) = init_receiver.await {
return Err(anyhow!(e));
}
let mut input_devices: HashMap<u32, AudioDevice> = HashMap::new();
@@ -187,47 +176,14 @@ pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>), B
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);
}
_ => {}
}
input_device.add_port(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)
}
_ => {}
}
output_device.add_port(port);
}
}
let mut input_devices: Vec<AudioDevice> = input_devices.values().cloned().collect();
let mut output_devices: Vec<AudioDevice> =
output_devices.values().cloned().collect();
let mut input_devices: Vec<AudioDevice> = input_devices.into_values().collect();
let mut output_devices: Vec<AudioDevice> = output_devices.into_values().collect();
input_devices.sort_by_key(|a| a.id);
output_devices.sort_by_key(|a| a.id);
@@ -238,7 +194,7 @@ pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>), B
}
}
pub async fn get_device(device_name: &str) -> Result<AudioDevice, Box<dyn Error>> {
pub async fn get_device(device_name: &str) -> Result<AudioDevice> {
let (input_devices, output_devices) = get_all_devices().await?;
input_devices
@@ -250,10 +206,10 @@ pub async fn get_device(device_name: &str) -> Result<AudioDevice, Box<dyn Error>
|| device.name.contains(device_name)
|| device.nick.contains(device_name)
})
.ok_or_else(|| "Device not found".into())
.ok_or_else(|| anyhow!("Device not found"))
}
pub fn create_virtual_mic() -> Result<pipewire::channel::Sender<Terminate>, Box<dyn Error>> {
pub fn create_virtual_mic() -> Result<pipewire::channel::Sender<Terminate>> {
let (pw_sender, pw_receiver) = pipewire::channel::channel::<Terminate>();
let (init_sender, init_receiver) = std::sync::mpsc::sync_channel(0);
@@ -305,45 +261,46 @@ pub fn create_virtual_mic() -> Result<pipewire::channel::Sender<Terminate>, Box<
});
if let Err(e) = init_receiver.recv()? {
return Err(e.into());
return Err(anyhow!(e));
}
Ok(pw_sender)
}
pub async fn link_player_to_virtual_mic()
-> Result<pipewire::channel::Sender<Terminate>, Box<dyn Error>> {
pub async fn link_player_to_virtual_mic() -> Result<pipewire::channel::Sender<Terminate>> {
let pwsp_daemon_output = match get_device("pwsp-daemon").await {
Ok(device) => device,
Err(_) => {
return Err(
"Could not find alsa_playback.pwsp-daemon device, skipping device linking".into(),
);
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("Could not find pwsp-virtual-mic device, skipping device linking".into());
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("Failed to get pwsp-daemon output_fl".into()),
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("Failed to get pwsp-daemon output_fr".into()),
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("Failed to get pwsp-virtual-mic input_fl".into()),
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("Failed to get pwsp-virtual-mic input_fr".into()),
None => return Err(anyhow!("Failed to get pwsp-virtual-mic input_fr")),
};
create_link(output_fl, output_fr, input_fl, input_fr)
@@ -354,7 +311,7 @@ pub fn create_link(
output_fr: Port,
input_fl: Port,
input_fr: Port,
) -> Result<pipewire::channel::Sender<Terminate>, Box<dyn Error>> {
) -> Result<pipewire::channel::Sender<Terminate>> {
let (pw_sender, pw_receiver) = pipewire::channel::channel::<Terminate>();
let (init_sender, init_receiver) = std::sync::mpsc::sync_channel(0);
@@ -419,7 +376,7 @@ pub fn create_link(
});
if let Err(e) = init_receiver.recv()? {
return Err(e.into());
return Err(anyhow!(e));
}
Ok(pw_sender)
+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>,
}