Compare commits

..

28 Commits

Author SHA1 Message Date
Tarasov Aleksandr e6c8d720d5 update deps and change version to 1.7.6 (#92)
* change version to 1.7.6

* cargo update

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

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

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

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

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

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

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

* Delete benches/ui_benchmark.rs

* refactor: remove garbage

---------

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

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

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

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

* change version to 1.7.5

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

* deps: update cargo-sources.json

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

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

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

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

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

---------

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

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

* deps: update cargo-sources.json for flatpak

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

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

fully reworked hotkeys page

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

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

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

* deps: cargo update

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

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

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

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

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

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

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

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

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

* chore(flatpak): prepare application for Flathub submission

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

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

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

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

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

---------

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

* deps: bump clap to 4.6.1

* deps: cargo update

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

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

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

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

* small refactor

* refactor

---------

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

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

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

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-04-17 13:56:29 +03:00
27 changed files with 8034 additions and 777 deletions
+88
View File
@@ -0,0 +1,88 @@
name: Flatter
on:
push:
branches: [ main, master ]
release:
types: [ published ]
workflow_dispatch:
inputs:
tag_name:
description: "TAG (empty to build from current branch)"
required: false
type: string
build_branch:
description: "Flatpak branch to build (stable/nightly)"
required: true
type: choice
options:
- stable
- nightly
default: "stable"
jobs:
flatter:
name: Flatter
runs-on: ubuntu-latest
permissions:
contents: read
container:
image: ghcr.io/andyholmes/flatter/freedesktop:25.08
options: --privileged
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ inputs.tag_name || github.ref }}
- name: Setup GPG
id: gpg
uses: crazy-max/ghaction-import-gpg@v6
with:
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.GPG_PASSPHRASE }}
- name: Set Default Branch
id: set_branch
run: |
if [ "${{ github.event_name }}" == "release" ]; then
echo "branch=stable" >> $GITHUB_OUTPUT
elif [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
echo "branch=${{ inputs.build_branch }}" >> $GITHUB_OUTPUT
else
echo "branch=nightly" >> $GITHUB_OUTPUT
fi
- name: Modify Manifest
run: |
echo "branch: ${{ steps.set_branch.outputs.branch }}" >> packages/flatpak/ru.arabianq.pwsp.yaml
echo "default-branch: ${{ steps.set_branch.outputs.branch }}" >> packages/flatpak/ru.arabianq.pwsp.yaml
- name: Install SDK Extensions
run: flatpak install -y flathub org.freedesktop.Sdk.Extension.rust-stable//25.08
org.freedesktop.Sdk.Extension.llvm20//25.08
- name: Build Flatpak
uses: andyholmes/flatter@main
with:
files: packages/flatpak/ru.arabianq.pwsp.yaml
gpg-sign: ${{ steps.gpg.outputs.fingerprint }}
upload-bundles: false
upload-pages-artifact: true
deploy:
name: Deploy to GitHub Pages
runs-on: ubuntu-latest
needs: flatter
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
-7
View File
@@ -174,10 +174,3 @@ jobs:
cache: true
branch: master
build-bundle: true
- name: Upload Flatpak to release
uses: softprops/action-gh-release@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
tag_name: ${{ needs.prepare.outputs.tag }}
files: ru.arabianq.pwsp.flatpak
Generated
+315 -183
View File
File diff suppressed because it is too large Load Diff
+6 -4
View File
@@ -1,6 +1,6 @@
[package]
name = "pwsp"
version = "1.7.1"
version = "1.7.6"
edition = "2024"
authors = ["arabian"]
description = "PWSP lets you play audio files through your microphone. Has both CLI and GUI clients."
@@ -12,13 +12,13 @@ keywords = ["soundpad", "pipewire", "linux", "cli", "gui"]
[dependencies]
tokio = { version = "1.51.1", features = ["full"] }
tokio = { version = "1.52.1", features = ["full"] }
async-trait = "0.1.89"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
clap = { version = "4.6.0", default-features = false, features = [
clap = { version = "4.6.1", default-features = false, features = [
"std",
"suggestions",
"help",
@@ -29,8 +29,9 @@ clap = { version = "4.6.0", default-features = false, features = [
dirs = "6.0.0"
itertools = "0.14.0"
rodio = { version = "0.22.2", default-features = false, features = [
rodio = { git = "https://github.com/RustAudio/rodio.git", rev = "57ad9d8a9f30398f634fbf8e4e1d53dde7243c21", default-features = false, features = [
"symphonia-all",
"symphonia-libopus",
"playback",
] }
pipewire = "0.9.2"
@@ -50,6 +51,7 @@ eframe = { version = "0.34.1", default-features = false, features = [
"x11",
"wayland",
] }
egui_extras = "0.34.1"
egui_material_icons = "0.6.0"
egui_dnd = "0.15.0"
+22
View File
@@ -52,6 +52,28 @@ three main components:
You can download pre-built binaries and .deb packages from
the [releases page](https://github.com/arabianq/pipewire-soundpad/releases).
## **Flatpak**
You can install PWSP via Flatpak from our custom repository hosted on GitHub Pages.
Add the repository:
```bash
flatpak remote-add --user --if-not-exists arabianq-repo https://arabianq.github.io/pipewire-soundpad/index.flatpakrepo
```
Install the stable version:
```bash
flatpak install --user arabianq-repo ru.arabianq.pwsp//stable
```
Or install the nightly version (latest commit to `main`):
```bash
flatpak install --user arabianq-repo ru.arabianq.pwsp//nightly
```
## **Fedora Linux (and derivatives)**
If you're using Fedora, you can install PWSP from a dedicated repository using DNF.
+4 -4
View File
@@ -1,7 +1,7 @@
pkgbase = pwsp-bin
pkgdesc = Lets you play audio files through your microphone (Pre-built binaries)
pkgver = 1.7.1
pkgrel = 2
pkgver = 1.7.6
pkgrel = 1
url = https://github.com/arabianq/pipewire-soundpad
arch = x86_64
license = MIT
@@ -9,8 +9,8 @@ depends = pipewire
depends = alsa-lib
provides = pwsp
conflicts = pwsp
source = pwsp-bin-1.7.1.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.7.1/pwsp-v1.7.1-linux-x64.zip
source = pipewire-soundpad-1.7.1.tar.gz :: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.7.1.tar.gz
source = pwsp-bin-1.7.6.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.7.6/pwsp-v1.7.6-linux-x64.zip
source = pipewire-soundpad-1.7.6.tar.gz :: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.7.6.tar.gz
sha256sums = SKIP
sha256sums = SKIP
+2 -2
View File
@@ -1,8 +1,8 @@
# Maintainer: Alexander Tarasov <a.tevg@ya.ru>
pkgname=pwsp-bin
_pkgname=pipewire-soundpad
pkgver=1.7.1
pkgrel=2
pkgver=1.7.6
pkgrel=1
pkgdesc="Lets you play audio files through your microphone (Pre-built binaries)"
arch=('x86_64')
url="https://github.com/arabianq/pipewire-soundpad"
+3 -2
View File
@@ -1,6 +1,6 @@
pkgbase = pwsp
pkgdesc = Lets you play audio files through your microphone
pkgver = 1.7.1
pkgver = 1.7.6
pkgrel = 1
url = https://github.com/arabianq/pipewire-soundpad
arch = any
@@ -8,9 +8,10 @@ pkgbase = pwsp
makedepends = clang
makedepends = rust
makedepends = cargo
makedepends = cmake
makedepends = pipewire
makedepends = alsa-lib
source = https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.7.1.tar.gz
source = https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.7.6.tar.gz
sha256sums = SKIP
pkgname = pwsp
+2 -2
View File
@@ -1,13 +1,13 @@
# Maintainer: Alexander Tarasov <a.tevg@ya.ru>
pkgsubn=pwsp
pkgname=pwsp
pkgver=1.7.1
pkgver=1.7.6
pkgrel=1
pkgdesc="Lets you play audio files through your microphone"
arch=('any')
url="https://github.com/arabianq/pipewire-soundpad"
license=('MIT')
makedepends=(clang rust cargo pipewire alsa-lib)
makedepends=(clang rust cargo cmake pipewire alsa-lib)
source=("$url/archive/refs/tags/v$pkgver.tar.gz")
sha256sums=('SKIP')
File diff suppressed because one or more lines are too long
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
set -e
if [ ! -f "Cargo.lock" ]; then
echo "Error: Cargo.lock not found. Please run this script from the project root."
return 1
fi
echo "Downloading flatpak-cargo-generator.py..."
curl -sLO https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/master/cargo/flatpak-cargo-generator.py
chmod +x flatpak-cargo-generator.py
echo "Generating cargo-sources.json..."
python3 flatpak-cargo-generator.py Cargo.lock -o packages/flatpak/cargo-sources.json
echo "Cleaning up..."
rm flatpak-cargo-generator.py
echo "Successfully generated packages/flatpak/cargo-sources.json"
+1 -1
View File
@@ -5,5 +5,5 @@ Exec=pwsp-wrapper.py %u
Icon=ru.arabianq.pwsp
Terminal=false
Type=Application
Categories=Audio;Utility;
Categories=AudioVideo;Audio;
Keywords=soundpad;pipewire;audio;
@@ -15,11 +15,17 @@
<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/master/assets/screenshot.png</image>
</screenshot>
</screenshots>
<url type="homepage">https://pwsp.arabianq.ru</url>
<developer_name>arabian</developer_name>
<url type="bugtracker">https://github.com/arabianq/pipewire-soundpad/issues</url>
<url type="vcs-browser">https://github.com/arabianq/pipewire-soundpad</url>
<developer id="ru.arabianq">
<name>arabian</name>
</developer>
<releases>
<release version="1.7.6" date="2026-04-28" />
</releases>
<content_rating type="oars-1.1" />
</component>
+3 -6
View File
@@ -14,10 +14,9 @@ finish-args:
- --filesystem=xdg-run/pipewire-0
- --filesystem=xdg-run/pwsp:create
- --filesystem=xdg-run/app/ru.arabianq.pwsp:create
- --filesystem=host
- --filesystem=home
- --device=all
- --device=dri
- --share=network
- --talk-name=org.freedesktop.portal.Desktop
- --talk-name=org.freedesktop.portal.Documents
@@ -30,11 +29,8 @@ build-options:
modules:
- name: pwsp
buildsystem: simple
build-options:
build-args:
- --share=network
build-commands:
- cargo build --release
- export CARGO_HOME=$PWD/cargo && cargo build --release --offline
- install -Dm755 target/release/pwsp-daemon /app/bin/pwsp-daemon
- install -Dm755 target/release/pwsp-cli /app/bin/pwsp-cli
- install -Dm755 target/release/pwsp-gui /app/bin/pwsp-gui
@@ -45,3 +41,4 @@ modules:
sources:
- type: dir
path: ../../
- cargo-sources.json
+2 -1
View File
@@ -4,7 +4,7 @@
%global cargo_install_lib 0
Name: pwsp
Version: 1.7.1
Version: 1.7.6
Release: %autorelease
Summary: Lets you play audio files through your microphone
@@ -18,6 +18,7 @@ BuildRequires: cargo
BuildRequires: pipewire-devel
BuildRequires: alsa-lib-devel
BuildRequires: clang-devel
BuildRequires: cmake
%global _description %{expand:
PWSP lets you play audio files through your microphone. Has both CLI and
+6 -25
View File
@@ -1,10 +1,10 @@
use pwsp::{
types::socket::{Request, Response},
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, link_player_to_virtual_mic,
is_daemon_running,
},
global_hotkeys::start_global_hotkey_listener,
pipewire::create_virtual_mic,
@@ -32,25 +32,6 @@ async fn main() -> Result<(), Box<dyn Error>> {
eprintln!("Failed to initialize audio player: {}", err);
} // Initialize audio player
tokio::spawn(async {
let max_retries = 60;
for i in 0..=max_retries {
match link_player_to_virtual_mic().await {
Ok(_) => {
println!("Successfully linked player to virtual mic.");
break;
}
Err(e) => {
if i == 0 || i == max_retries {
eprintln!("{e} (attempt {i}/{max_retries})");
}
}
}
sleep(Duration::from_millis(1000)).await;
}
});
tokio::spawn(async {
start_global_hotkey_listener().await;
});
@@ -61,11 +42,11 @@ async fn main() -> Result<(), Box<dyn Error>> {
lock_file.lock()?;
let socket_path = runtime_dir.join("daemon.sock");
if let Err(e) = fs::remove_file(&socket_path) {
if e.kind() != std::io::ErrorKind::NotFound {
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))?;
@@ -109,7 +90,7 @@ async fn commands_loop(listener: UnixListener) -> Result<(), Box<dyn Error>> {
let request_len = u32::from_le_bytes(len_bytes) as usize;
if request_len > 10 * 1024 * 1024 {
if request_len > MAX_MESSAGE_SIZE {
eprintln!(
"Failed to read message from client: request too large ({} bytes)!",
request_len
+241 -196
View File
@@ -1,15 +1,16 @@
use crate::gui::SoundpadGui;
use egui::{
Align, AtomExt, Button, CollapsingHeader, Color32, ComboBox, CursorIcon, FontFamily, Grid,
Label, Layout, RichText, ScrollArea, Sense, Slider, TextEdit, Ui, Vec2,
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, PathBuf},
path::Path,
time::Instant,
};
@@ -133,26 +134,38 @@ impl SoundpadGui {
}
pub fn draw_hotkeys(&mut self, ui: &mut Ui) {
let area_size = ui.available_size();
ui.vertical(|ui| {
ui.set_min_width(area_size.x);
ui.set_min_height(area_size.y);
ui.spacing_mut().item_spacing.y = 5.0;
// Header
ui.horizontal_top(|ui| {
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.add_space(ui.available_width() / 2.0 - 40.0);
ui.vertical_centered(|ui| {
ui.label(RichText::new("Hotkeys").color(Color32::WHITE).monospace());
});
});
}
ui.separator();
// Search and Add Command
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;
@@ -185,63 +198,21 @@ impl SoundpadGui {
ui.add_space(10.0);
ui.add_sized(
[ui.available_width(), 22.0],
ui.add(
TextEdit::singleline(&mut self.app_state.hotkey_search_query)
.hint_text("Search hotkeys..."),
.hint_text("Search hotkeys...")
.desired_width(f32::INFINITY),
);
});
}
ui.separator();
ui.add_space(5.0);
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<String> = conflicts
.iter()
.flat_map(|(a, b)| vec![a.clone(), b.clone()])
.collect();
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();
// Slots table
let mut action: Option<HotkeyAction> = None;
let area_size = ui.available_size();
ScrollArea::vertical().show(ui, |ui| {
ui.set_min_width(area_size.x);
Grid::new("hotkeys_grid")
.striped(true)
.num_columns(4)
.max_col_width(area_size.x)
.min_col_width(area_size.x / 4.0)
.spacing([40.0, 10.0])
.show(ui, |ui| {
// Table header
ui.label(
RichText::new("Slot")
.strong()
.monospace()
.color(Color32::LIGHT_GRAY),
);
ui.label(
RichText::new("Sound")
.strong()
.monospace()
.color(Color32::LIGHT_GRAY),
);
ui.label(
RichText::new("Key Chord")
.strong()
.monospace()
.color(Color32::LIGHT_GRAY),
);
ui.label(
RichText::new("Actions")
.strong()
.monospace()
.color(Color32::LIGHT_GRAY),
);
ui.end_row();
let slots: Vec<_> = self
.app_state
@@ -263,26 +234,91 @@ impl SoundpadGui {
.cloned()
.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| {
// Conflict badge
if conflict_slots.contains(&slot.slot) {
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");
}
// Slot name
let slot_text = RichText::new(&slot.slot).monospace();
ui.label(slot_text);
ui.add(
Label::new(RichText::new(&slot.slot).monospace())
.truncate(),
);
});
});
// Action description
// 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") {
if let Some(file_path_str) =
slot.action.args.get("file_path")
{
Path::new(file_path_str)
.file_name()
.unwrap_or_default()
@@ -299,20 +335,29 @@ impl SoundpadGui {
"toggle_loop" => "Toggle Loop".to_string(),
other => other.to_string(),
};
ui.add(Label::new(RichText::new(action_name).monospace()).truncate());
ui.add(
Label::new(RichText::new(action_name).monospace()).truncate(),
);
});
// Key chord
// Column 3: Key Chord
row.col(|ui| {
let chord_text = slot.key_chord.as_deref().unwrap_or("(none)");
ui.label(RichText::new(chord_text).monospace().color(
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| {
// Delete button
if ui
.add(Button::new(ICON_DELETE).frame(false))
.on_hover_text("Remove slot")
@@ -320,8 +365,6 @@ impl SoundpadGui {
{
action = Some(HotkeyAction::Remove(slot.slot.clone()));
}
// Set key chord button
if ui
.add(Button::new(ICON_KEYBOARD).frame(false))
.on_hover_text("Set key chord")
@@ -329,8 +372,6 @@ impl SoundpadGui {
{
action = Some(HotkeyAction::Capture(slot.slot.clone()));
}
// Clear key chord
if slot.key_chord.is_some()
&& ui
.add(Button::new(ICON_BACKSPACE).frame(false))
@@ -339,8 +380,6 @@ impl SoundpadGui {
{
action = Some(HotkeyAction::ClearChord(slot.slot.clone()));
}
// Play button
if ui
.add(Button::new(ICON_PLAY_ARROW).frame(false))
.on_hover_text("Play")
@@ -349,20 +388,15 @@ impl SoundpadGui {
action = Some(HotkeyAction::Play(slot.slot.clone()));
}
});
ui.end_row();
}
if slots.is_empty() {
ui.label("No hotkey slots configured.");
ui.label("");
ui.label("");
ui.label("");
ui.end_row();
}
});
});
}
});
if let Some(action) = action {
action
}
fn handle_hotkey_action(&mut self, action: HotkeyAction) {
match action {
HotkeyAction::Remove(slot) => {
make_request_async(Request::clear_hotkey(&slot));
@@ -381,8 +415,6 @@ impl SoundpadGui {
}
}
}
});
}
fn draw_header(&mut self, ui: &mut Ui) {
ui.vertical_centered_justified(|ui| {
@@ -391,10 +423,9 @@ impl SoundpadGui {
return;
}
let tracks = self.audio_player_state.tracks.clone();
let mut action = None;
for track in tracks {
for track in &self.audio_player_state.tracks {
CollapsingHeader::new(
RichText::new(
track
@@ -409,7 +440,7 @@ impl SoundpadGui {
)
.default_open(true)
.show(ui, |ui| {
if let Some(act) = Self::draw_track_control(ui, &mut self.app_state, &track) {
if let Some(act) = Self::draw_track_control(ui, &mut self.app_state, track) {
action = Some(act);
}
});
@@ -427,6 +458,99 @@ impl SoundpadGui {
});
}
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,
@@ -457,93 +581,17 @@ impl SoundpadGui {
let mut action = None;
ui.horizontal_top(|ui| {
// ---------- Play Button ----------
let play_button = Button::new(if track.paused {
ICON_PLAY_ARROW
} else {
ICON_PAUSE
})
.corner_radius(15.0);
let play_button_response = ui.add_sized([30.0, 30.0], play_button);
if play_button_response.clicked() {
if track.paused {
action = Some(TrackAction::Resume(track.id));
} else {
action = Some(TrackAction::Pause(track.id));
if let Some(act) = Self::draw_playback_controls(ui, track) {
action = Some(act);
}
}
// --------------------------------
// ---------- Loop Button ----------
let loop_button = Button::new(
RichText::new(if track.looped {
ICON_REPEAT_ONE
} else {
ICON_REPEAT
})
.size(18.0),
)
.frame(false);
let loop_button_response = ui.add_sized([15.0, 30.0], loop_button);
if loop_button_response.clicked() {
action = Some(TrackAction::ToggleLoop(track.id));
}
// --------------------------------
// ---------- Position Slider ----------
let duration = track.duration.unwrap_or(1.0);
let position_slider = Slider::new(&mut ui_state.position_slider_value, 0.0..=duration)
.show_value(false)
.step_by(0.01);
let default_slider_width = ui.spacing().slider_width;
let position_slider_width = ui.available_width()
- (30.0 * 3.0)
- default_slider_width
- (ui.spacing().item_spacing.x * 6.0);
ui.spacing_mut().slider_width = position_slider_width;
let position_slider_response = ui.add_sized([30.0, 30.0], position_slider);
if position_slider_response.drag_stopped() {
ui_state.position_dragged = true;
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);
}
// --------------------------------
// ---------- Time Label ----------
let time_label =
Label::new(RichText::new(format_time_pair(track.position, duration)).monospace());
ui.add_sized([30.0, 30.0], time_label);
// --------------------------------
// ---------- Volume Icon ----------
let volume_icon = Self::get_volume_icon(track.volume);
let volume_label = Label::new(RichText::new(volume_icon).size(18.0));
ui.add_sized([30.0, 30.0], volume_label)
.on_hover_text(format!("Volume: {:.0}%", track.volume * 100.0));
// --------------------------------
// ---------- Volume Slider ----------
let volume_slider = Slider::new(&mut ui_state.volume_slider_value, 0.0..=1.0)
.show_value(false)
.step_by(0.01);
ui.spacing_mut().slider_width = default_slider_width - 30.0;
ui.spacing_mut().item_spacing.x = 0.0;
let volume_slider_response = ui.add_sized([30.0, 30.0], volume_slider);
if volume_slider_response.drag_stopped() {
ui_state.volume_dragged = true;
}
// --------------------------------
// ---------- Stop Button ---------
let stop_button = Button::new(ICON_CLOSE).frame(false);
let stop_button_response = ui.add_sized([30.0, 30.0], stop_button);
if stop_button_response.clicked() {
action = Some(TrackAction::Stop(track.id));
}
// --------------------------------
});
action
@@ -609,11 +657,11 @@ impl SoundpadGui {
.unwrap_or_else(|| path.to_string_lossy().to_string());
let mut dir_button_text = RichText::new(name.clone());
if let Some(current_dir) = &self.app_state.current_dir {
if current_dir.eq(&path) {
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);
@@ -645,11 +693,10 @@ impl SoundpadGui {
ICON_OPEN_IN_BROWSER.codepoint, "Open in File Manager"
))
.clicked()
&& let Err(e) = opener::open(&path)
{
if let Err(e) = opener::open(&path) {
eprintln!("Failed to open file manager: {}", e);
}
}
ui.separator();
@@ -728,13 +775,13 @@ impl SoundpadGui {
}
let mut file_button_text = RichText::new(&file_name);
if let Some(current_file) = &self.app_state.selected_file {
if current_file.eq(&entry_path) {
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);
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| {
@@ -792,11 +839,10 @@ impl SoundpadGui {
ICON_OPEN_IN_BROWSER.codepoint, "Show in File Manager"
))
.clicked()
&& let Err(e) = opener::reveal(&entry_path)
{
if let Err(e) = opener::reveal(&entry_path) {
eprintln!("Failed to open file manager: {}", e);
}
}
ui.separator();
@@ -820,11 +866,12 @@ impl SoundpadGui {
});
}
fn get_hotkey_badge(&self, path: &PathBuf) -> Option<String> {
fn get_hotkey_badge(&self, path: &Path) -> Option<String> {
for slot in &self.app_state.hotkey_config.slots {
if slot.action.name == "play" {
if let Some(file_path_str) = slot.action.args.get("file_path") {
if Path::new(file_path_str) == path.as_path() {
if 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 {
@@ -832,8 +879,6 @@ impl SoundpadGui {
}
}
}
}
}
None
}
+31 -106
View File
@@ -7,57 +7,18 @@ use std::path::PathBuf;
/// Convert an egui Key + Modifiers to a normalized chord string like "Ctrl+Shift+A".
fn chord_from_event(modifiers: &Modifiers, key: &Key) -> Option<String> {
let key_name = match key {
Key::A => "A",
Key::B => "B",
Key::C => "C",
Key::D => "D",
Key::E => "E",
Key::F => "F",
Key::G => "G",
Key::H => "H",
Key::I => "I",
Key::J => "J",
Key::K => "K",
Key::L => "L",
Key::M => "M",
Key::N => "N",
Key::O => "O",
Key::P => "P",
Key::Q => "Q",
Key::R => "R",
Key::S => "S",
Key::T => "T",
Key::U => "U",
Key::V => "V",
Key::W => "W",
Key::X => "X",
Key::Y => "Y",
Key::Z => "Z",
Key::Num0 => "0",
Key::Num1 => "1",
Key::Num2 => "2",
Key::Num3 => "3",
Key::Num4 => "4",
Key::Num5 => "5",
Key::Num6 => "6",
Key::Num7 => "7",
Key::Num8 => "8",
Key::Num9 => "9",
Key::F1 => "F1",
Key::F2 => "F2",
Key::F3 => "F3",
Key::F4 => "F4",
Key::F5 => "F5",
Key::F6 => "F6",
Key::F7 => "F7",
Key::F8 => "F8",
Key::F9 => "F9",
Key::F10 => "F10",
Key::F11 => "F11",
Key::F12 => "F12",
_ => return None,
};
let key_name = key.name();
let is_valid = (key_name.len() == 1
&& key_name
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphanumeric()))
|| (key_name.starts_with('F')
&& key_name.len() > 1
&& key_name[1..].chars().all(|c| c.is_ascii_digit()));
if !is_valid {
return None;
}
// Require at least one modifier for hotkey chords (ignoring command/Super due to Wayland/Niri bug)
if !modifiers.ctrl && !modifiers.alt && !modifiers.shift {
@@ -100,57 +61,21 @@ pub fn parse_chord(chord: &str) -> Option<(Modifiers, Key)> {
}
}
let key = match parts[parts.len() - 1] {
"A" => Key::A,
"B" => Key::B,
"C" => Key::C,
"D" => Key::D,
"E" => Key::E,
"F" => Key::F,
"G" => Key::G,
"H" => Key::H,
"I" => Key::I,
"J" => Key::J,
"K" => Key::K,
"L" => Key::L,
"M" => Key::M,
"N" => Key::N,
"O" => Key::O,
"P" => Key::P,
"Q" => Key::Q,
"R" => Key::R,
"S" => Key::S,
"T" => Key::T,
"U" => Key::U,
"V" => Key::V,
"W" => Key::W,
"X" => Key::X,
"Y" => Key::Y,
"Z" => Key::Z,
"0" => Key::Num0,
"1" => Key::Num1,
"2" => Key::Num2,
"3" => Key::Num3,
"4" => Key::Num4,
"5" => Key::Num5,
"6" => Key::Num6,
"7" => Key::Num7,
"8" => Key::Num8,
"9" => Key::Num9,
"F1" => Key::F1,
"F2" => Key::F2,
"F3" => Key::F3,
"F4" => Key::F4,
"F5" => Key::F5,
"F6" => Key::F6,
"F7" => Key::F7,
"F8" => Key::F8,
"F9" => Key::F9,
"F10" => Key::F10,
"F11" => Key::F11,
"F12" => Key::F12,
_ => return None,
};
let key_name = parts[parts.len() - 1];
let is_valid = (key_name.len() == 1
&& key_name
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphanumeric()))
|| (key_name.starts_with('F')
&& key_name.len() > 1
&& key_name[1..].chars().all(|c| c.is_ascii_digit()));
if !is_valid {
return None;
}
let key = Key::from_name(key_name)?;
Some((modifiers, key))
}
@@ -273,8 +198,9 @@ impl SoundpadGui {
}
// Play selected file on Enter
if self.key_pressed(ctx, Key::Enter) {
if let Some(path) = self.app_state.selected_file.clone() {
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
@@ -286,14 +212,13 @@ impl SoundpadGui {
self.play_file(&path, false);
}
}
}
// Iterate through dirs and files with Ctrl + Up/Down
let arrow_up_pressed = self.key_pressed(ctx, Key::ArrowUp);
let arrow_down_pressed = self.key_pressed(ctx, Key::ArrowDown);
if modifiers.ctrl && (arrow_up_pressed || arrow_down_pressed) {
if modifiers.shift && !self.app_state.dirs.is_empty() {
let mut dirs: Vec<PathBuf> = self.app_state.dirs.iter().cloned().collect();
let mut dirs: Vec<PathBuf> = self.app_state.dirs.to_vec();
dirs.sort();
let current_dir_index = self
+4 -4
View File
@@ -21,12 +21,12 @@ use pwsp::{
use rfd::FileDialog;
use std::{
error::Error,
path::PathBuf,
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
const SUPPORTED_EXTENSIONS: [&str; 12] = [
"mp3", "wav", "ogg", "flac", "mp4", "m4a", "aac", "mov", "mkv", "mka", "webm", "avi",
const SUPPORTED_EXTENSIONS: [&str; 13] = [
"mp3", "wav", "ogg", "flac", "mp4", "m4a", "aac", "mov", "mkv", "mka", "webm", "avi", "opus",
];
struct SoundpadGui {
@@ -120,7 +120,7 @@ impl SoundpadGui {
}
}
pub fn play_file(&mut self, path: &PathBuf, concurrent: bool) {
pub fn play_file(&mut self, path: &Path, concurrent: bool) {
make_request_async(Request::play(&path.to_string_lossy(), concurrent));
}
+47 -14
View File
@@ -2,7 +2,7 @@ use crate::{
types::pipewire::{DeviceType, Terminate},
utils::{
daemon::get_daemon_config,
pipewire::{create_link, get_device},
pipewire::{create_link, get_device, link_player_to_virtual_mic},
},
};
use rodio::{Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source};
@@ -58,6 +58,7 @@ pub struct AudioPlayer {
pub next_id: u32,
input_link_sender: Option<pipewire::channel::Sender<Terminate>>,
player_link_sender: Option<pipewire::channel::Sender<Terminate>>,
pub input_device_name: Option<String>,
pub volume: f32, // Master volume
@@ -74,6 +75,7 @@ impl AudioPlayer {
next_id: 1,
input_link_sender: None,
player_link_sender: None,
input_device_name: daemon_config.default_input_name.clone(),
volume: default_volume,
@@ -98,19 +100,44 @@ impl AudioPlayer {
fn drop_stream(&mut self) {
if self.stream_handle.is_some() {
self.stream_handle = None;
self.abort_player_link_thread();
}
}
fn abort_link_thread(&mut self) {
if let Some(sender) = &self.input_link_sender {
match sender.send(Terminate {}) {
Ok(_) => {
println!("Sent terminate signal to link thread");
if sender.send(Terminate {}).is_ok() {
println!("Sent terminate signal to input link thread");
self.input_link_sender = None;
} else {
eprintln!("Failed to send terminate signal to input link thread");
}
Err(_) => eprintln!("Failed to send terminate signal to link thread"),
}
}
fn abort_player_link_thread(&mut self) {
if let Some(sender) = &self.player_link_sender {
if sender.send(Terminate {}).is_ok() {
println!("Sent terminate signal to player link thread");
self.player_link_sender = None;
} else {
eprintln!("Failed to send terminate signal to player link thread");
}
}
}
async fn link_player(&mut self) -> Result<(), Box<dyn Error>> {
if self.player_link_sender.is_some() {
return Ok(());
}
match link_player_to_virtual_mic().await {
Ok(sender) => {
self.player_link_sender = Some(sender);
Ok(())
}
Err(_) => Ok(()),
}
}
async fn link_devices(&mut self) -> Result<(), Box<dyn Error>> {
@@ -227,12 +254,12 @@ impl AudioPlayer {
pub fn get_volume(&mut self, id: Option<u32>) -> Option<f32> {
if let Some(id) = id {
if let Some(sound) = self.tracks.get_mut(&id) {
return Some(sound.sink.volume());
Some(sound.sink.volume())
} else {
return None;
None
}
} else {
return Some(self.volume);
Some(self.volume)
}
}
@@ -316,6 +343,7 @@ impl AudioPlayer {
}
self.ensure_stream()?;
self.link_player().await.ok();
let id = self.next_id;
self.next_id += 1;
@@ -394,6 +422,10 @@ impl AudioPlayer {
self.link_devices().await.ok();
}
}
if self.stream_handle.is_some() && self.player_link_sender.is_none() {
self.link_player().await.ok();
}
}
// Handle looped sounds
@@ -411,11 +443,11 @@ impl AudioPlayer {
if let Some(sound) = self.tracks.get(&id) {
let path = sound.path.clone();
let handle = tokio::task::spawn_blocking(move || {
if let Ok(file) = fs::File::open(&path) {
if let Ok(source) = Decoder::try_from(file) {
if let Ok(file) = fs::File::open(&path)
&& let Ok(source) = Decoder::try_from(file)
{
return Some((id, source));
}
}
None
});
restart_futures.push(handle);
@@ -423,13 +455,14 @@ impl AudioPlayer {
}
for handle in restart_futures {
if let Ok(Some((id, source))) = handle.await {
if let Some(sound) = self.tracks.get_mut(&id) {
if let Ok(res) = handle.await
&& let Some((id, source)) = res
&& let Some(sound) = self.tracks.get_mut(&id)
{
sound.sink.append(source);
sound.sink.play();
}
}
}
self.tracks
.retain(|_, sound| !sound.sink.empty() || sound.looped);
+1 -1
View File
@@ -673,7 +673,7 @@ impl Executable for PlayHotkeyCommand {
if let Some(cmd) = parse_command(&action) {
cmd.execute().await
} else {
Response::new(false, "Unknown command in hotkey slot".to_string())
Response::new(false, "Unknown command in hotkey slot")
}
}
}
+8 -8
View File
@@ -13,11 +13,11 @@ 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() {
if !config_path.exists() {
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())?;
@@ -68,11 +68,11 @@ 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() {
if !config_path.exists() {
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 {
@@ -172,7 +172,7 @@ impl HotkeyConfig {
}
/// Returns pairs of slot names that share the same key chord.
pub fn find_conflicts(&self) -> Vec<(String, String)> {
pub fn find_conflicts(&self) -> Vec<(&str, &str)> {
let mut conflicts = vec![];
let mut chord_map: HashMap<&str, Vec<&str>> = HashMap::new();
@@ -186,7 +186,7 @@ impl HotkeyConfig {
if slots.len() > 1 {
for i in 0..slots.len() {
for j in (i + 1)..slots.len() {
conflicts.push((slots[i].to_string(), slots[j].to_string()));
conflicts.push((slots[i], slots[j]));
}
}
}
+2
View File
@@ -1,6 +1,8 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
pub const MAX_MESSAGE_SIZE: usize = 128 * 1024;
#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Request {
pub name: String,
+85
View File
@@ -122,3 +122,88 @@ pub fn parse_command(request: &Request) -> Option<Box<dyn Executable + Send>> {
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::socket::Request;
use std::collections::HashMap;
#[test]
fn test_parse_set_volume_valid() {
let mut args = HashMap::new();
args.insert("volume".to_string(), "0.5".to_string());
args.insert("id".to_string(), "1".to_string());
let request = Request {
name: "set_volume".to_string(),
args,
};
let cmd = parse_command(&request);
assert!(cmd.is_some());
}
#[test]
fn test_parse_set_volume_missing_volume() {
let mut args = HashMap::new();
args.insert("id".to_string(), "1".to_string());
let request = Request {
name: "set_volume".to_string(),
args,
};
let cmd = parse_command(&request);
assert!(cmd.is_some());
}
#[test]
fn test_parse_set_volume_invalid_volume() {
let mut args = HashMap::new();
args.insert("volume".to_string(), "not-a-float".to_string());
let request = Request {
name: "set_volume".to_string(),
args,
};
let cmd = parse_command(&request);
assert!(cmd.is_some());
}
#[test]
fn test_parse_set_volume_missing_id() {
let mut args = HashMap::new();
args.insert("volume".to_string(), "0.5".to_string());
let request = Request {
name: "set_volume".to_string(),
args,
};
let cmd = parse_command(&request);
assert!(cmd.is_some());
}
#[test]
fn test_parse_set_volume_invalid_id() {
let mut args = HashMap::new();
args.insert("id".to_string(), "not-an-int".to_string());
args.insert("volume".to_string(), "0.5".to_string());
let request = Request {
name: "set_volume".to_string(),
args,
};
let cmd = parse_command(&request);
assert!(cmd.is_some());
}
#[test]
fn test_parse_set_volume_empty_args() {
let request = Request {
name: "set_volume".to_string(),
args: HashMap::new(),
};
let cmd = parse_command(&request);
assert!(cmd.is_some());
}
}
+11 -43
View File
@@ -1,11 +1,9 @@
use crate::{
types::{
use crate::types::{
audio_player::AudioPlayer,
config::DaemonConfig,
socket::{Request, Response},
},
utils::pipewire::{create_link, get_device},
socket::{MAX_MESSAGE_SIZE, Request, Response},
};
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::{error::Error, fs};
@@ -38,44 +36,6 @@ pub fn get_daemon_config() -> DaemonConfig {
})
}
pub async fn link_player_to_virtual_mic() -> Result<(), Box<dyn Error>> {
let pwsp_daemon_output;
if let Ok(device) = get_device("pwsp-daemon").await {
pwsp_daemon_output = device;
} else {
return Err(
"Could not find alsa_playback.pwsp-daemon device, skipping device linking".into(),
);
}
let pwsp_daemon_input;
if let Ok(device) = get_device("pwsp-virtual-mic").await {
pwsp_daemon_input = device;
} else {
return Err("Could not find pwsp-virtual-mic device, skipping device linking".into());
}
let output_fl = pwsp_daemon_output
.clone()
.output_fl
.expect("Failed to get pwsp-daemon output_fl");
let output_fr = pwsp_daemon_output
.clone()
.output_fr
.expect("Failed to get pwsp-daemon output_fl");
let input_fl = pwsp_daemon_input
.clone()
.input_fl
.expect("Failed to get pwsp-daemon input_fl");
let input_fr = pwsp_daemon_input
.clone()
.input_fr
.expect("Failed to get pwsp-daemon input_fr");
create_link(output_fl, output_fr, input_fl, input_fr)?;
Ok(())
}
pub fn get_runtime_dir() -> PathBuf {
dirs::runtime_dir().unwrap_or(PathBuf::from("/run/pwsp"))
}
@@ -135,6 +95,14 @@ pub async fn make_request(request: Request) -> Result<Response, Box<dyn Error +
}
let response_len = u32::from_le_bytes(len_bytes) as usize;
if response_len > MAX_MESSAGE_SIZE {
eprintln!(
"Failed to read response from daemon: response too large ({} bytes)!",
response_len
);
return Err("Response too large".into());
}
let mut buffer = vec![0u8; response_len];
if stream.read_exact(&mut buffer).await.is_err() {
return Err("Failed to read response".into());
+3 -3
View File
@@ -112,14 +112,14 @@ pub fn start_app_state_thread(audio_player_state_shared: Arc<Mutex<AudioPlayerSt
let hotkey_res = make_request(Request::get_hotkeys())
.await
.unwrap_or_default();
if hotkey_res.status {
if let Ok(config) = serde_json::from_str::<HotkeyConfig>(&hotkey_res.message) {
if hotkey_res.status
&& let Ok(config) = serde_json::from_str::<HotkeyConfig>(&hotkey_res.message)
{
let mut guard = audio_player_state_shared
.lock()
.unwrap_or_else(|e| e.into_inner());
guard.hotkey_config = Some(config);
}
}
last_hotkey_poll = Instant::now();
}
+159 -44
View File
@@ -9,6 +9,13 @@ use tokio::{
time::{Duration, timeout},
};
pub fn setup_pipewire_context() -> Result<(MainLoopRc, ContextRc), String> {
pipewire::init();
let main_loop = MainLoopRc::new(None).map_err(|e| e.to_string())?;
let context = ContextRc::new(&main_loop, None).map_err(|e| e.to_string())?;
Ok((main_loop, context))
}
fn parse_global_object(
global_object: &GlobalObject<&DictRef>,
) -> (Option<AudioDevice>, Option<Port>) {
@@ -56,12 +63,13 @@ fn parse_global_object(
(None, None)
};
// Check if the object is a port
} else if props.get("port.direction").is_some() {
if let (Some(node_id), Some(port_id), Some(port_name)) = (
} else if props.get("port.direction").is_some()
&& let (Some(node_id), Some(port_id), Some(port_name)) = (
props.get("node.id").and_then(|id| id.parse::<u32>().ok()),
props.get("port.id").and_then(|id| id.parse::<u32>().ok()),
props.get("port.name"),
) {
)
{
let port = Port {
node_id,
port_id,
@@ -71,17 +79,21 @@ fn parse_global_object(
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>>,
) {
pipewire::init();
let main_loop = MainLoopRc::new(None).expect("Failed to initialize pipewire main loop");
let (main_loop, context) = match setup_pipewire_context() {
Ok(res) => res,
Err(e) => {
let _ = init_sender.send(Err(e));
return;
}
};
// Stop main loop on Terminate message
let _receiver = pw_receiver.attach(main_loop.loop_(), {
@@ -89,13 +101,24 @@ async fn pw_get_global_objects_thread(
move |_| _main_loop.quit()
});
let context = ContextRc::new(&main_loop, None).expect("Failed to create pipewire context");
let core = context
.connect(None)
.expect("Failed to connect to pipewire context");
let registry = core
.get_registry()
.expect("Failed to get registry from pipewire context");
let core = match context.connect(None) {
Ok(core) => core,
Err(e) => {
let _ = init_sender.send(Err(format!("Failed to connect to pipewire context: {}", e)));
return;
}
};
let registry = match core.get_registry() {
Ok(registry) => registry,
Err(e) => {
let _ = init_sender.send(Err(format!(
"Failed to get registry from pipewire context: {}",
e
)));
return;
}
};
let _listener = registry
.add_listener_local()
@@ -111,6 +134,11 @@ async fn pw_get_global_objects_thread(
})
.register();
// Signal successful initialization
if init_sender.send(Ok(())).is_err() {
return;
}
main_loop.run();
}
@@ -118,10 +146,17 @@ pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>), B
// Channels to communicate with pipewire thread
let (main_sender, mut main_receiver) = mpsc::channel(10);
let (pw_sender, pw_receiver) = pipewire::channel::channel();
let (init_sender, init_receiver) = std::sync::mpsc::sync_channel(0);
// Spawn pipewire thread in background
let _pw_thread =
tokio::spawn(async move { pw_get_global_objects_thread(main_sender, pw_receiver).await });
let _pw_thread = tokio::spawn(async move {
pw_get_global_objects_thread(main_sender, pw_receiver, init_sender).await
});
// Wait for initialization to complete
if let Err(e) = init_receiver.recv()? {
return Err(e.into());
}
let mut input_devices: HashMap<u32, AudioDevice> = HashMap::new();
let mut output_devices: HashMap<u32, AudioDevice> = HashMap::new();
@@ -146,9 +181,7 @@ pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>), B
}
Ok(None) | Err(_) => {
// Pipewire thread is finished and we can collect our devices
pw_sender
.send(Terminate {})
.expect("Failed to terminate pipewire thread");
let _ = pw_sender.send(Terminate {});
for port in ports {
let node_id = port.node_id;
@@ -196,8 +229,8 @@ pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>), B
let mut output_devices: Vec<AudioDevice> =
output_devices.values().cloned().collect();
input_devices.sort_by(|a, b| a.id.cmp(&b.id));
output_devices.sort_by(|a, b| a.id.cmp(&b.id));
input_devices.sort_by_key(|a| a.id);
output_devices.sort_by_key(|a| a.id);
return Ok((input_devices, output_devices));
}
@@ -222,15 +255,24 @@ pub async fn get_device(device_name: &str) -> Result<AudioDevice, Box<dyn Error>
pub fn create_virtual_mic() -> Result<pipewire::channel::Sender<Terminate>, Box<dyn Error>> {
let (pw_sender, pw_receiver) = pipewire::channel::channel::<Terminate>();
let (init_sender, init_receiver) = std::sync::mpsc::sync_channel(0);
let _pw_thread = thread::spawn(move || {
pipewire::init();
let main_loop = MainLoopRc::new(None).expect("Failed to initialize pipewire main loop");
let context = ContextRc::new(&main_loop, None).expect("Failed to create pipewire context");
let core = context
.connect(None)
.expect("Failed to connect to pipewire context");
let (main_loop, context) = match setup_pipewire_context() {
Ok(res) => res,
Err(e) => {
let _ = init_sender.send(Err(e));
return;
}
};
let core = match context.connect(None) {
Ok(core) => core,
Err(e) => {
let _ =
init_sender.send(Err(format!("Failed to connect to pipewire context: {}", e)));
return;
}
};
let props = properties!(
"factory.name" => "support.null-audio-sink",
@@ -242,9 +284,13 @@ pub fn create_virtual_mic() -> Result<pipewire::channel::Sender<Terminate>, Box<
"object.linger" => "false", // Destroy the node on app exit
);
let _node = core
.create_object::<pipewire::node::Node>("adapter", &props)
.expect("Failed to create virtual mic");
let _node = match core.create_object::<pipewire::node::Node>("adapter", &props) {
Ok(node) => node,
Err(e) => {
let _ = init_sender.send(Err(format!("Failed to create virtual mic: {}", e)));
return;
}
};
let _receiver = pw_receiver.attach(main_loop.loop_(), {
let _main_loop = main_loop.clone();
@@ -252,12 +298,57 @@ pub fn create_virtual_mic() -> Result<pipewire::channel::Sender<Terminate>, Box<
});
println!("Virtual mic created");
if init_sender.send(Ok(())).is_err() {
return;
}
main_loop.run();
});
if let Err(e) = init_receiver.recv()? {
return Err(e.into());
}
Ok(pw_sender)
}
pub async fn link_player_to_virtual_mic()
-> Result<pipewire::channel::Sender<Terminate>, Box<dyn Error>> {
let pwsp_daemon_output = match get_device("pwsp-daemon").await {
Ok(device) => device,
Err(_) => {
return Err(
"Could not find alsa_playback.pwsp-daemon device, skipping device linking".into(),
);
}
};
let pwsp_daemon_input = match get_device("pwsp-virtual-mic").await {
Ok(device) => device,
Err(_) => {
return Err("Could not find pwsp-virtual-mic device, skipping device linking".into());
}
};
let output_fl = match pwsp_daemon_output.output_fl {
Some(port) => port,
None => return Err("Failed to get pwsp-daemon output_fl".into()),
};
let output_fr = match pwsp_daemon_output.output_fr {
Some(port) => port,
None => return Err("Failed to get pwsp-daemon output_fr".into()),
};
let input_fl = match pwsp_daemon_input.input_fl {
Some(port) => port,
None => return Err("Failed to get pwsp-virtual-mic input_fl".into()),
};
let input_fr = match pwsp_daemon_input.input_fr {
Some(port) => port,
None => return Err("Failed to get pwsp-virtual-mic input_fr".into()),
};
create_link(output_fl, output_fr, input_fl, input_fr)
}
pub fn create_link(
output_fl: Port,
output_fr: Port,
@@ -265,15 +356,24 @@ pub fn create_link(
input_fr: Port,
) -> Result<pipewire::channel::Sender<Terminate>, Box<dyn Error>> {
let (pw_sender, pw_receiver) = pipewire::channel::channel::<Terminate>();
let (init_sender, init_receiver) = std::sync::mpsc::sync_channel(0);
let _pw_thread = thread::spawn(move || {
pipewire::init();
let main_loop = MainLoopRc::new(None).expect("Failed to initialize pipewire main loop");
let context = ContextRc::new(&main_loop, None).expect("Failed to create pipewire context");
let core = context
.connect(None)
.expect("Failed to connect to pipewire context");
let (main_loop, context) = match setup_pipewire_context() {
Ok(res) => res,
Err(e) => {
let _ = init_sender.send(Err(e));
return;
}
};
let core = match context.connect(None) {
Ok(core) => core,
Err(e) => {
let _ =
init_sender.send(Err(format!("Failed to connect to pipewire context: {}", e)));
return;
}
};
let props_fl = properties! {
"link.output.node" => format!("{}", output_fl.node_id).as_str(),
@@ -288,12 +388,20 @@ pub fn create_link(
"link.input.port" => format!("{}", input_fr.port_id).as_str(),
};
let _link_fl = core
.create_object::<Link>("link-factory", &props_fl)
.expect("Failed to create link FL");
let _link_fr = core
.create_object::<Link>("link-factory", &props_fr)
.expect("Failed to create link FR");
let _link_fl = match core.create_object::<Link>("link-factory", &props_fl) {
Ok(link) => link,
Err(e) => {
let _ = init_sender.send(Err(format!("Failed to create link FL: {}", e)));
return;
}
};
let _link_fr = match core.create_object::<Link>("link-factory", &props_fr) {
Ok(link) => link,
Err(e) => {
let _ = init_sender.send(Err(format!("Failed to create link FR: {}", e)));
return;
}
};
let _receiver = pw_receiver.attach(main_loop.loop_(), {
let _main_loop = main_loop.clone();
@@ -304,8 +412,15 @@ pub fn create_link(
"Link created: FL: {}-{} FR: {}-{}",
output_fl.node_id, input_fl.node_id, output_fr.node_id, input_fr.node_id
);
if init_sender.send(Ok(())).is_err() {
return;
}
main_loop.run();
});
if let Err(e) = init_receiver.recv()? {
return Err(e.into());
}
Ok(pw_sender)
}