Compare commits

..
105 Commits
Author SHA1 Message Date
arabianq c4028f1d56 change version to 1.1.3 2025-12-07 03:51:50 +03:00
arabianq e39883e133 cargo update 2025-12-07 03:51:01 +03:00
arabianq 7e793f428f bump rfd to 0.16.0 2025-12-07 03:50:26 +03:00
arabianq 238e0b464b fix not working with mono input devices 2025-12-07 03:49:19 +03:00
arabianq 59bee29609 change version to 1.1.2 2025-11-23 01:49:57 +03:00
arabianq 12725d0add fix incorrect volume slider width 2025-11-23 01:47:54 +03:00
arabianq bb83ac54ef disable hotkeys when some widget is focused 2025-11-23 01:46:47 +03:00
arabianq adfc0f25c4 change version to 1.1.1 2025-11-09 22:37:55 +03:00
arabianq 572aa33c95 fix: crash when setting negative position 2025-11-09 22:28:49 +03:00
arabianq 3850a9ce10 new hotkeys to select dirs, files. 2025-11-09 22:26:07 +03:00
arabianq b7dc54c2cb cargo update 2025-11-09 21:53:55 +03:00
arabianq 0357c239d5 change version to 1.1.0 2025-11-09 21:53:18 +03:00
arabianq cb1bd42f83 add 10s timeout before starting pwsp-daemon via systemd service 2025-10-31 16:39:17 +03:00
arabianq 113eb38767 update dependencies 2025-10-31 16:37:38 +03:00
arabianq ab68648ef6 change version to 1.0.3 2025-10-13 23:54:16 +03:00
arabianq e10b6f1449 call pipewire::init in every pipewire thread 2025-10-13 23:51:37 +03:00
arabianq ede5028d35 cargo update 2025-10-13 23:48:40 +03:00
arabianq f721e4612a deps: update clap 2025-10-13 23:48:05 +03:00
arabianq ef9125024c deps: update egui, eframe, egui_material_icons 2025-10-13 23:47:42 +03:00
arabianq 12c70f0edb fix: now systemd service should wait for pipewire to start 2025-10-13 23:46:15 +03:00
arabianq eae455f0b8 change pwsp version to 1.0.2 2025-10-05 23:32:03 +03:00
arabianq e0a55dffa6 update dependencies 2025-10-05 23:31:32 +03:00
arabianq 6a755ad068 use device name instead of node id to get audio device 2025-10-05 23:26:29 +03:00
arabianq 7809a8c9ff fix pwsp.spec failed to build 2025-09-26 23:39:48 +03:00
arabianq 76a5f069ab update descriptions in github actions to use new tags format 2025-09-26 23:31:51 +03:00
arabianq ae84b345a4 change pwsp.spec to use new tags format 2025-09-26 23:30:09 +03:00
arabianq cdd58a04ed change package version to 1.0.1 2025-09-26 23:29:14 +03:00
arabianq 271955c777 cargo update 2025-09-26 23:28:21 +03:00
arabianq 5b475d1f07 bump serde version to 1.0.227 2025-09-26 23:27:56 +03:00
arabianq 5509b80f3e change code to work with pipewire 0.9.2 2025-09-26 23:25:30 +03:00
arabianq 2a31865822 bump pipewire crate to version 0.9.2 2025-09-26 23:20:11 +03:00
arabianq 258467d5bc change: now, instead of the full path to the file, only its name is displayed at the top 2025-09-26 23:09:28 +03:00
arabianq 51ab5eacbc fix: too large directory names break the interface 2025-09-26 23:06:27 +03:00
arabianandGitHub 1957a5e2fd Delete scripts directory 2025-09-26 01:43:08 +03:00
arabianandGitHub 5f852343da Update README.md 2025-09-26 01:42:14 +03:00
arabianandGitHub aee48c8f8d Update release-deb.yml
fix .deb uploading
2025-09-26 01:22:35 +03:00
arabianandGitHub c63b220d92 Update release-deb.yml
change name
2025-09-26 01:17:48 +03:00
arabianandGitHub a4708f1812 Create release-deb.yml 2025-09-26 01:17:28 +03:00
arabianandGitHub 3754121ab5 Update and rename build-release.yml to release-archive.yml 2025-09-26 01:10:35 +03:00
arabianandGitHub a665939137 Update build-release.yml 2025-09-26 01:01:58 +03:00
arabianandGitHub 974fdc9411 Update build-release.yml
try to fix zip creation
2025-09-26 00:54:56 +03:00
arabianandGitHub 9a1107fb41 Update build-release.yml
change workflow_dispatch description
2025-09-26 00:50:13 +03:00
arabianandGitHub ad2c15f9e3 Update build-release.yml
add build dependencies installation
2025-09-26 00:49:40 +03:00
arabianandGitHub 6d66b57d1b Create build-release.yml 2025-09-26 00:46:28 +03:00
arabianandGitHub 869b67738c Update README.md
add installation on Arch Linux from AUR
2025-09-26 00:35:27 +03:00
arabianq af3e19d794 update README.md 2025-09-25 19:50:09 +03:00
arabianq b42498d188 remove build_rpm.sh and cargo-generate-rpm mentions. Now copr handles rpm builds 2025-09-25 18:48:19 +03:00
arabianq 60975110da fix pwsp.spec 2025-09-25 18:40:06 +03:00
arabianq 05f243b322 add missing files to pwsp.spec 2025-09-25 17:54:10 +03:00
arabianq 0188cac476 specify BuildRequires and %install manually in pwsp.spec 2025-09-25 17:15:37 +03:00
arabianq 3e93ba14e1 update pwsp.spec file 2025-09-25 17:01:34 +03:00
arabianq 939dbea12b update pwsp.spec file 2025-09-25 16:57:41 +03:00
arabianq a4d3111c6d update pwsp.spec file 2025-09-25 16:55:26 +03:00
arabianq 9053056dfa fix Source url in pwsp.spec 2025-09-25 16:51:01 +03:00
arabianq e9e3f67735 add pwsp.spec 2025-09-25 16:47:26 +03:00
arabianq 9238e6563b remove generate_rpm_spec.sh 2025-09-25 16:46:33 +03:00
arabianq 939b01b587 use --path . in generate_rpm_spec 2025-09-25 16:44:18 +03:00
arabianq 489878c813 add rust-pwsp.spec 2025-09-25 16:39:19 +03:00
arabianq c1c1bfd487 add generate_rpm_spec.sh and rust-pwsp.spec 2025-09-25 16:38:25 +03:00
arabianq 2028a728e0 fix .rpm configuration 2025-09-24 23:49:36 +03:00
arabianq e627e71cf6 new README 2025-09-24 23:39:30 +03:00
arabianq dfe7a7e971 1.0.0 rewrite 2025-09-24 22:51:34 +03:00
arabianq 0535744b30 clear README 2025-09-14 01:05:24 +03:00
arabianq 3170e9f30a remove everything 2025-09-14 01:05:07 +03:00
arabianq 7ddd2c0dab change version to 0.1.8 2025-09-13 18:31:22 +03:00
arabianq 8590dcceae cargo update 2025-09-13 18:30:57 +03:00
arabianq fb6714aeee better imports 2025-09-13 18:30:19 +03:00
arabianq 34886e44a6 PlayerState::PLAYING -> PlayerState::Playing; PlayerState::PAUSED -> PlayerState::Paused 2025-09-13 18:28:30 +03:00
arabianq ede04dd3f8 cargo clippy 2025-09-13 18:27:22 +03:00
arabianq ad892dda29 bump egui and eframe to 0.32.3 2025-09-13 18:26:12 +03:00
arabianq 3a02e9991c change version to 0.1.7 2025-09-05 22:20:34 +03:00
arabianq 5d9d20417b update egui and eframe to 0.32.2 2025-09-05 22:19:26 +03:00
arabianq d1994d7226 change version to 0.1.6 2025-08-21 16:42:36 +03:00
arabianq b526d67d2f update deps 2025-08-21 16:42:22 +03:00
arabianq c641ec4f31 fix crash when seeking 2025-08-21 16:40:37 +03:00
arabianq 5bce45c97f Merge branch 'main' of github.com:arabianq/pipewire-soundpad 2025-07-21 12:20:07 +03:00
arabianq adfdf7db69 version -> 0.1.5 2025-07-21 12:19:50 +03:00
arabianq 7a7e0f741a update dependencies 2025-07-21 12:19:31 +03:00
arabianq d86cd6c5d3 rodio -> 0.21.1 2025-07-21 12:15:01 +03:00
arabianandGitHub 61ba71d38e Update README.md 2025-07-15 20:30:56 +03:00
arabianq 7116ccf487 version -> 0.1.4 2025-07-12 00:51:57 +03:00
arabianq 654ef0d973 fix incorrect dependency for deb package 2025-07-12 00:51:31 +03:00
arabianq e9756b0681 update Cargo.toml 2025-07-12 00:27:50 +03:00
arabianq f72fe588e3 egui, eframe -> 0.32.0; egui_material_icons -> 0.4.0 2025-07-12 00:24:47 +03:00
arabianq 53533c35ef update build scripts 2025-07-12 00:22:52 +03:00
arabianq 382ddb0ff2 update README 2025-07-12 00:16:24 +03:00
arabianq 714f81ab34 update screenshot.png 2025-07-12 00:14:22 +03:00
arabianq 3c028972fe new build scripts and .desktop file 2025-07-08 05:01:44 +03:00
arabianq 7e3ff23156 version -> 0.1.31 2025-07-06 22:55:54 +03:00
arabianq 09cec44a5b maximum volume -> 1.0 from 5.0 2025-07-06 22:55:27 +03:00
arabianq ead17d26a9 version -> 0.1.3 2025-07-06 22:28:05 +03:00
arabianq dfa1cfbb15 now use single settings file instead of many; minor refactoring 2025-07-06 22:26:33 +03:00
arabianq a70c991711 new app::run function 2025-07-06 21:08:50 +03:00
arabianq 8c0704ce57 move creation of dirs to the separate function 2025-07-06 21:03:09 +03:00
arabianq 00196bfe7f split main.rs into main.rs and app.rs 2025-07-06 20:59:35 +03:00
arabianq 428fb4064d bump pwsp version to 0.1.2 2025-04-27 01:15:00 +03:00
arabianq e2b003be31 bump edition to 2024 2025-04-27 01:14:38 +03:00
arabianq 79176432de fixed setting player position to 0 2025-04-27 01:09:07 +03:00
arabianq 9e0a307106 bump egui, eframe and rfd versions 2025-04-27 01:01:24 +03:00
arabianq 428565594d v0.1.1 - minor fixes 2025-02-11 00:11:27 +03:00
arabianq 1d0e3036e9 Added unlink() function 2025-02-10 22:54:27 +03:00
arabianq b31f4c8c45 Update README.md 2025-02-10 17:40:10 +03:00
arabianq 84b6f8ce20 Added Cargo.lock 2025-02-10 02:58:14 +03:00
arabianq c797b204a2 Merge branch 'main' of github.com:arabianq/pipewire-soundpad 2025-02-09 23:41:48 +03:00
arabianandGitHub faa29d8805 Create LICENSE 2025-02-09 13:43:23 +03:00
96 changed files with 3675 additions and 19790 deletions
-1
View File
@@ -1 +0,0 @@
custom: ['https://dalink.to/arabianq', 'https://boosty.to/arabian', 'https://sponsr.ru/notultranx/']
-29
View File
@@ -1,29 +0,0 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "weekly"
groups:
egui-stack:
patterns:
- "egui*"
- "eframe"
serde-stack:
patterns:
- "serde*"
tokio-stack:
patterns:
- "tokio*"
- "reqwest"
clap-stack:
patterns:
- "clap*"
-257
View File
@@ -1,257 +0,0 @@
name: Build
permissions:
contents: write
packages: write
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
workflow_dispatch:
jobs:
build-and-package:
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)
run: |
sudo apt-get update
sudo apt-get install -y \
zip jq \
libpipewire-0.3-dev \
libclang-dev \
libasound2-dev \
libdbus-1-dev \
libssl-dev \
pkg-config
- name: Checkout code
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: stable
cache: false
- name: Rust Cache
uses: swatinem/rust-cache@v2
with:
prefix-key: "v3-${{ matrix.runner }}"
- name: Run tests
run: cargo test --locked
- name: Build all binaries
run: cargo build --locked
- name: Extract all binary names
id: cargo-meta
run: |
set -euo pipefail
BIN_NAMES=$(cargo metadata --no-deps --format-version 1 \
| 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: Package ZIP archive
shell: bash
run: |
set -euo pipefail
COMMIT_SHA="${{ github.sha }}"
ARCHIVE_NAME="pwsp-${COMMIT_SHA}-linux-${{ matrix.arch }}.zip"
echo "Creating archive: $ARCHIVE_NAME"
FILES=()
while IFS= read -r BIN; do
[ -z "$BIN" ] && continue
FILES+=("target/debug/$BIN")
done <<< "${{ steps.cargo-meta.outputs.bin_names }}"
if [ "${#FILES[@]}" -eq 0 ]; then
echo "Error: no binaries were discovered via cargo metadata." >&2
exit 1
fi
zip -j "$ARCHIVE_NAME" "${FILES[@]}"
- name: Upload ZIP artifact
uses: actions/upload-artifact@v7
with:
name: archive-${{ matrix.arch }}
path: pwsp-*.zip
retention-days: 7
- name: Cache cargo-deb
id: cache-cargo-deb
uses: actions/cache@v6
with:
path: ~/.cargo/bin/cargo-deb
key: ${{ runner.os }}-${{ runner.arch }}-cargo-deb-v1
- name: Install cargo-deb
if: steps.cache-cargo-deb.outputs.cache-hit != 'true'
run: cargo install --locked cargo-deb
- name: Create .deb package
shell: bash
run: |
set -euo pipefail
export PATH="$HOME/.cargo/bin:$PATH"
if [ "${{ github.event_name }}" == "push" ] && [ "${{ github.ref }}" == "refs/heads/main" ]; then
BASE_VERSION=$(grep '^version =' Cargo.toml | head -1 | cut -d'"' -f2)
GIT_REV=$(git rev-parse --short HEAD)
DATE=$(date +%Y%m%d)
DEB_VER="${BASE_VERSION}+git${DATE}.${GIT_REV}"
echo "Building nightly deb version: $DEB_VER"
cargo-deb -p pwsp-gui --no-build --no-strip --profile dev --deb-version "$DEB_VER"
else
cargo-deb -p pwsp-gui --no-build --no-strip --profile dev
fi
- name: Upload .deb artifact
uses: actions/upload-artifact@v7
with:
name: deb-packages-${{ matrix.arch }}
path: target/debian/*.deb
retention-days: 7
deploy-nightly-apt:
needs: build-and-package
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
concurrency: gh-pages-deploy
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Setup GPG
id: gpg
uses: crazy-max/ghaction-import-gpg@v7
with:
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.GPG_PASSPHRASE }}
- name: Download DEB artifacts
uses: actions/download-artifact@v8
with:
pattern: deb-packages-*
path: debs
merge-multiple: true
- name: Checkout existing gh-pages
uses: actions/checkout@v7
with:
ref: gh-pages
path: gh-pages-branch
continue-on-error: true
- name: Generate Nightly APT Repository
run: |
sudo apt-get update
sudo apt-get install -y reprepro
mkdir -p apt-repo
if [ -d "gh-pages-branch/apt" ]; then
cp -r gh-pages-branch/apt/* apt-repo/ || true
fi
mkdir -p apt-repo/conf
cat <<EOF > apt-repo/conf/distributions
Origin: arabianq
Label: PipeWire Soundpad
Codename: stable
Architectures: amd64 arm64
Components: main
Description: APT Repository for PWSP
SignWith: ${{ steps.gpg.outputs.fingerprint }}
Origin: arabianq
Label: PipeWire Soundpad
Codename: nightly
Architectures: amd64 arm64
Components: main
Description: Nightly APT Repository for PWSP
SignWith: ${{ steps.gpg.outputs.fingerprint }}
EOF
gpg --armor --export ${{ steps.gpg.outputs.fingerprint }} > apt-repo/pubkey.gpg
cd apt-repo
for deb in ../debs/*.deb; do
if [ -f "$deb" ]; then
echo "Adding $deb to nightly APT repository..."
reprepro includedeb nightly "$deb"
fi
done
- name: Deploy APT Repo to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./apt-repo
destination_dir: apt
keep_files: true
flatpak-build:
if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'
strategy:
matrix:
include:
- arch: x86_64
runner: ubuntu-latest
- arch: aarch64
runner: ubuntu-24.04-arm
fail-fast: false
runs-on: ${{ matrix.runner }}
container:
image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-25.08
options: --privileged
steps:
- uses: actions/checkout@v7
- name: Build Flatpak
uses: flatpak/flatpak-github-actions/flatpak-builder@v6
with:
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 }}
trigger-copr-nightly:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Install copr-cli
run: pip install copr-cli
- name: Trigger Copr Build
env:
COPR_CONFIG: ${{ secrets.COPR_CONFIG }}
run: |
mkdir -p ~/.config
echo "$COPR_CONFIG" > ~/.config/copr
copr-cli buildscm --nowait \
--clone-url https://github.com/arabianq/pipewire-soundpad.git \
--commit ${{ github.sha }} \
--spec packages/rpm/pwsp-git.spec \
arabianq/pwsp
-36
View File
@@ -1,36 +0,0 @@
name: Dependabot Flatpak Cargo Sources
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- "Cargo.lock"
jobs:
update-cargo-sources:
if: github.actor == 'dependabot[bot]'
runs-on: ubuntu-latest
permissions:
contents: write # Needed to push back to the branch
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
ref: ${{ github.head_ref }}
- name: Setup Python
uses: actions/setup-python@v7
with:
python-version: "3.x"
- name: Install dependencies
run: pip install aiohttp tomlkit
- name: Generate cargo-sources.json
run: bash ./scripts/generate-sources.sh
- name: Commit and push changes
uses: stefanzweifel/git-auto-commit-action@v7
with:
commit_message: "chore(flatpak): automatically update cargo-sources.json"
file_pattern: "packages/flatpak/cargo-sources.json"
-186
View File
@@ -1,186 +0,0 @@
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:
build-flatpak:
name: Build Flatpak (${{ matrix.arch }})
strategy:
matrix:
include:
- arch: x86_64
runner: ubuntu-latest
- arch: aarch64
runner: ubuntu-24.04-arm
fail-fast: false
runs-on: ${{ matrix.runner }}
permissions:
contents: read
container:
image: ghcr.io/andyholmes/flatter/freedesktop:25.08
options: --privileged
steps:
- name: Checkout
uses: actions/checkout@v7
with:
ref: ${{ inputs.tag_name || github.ref }}
- name: Setup GPG
id: gpg
uses: crazy-max/ghaction-import-gpg@v7
with:
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.GPG_PASSPHRASE }}
- name: 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: |
sed -i '/^branch:/d' packages/flatpak/ru.arabianq.pwsp.yaml
sed -i '/^default-branch:/d' packages/flatpak/ru.arabianq.pwsp.yaml
echo "branch: ${{ steps.set_branch.outputs.branch }}" >> packages/flatpak/ru.arabianq.pwsp.yaml
echo "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
flatpak install -y flathub 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: true
upload-pages-artifact: false
arch: ${{ matrix.arch }}
cache: true
prepare-repo:
name: Prepare OSTree Repo
needs: build-flatpak
runs-on: ubuntu-latest
concurrency: gh-pages-deploy
container:
image: ghcr.io/andyholmes/flatter/freedesktop:25.08
options: --privileged
permissions:
contents: write # Needed to upload files to GitHub Release
steps:
- name: Download Flatpak Bundles
uses: actions/download-artifact@v8
with:
path: bundles
pattern: ru.arabianq.pwsp-*
- name: Checkout existing gh-pages
uses: actions/checkout@v7
with:
ref: gh-pages
path: gh-pages-branch
continue-on-error: true
- name: Setup GPG
id: gpg
uses: crazy-max/ghaction-import-gpg@v7
with:
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.GPG_PASSPHRASE }}
- name: Update OSTree Repo and Prepare Release Bundles
run: |
mkdir -p release-bundles
# Inherit existing OSTree repository to preserve previous branches (e.g. stable vs nightly)
# Only inherit if objects/ exists, otherwise it's a corrupted/empty repo due to Git ignoring empty folders
if [ -d "gh-pages-branch/objects" ]; then
echo "Found existing OSTree repository. Copying..."
cp -r gh-pages-branch pages-repo
# Clean up the .git folder so it doesn't interfere
rm -rf pages-repo/.git
# Recreate empty OSTree directories that Git ignored when pushing previously
mkdir -p pages-repo/refs/heads pages-repo/refs/mirrors pages-repo/refs/remotes \
pages-repo/objects pages-repo/state pages-repo/extensions pages-repo/tmp
else
echo "Initializing new OSTree repository..."
mkdir -p pages-repo
ostree init --mode=archive-z2 --repo=pages-repo
fi
shopt -s nullglob
for dir in bundles/ru.arabianq.pwsp-*; do
arch=$(basename "$dir" | sed 's/ru.arabianq.pwsp-//')
# Find the flatpak file inside the dir
bundle=$(find "$dir" -name "*.flatpak" | head -n 1)
if [ -n "$bundle" ]; then
echo "Importing $bundle for architecture $arch..."
flatpak build-import-bundle pages-repo "$bundle"
# Copy with a unique name for GitHub Release
cp "$bundle" "release-bundles/ru.arabianq.pwsp-${arch}.flatpak"
fi
done
# Update summary and sign the repository
flatpak build-update-repo --generate-static-deltas --gpg-sign=${{ steps.gpg.outputs.fingerprint }} pages-repo
# Export GPG public key for manual import
gpg --export ${{ steps.gpg.outputs.fingerprint }} > pages-repo/pwsp.gpg
# Generate a .flatpakrepo file for easy installation
GPG_BASE64=$(gpg --export ${{ steps.gpg.outputs.fingerprint }} | base64 -w 0)
cat <<EOF | sed 's/^ *//' > pages-repo/pwsp.flatpakrepo
[Flatpak Repo]
Title=PipeWire Soundpad
Url=https://arabianq.github.io/pipewire-soundpad/
Homepage=https://github.com/arabianq/pipewire-soundpad
Comment=PipeWire Soundpad Flatpak Repository
Description=Official repository for PWSP
GPGKey=${GPG_BASE64}
EOF
- name: Deploy Flatpak Repo to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./pages-repo
destination_dir: .
keep_files: true
- name: Upload bundles to GitHub Release
if: github.event_name == 'release'
uses: softprops/action-gh-release@v3
with:
files: release-bundles/*.flatpak
tag_name: ${{ github.event.release.tag_name }}
+140
View File
@@ -0,0 +1,140 @@
name: Release archive
permissions:
contents: write
packages: write
on:
release:
types: [created]
workflow_dispatch:
inputs:
tag:
description: 'Tag to attach assets to (e.g. v1.0.0)'
required: false
jobs:
build-and-release:
runs-on: ubuntu-latest
steps:
- name: Install apt deps (jq/zip + dev-libs)
run: |
sudo apt-get update
sudo apt-get install -y \
zip jq \
libpipewire-0.3-dev \
libclang-dev \
libasound2-dev
- name: Determine tag to use
id: tag
run: |
set -euo pipefail
# приоритет 1: входной параметр workflow_dispatch
INPUT_TAG="${{ github.event.inputs.tag || '' }}"
if [ -n "$INPUT_TAG" ]; then
echo "Using input tag: $INPUT_TAG"
echo "tag=$INPUT_TAG" >> $GITHUB_OUTPUT
exit 0
fi
# приоритет 2: если запущено событием release
EVENT_TAG="${{ github.event.release.tag_name || '' }}"
if [ -n "$EVENT_TAG" ]; then
echo "Using event tag: $EVENT_TAG"
echo "tag=$EVENT_TAG" >> $GITHUB_OUTPUT
exit 0
fi
# приоритет 3: если GITHUB_REF — refs/tags/...
if [[ "${GITHUB_REF:-}" =~ ^refs/tags/(.+)$ ]]; then
echo "Using GITHUB_REF tag: ${BASH_REMATCH[1]}"
echo "tag=${BASH_REMATCH[1]}" >> $GITHUB_OUTPUT
exit 0
fi
# приоритет 4: пробуем получить последний релиз через API
echo "No tag in input/event/GITHUB_REF — querying latest release via API..."
LATEST_JSON=$(curl -sSf -H "Authorization: Bearer $GITHUB_TOKEN" -H "Accept: application/vnd.github+json" "https://api.github.com/repos/${{ github.repository }}/releases/latest" || true)
TAG_NAME=$(echo "$LATEST_JSON" | jq -r '.tag_name // empty')
if [ -n "$TAG_NAME" ]; then
echo "Found latest release tag: $TAG_NAME"
echo "tag=$TAG_NAME" >> $GITHUB_OUTPUT
exit 0
fi
echo "No tag found"
echo "tag=" >> $GITHUB_OUTPUT
- name: Fail if no tag determined
if: ${{ steps.tag.outputs.tag == '' }}
run: |
echo "ERROR: No tag determined. Provide a tag when running manually or ensure a release exists."
exit 1
- name: Checkout code at tag
uses: actions/checkout@v4
with:
ref: ${{ steps.tag.outputs.tag || github.ref }}
fetch-depth: 0
- name: Setup Rust toolchain
uses: actions-rs/toolchain@v1
with:
toolchain: stable
- name: Extract all binary names
id: cargo-meta
run: |
set -euo pipefail
BIN_NAMES=$(cargo metadata --no-deps --format-version 1 \
| jq -r '.packages[0].targets[] | select(.kind[] | contains("bin")) | .name')
# сохраним построчно в выход
echo "bin_names<<EOF" >> $GITHUB_OUTPUT
echo "$BIN_NAMES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Build all release binaries
run: cargo build --release --locked
- name: Package all binaries into one archive
shell: bash
run: |
set -euo pipefail
TAG="${{ steps.tag.outputs.tag }}"
ARCHIVE_NAME="pwsp-${TAG}-linux-x64.zip"
echo "Creating archive: $ARCHIVE_NAME"
# читаем построчно список бинарников и формируем массив файлов
FILES=()
while IFS= read -r BIN; do
[ -z "$BIN" ] && continue
FILES+=("target/release/$BIN")
done <<< "${{ steps.cargo-meta.outputs.bin_names }}"
if [ "${#FILES[@]}" -eq 0 ]; then
echo "Error: no binaries were discovered via cargo metadata." >&2
exit 1
fi
# проверим, что все бинарники действительно есть
for f in "${FILES[@]}"; do
if [ ! -f "$f" ]; then
echo "Error: expected binary not found: $f" >&2
exit 1
fi
echo "Will add: $f"
done
# создаём архив с бинарниками внутри как просто pwsp-gui, pwsp-daemon, pwsp-cli
zip -j "$ARCHIVE_NAME" "${FILES[@]}"
- name: Upload release archive
uses: softprops/action-gh-release@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
tag_name: ${{ steps.tag.outputs.tag }}
files: |
pwsp-*.zip
+102
View File
@@ -0,0 +1,102 @@
name: Release deb
permissions:
contents: write
packages: write
on:
release:
types: [created]
workflow_dispatch:
inputs:
tag:
description: 'Tag to attach assets to (e.g. v1.0.0)'
required: false
jobs:
build-and-release:
runs-on: ubuntu-latest
steps:
- name: Install apt deps (jq/zip + dev-libs)
run: |
sudo apt-get update
sudo apt-get install -y \
zip jq \
libpipewire-0.3-dev \
libclang-dev \
libasound2-dev
- name: Determine tag to use
id: tag
run: |
set -euo pipefail
INPUT_TAG="${{ github.event.inputs.tag || '' }}"
if [ -n "$INPUT_TAG" ]; then
echo "Using input tag: $INPUT_TAG"
echo "tag=$INPUT_TAG" >> $GITHUB_OUTPUT
exit 0
fi
EVENT_TAG="${{ github.event.release.tag_name || '' }}"
if [ -n "$EVENT_TAG" ]; then
echo "Using event tag: $EVENT_TAG"
echo "tag=$EVENT_TAG" >> $GITHUB_OUTPUT
exit 0
fi
if [[ "${GITHUB_REF:-}" =~ ^refs/tags/(.+)$ ]]; then
echo "Using GITHUB_REF tag: ${BASH_REMATCH[1]}"
echo "tag=${BASH_REMATCH[1]}" >> $GITHUB_OUTPUT
exit 0
fi
echo "No tag in input/event/GITHUB_REF — querying latest release via API..."
LATEST_JSON=$(curl -sSf -H "Authorization: Bearer $GITHUB_TOKEN" -H "Accept: application/vnd.github+json" "https://api.github.com/repos/${{ github.repository }}/releases/latest" || true)
TAG_NAME=$(echo "$LATEST_JSON" | jq -r '.tag_name // empty')
if [ -n "$TAG_NAME" ]; then
echo "Found latest release tag: $TAG_NAME"
echo "tag=$TAG_NAME" >> $GITHUB_OUTPUT
exit 0
fi
echo "No tag found"
echo "tag=" >> $GITHUB_OUTPUT
- name: Fail if no tag determined
if: ${{ steps.tag.outputs.tag == '' }}
run: |
echo "ERROR: No tag determined. Provide a tag when running manually or ensure a release exists."
exit 1
- name: Checkout code at tag
uses: actions/checkout@v4
with:
ref: ${{ steps.tag.outputs.tag || github.ref }}
fetch-depth: 0
- name: Setup Rust toolchain
uses: actions-rs/toolchain@v1
with:
toolchain: stable
- name: Build all release binaries
run: cargo build --release --locked
- name: Install cargo-deb and create .deb
shell: bash
run: |
set -euo pipefail
cargo install --locked cargo-deb
export PATH="$HOME/.cargo/bin:$PATH"
cargo-deb
- name: Upload .deb(s) to release
uses: softprops/action-gh-release@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
tag_name: ${{ steps.tag.outputs.tag }}
files: |
target/debian/*.deb
-272
View File
@@ -1,272 +0,0 @@
name: Release
permissions:
contents: write
packages: write
on:
release:
types: [created]
workflow_dispatch:
inputs:
tag:
description: "Tag to attach assets to (e.g. v1.0.0)"
required: false
jobs:
prepare:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.tag.outputs.tag }}
steps:
- name: Determine tag to use
id: tag
run: |
set -euo pipefail
INPUT_TAG="${{ github.event.inputs.tag || '' }}"
EVENT_TAG="${{ github.event.release.tag_name || '' }}"
if [ -n "$INPUT_TAG" ]; then
echo "tag=$INPUT_TAG" >> $GITHUB_OUTPUT
elif [ -n "$EVENT_TAG" ]; then
echo "tag=$EVENT_TAG" >> $GITHUB_OUTPUT
elif [[ "${GITHUB_REF:-}" =~ ^refs/tags/(.+)$ ]]; then
echo "tag=${BASH_REMATCH[1]}" >> $GITHUB_OUTPUT
else
LATEST_JSON=$(curl -sSf -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" -H "Accept: application/vnd.github+json" "https://api.github.com/repos/${{ github.repository }}/releases/latest" || true)
TAG_NAME=$(echo "$LATEST_JSON" | jq -r '.tag_name // empty')
if [ -n "$TAG_NAME" ]; then
echo "tag=$TAG_NAME" >> $GITHUB_OUTPUT
else
echo "tag=" >> $GITHUB_OUTPUT
fi
fi
- name: Fail if no tag determined
if: ${{ steps.tag.outputs.tag == '' }}
run: |
echo "ERROR: No tag determined."
exit 1
build-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
run: |
sudo apt-get update
sudo apt-get install -y \
zip jq \
libpipewire-0.3-dev \
libclang-dev \
libasound2-dev \
libdbus-1-dev \
libssl-dev \
pkg-config
- name: Checkout code at tag
uses: actions/checkout@v7
with:
ref: ${{ needs.prepare.outputs.tag }}
fetch-depth: 0
- name: Setup Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: stable
cache: false
- name: Rust Cache
uses: swatinem/rust-cache@v2
with:
prefix-key: "v3-${{ matrix.runner }}"
- name: Extract all binary names
id: cargo-meta
run: |
set -euo pipefail
BIN_NAMES=$(cargo metadata --no-deps --format-version 1 \
| 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 ZIP archive
shell: bash
run: |
set -euo pipefail
TAG="${{ needs.prepare.outputs.tag }}"
ARCHIVE_NAME="pwsp-${TAG}-linux-${{ matrix.arch }}.zip"
FILES=()
while IFS= read -r BIN; do
[ -z "$BIN" ] && continue
FILES+=("target/release/$BIN")
done <<< "${{ steps.cargo-meta.outputs.bin_names }}"
zip -j "$ARCHIVE_NAME" "${FILES[@]}"
- name: Upload ZIP artifact
uses: actions/upload-artifact@v7
with:
name: zip-archive-${{ matrix.arch }}
path: pwsp-*.zip
retention-days: 1
- name: Cache cargo-deb
id: cache-cargo-deb
uses: actions/cache@v6
with:
path: ~/.cargo/bin/cargo-deb
key: ${{ runner.os }}-${{ runner.arch }}-cargo-deb-v1
- name: Install cargo-deb
if: steps.cache-cargo-deb.outputs.cache-hit != 'true'
run: cargo install --locked cargo-deb
- name: Create .deb package
shell: bash
run: |
set -euo pipefail
export PATH="$HOME/.cargo/bin:$PATH"
cargo-deb -p pwsp-gui --no-build
- name: Upload DEB artifact
uses: actions/upload-artifact@v7
with:
name: deb-package-${{ matrix.arch }}
path: target/debian/*.deb
retention-days: 1
publish-release:
needs: [prepare, build-release]
runs-on: ubuntu-latest
steps:
- name: Download all artifacts
uses: actions/download-artifact@v8
with:
path: ./dist
merge-multiple: true
- name: Upload artifacts to Release
uses: softprops/action-gh-release@v3
with:
token: ${{ secrets.GITHUB_TOKEN }}
tag_name: ${{ needs.prepare.outputs.tag }}
files: |
./dist/pwsp-*.zip
./dist/*.deb
trigger-copr:
needs: prepare
runs-on: ubuntu-latest
steps:
- name: Install copr-cli
run: pip install copr-cli
- name: Trigger Copr Build
env:
COPR_CONFIG: ${{ secrets.COPR_CONFIG }}
run: |
mkdir -p ~/.config
echo "$COPR_CONFIG" > ~/.config/copr
copr-cli buildscm --nowait \
--clone-url https://github.com/arabianq/pipewire-soundpad.git \
--commit ${{ needs.prepare.outputs.tag }} \
--spec packages/rpm/pwsp.spec \
arabianq/pwsp
build-apt-repo:
needs: [prepare, build-release]
runs-on: ubuntu-latest
concurrency: gh-pages-deploy
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Setup GPG
id: gpg
uses: crazy-max/ghaction-import-gpg@v7
with:
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.GPG_PASSPHRASE }}
- name: Download DEB artifacts
uses: actions/download-artifact@v8
with:
pattern: deb-package-*
path: debs
merge-multiple: true
- name: Checkout existing gh-pages
uses: actions/checkout@v7
with:
ref: gh-pages
path: gh-pages-branch
continue-on-error: true
- name: Generate APT Repository
run: |
sudo apt-get update
sudo apt-get install -y reprepro
mkdir -p apt-repo
# Restore existing repository first so we don't lose old packages
if [ -d "gh-pages-branch/apt" ]; then
cp -r gh-pages-branch/apt/* apt-repo/ || true
fi
# Always rewrite the config and public key to ensure they are up-to-date
mkdir -p apt-repo/conf
cat <<EOF > apt-repo/conf/distributions
Origin: arabianq
Label: PipeWire Soundpad
Codename: stable
Architectures: amd64 arm64
Components: main
Description: APT Repository for PWSP
SignWith: ${{ steps.gpg.outputs.fingerprint }}
Origin: arabianq
Label: PipeWire Soundpad
Codename: nightly
Architectures: amd64 arm64
Components: main
Description: Nightly APT Repository for PWSP
SignWith: ${{ steps.gpg.outputs.fingerprint }}
EOF
gpg --armor --export ${{ steps.gpg.outputs.fingerprint }} > apt-repo/pubkey.gpg
cd apt-repo
for deb in ../debs/*.deb; do
if [ -f "$deb" ]; then
echo "Adding $deb to APT repository..."
reprepro includedeb stable "$deb"
fi
done
- name: Deploy APT Repo to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./apt-repo
destination_dir: apt
keep_files: true
-2
View File
@@ -1,4 +1,2 @@
/target /target
.idea .idea
packages/aur/bin/.git
packages/aur/standart/.git
View File
Generated
+886 -2082
View File
File diff suppressed because it is too large Load Diff
+35 -65
View File
@@ -1,86 +1,46 @@
[workspace] [package]
members = [ name = "pwsp"
"pwsp-lib", version = "1.1.3"
"pwsp-daemon",
"pwsp-cli",
"pwsp-gui"
]
resolver = "2"
[workspace.package]
version = "1.15.0"
edition = "2024" edition = "2024"
authors = ["arabian"] authors = ["arabian"]
description = "PWSP lets you play audio files through your microphone. Has both CLI and GUI clients."
readme = "README.md"
homepage = "https://pwsp.arabianq.ru" homepage = "https://pwsp.arabianq.ru"
repository = "https://github.com/arabianq/pipewire-soundpad" repository = "https://github.com/arabianq/pipewire-soundpad"
license = "MIT" license = "MIT"
description = "PWSP lets you play audio files through your microphone. Has both CLI and GUI clients."
keywords = ["soundpad", "pipewire", "linux", "cli", "gui"] keywords = ["soundpad", "pipewire", "linux", "cli", "gui"]
[workspace.dependencies]
pwsp-lib = { path = "pwsp-lib" }
pwsp-daemon = { path = "pwsp-daemon" }
pwsp-cli = { path = "pwsp-cli" }
pwsp-gui = { path = "pwsp-gui" }
tokio = { version = "1.52.3", features = ["full"] } [dependencies]
tokio = { version = "1.48.0", features = ["full"] }
futures = { version = "0.3.31", features = ["thread-pool"] }
async-trait = "0.1.89" async-trait = "0.1.89"
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149" serde_json = "1.0.145"
clap = { version = "4.6.1", default-features = false, features = [
"std",
"suggestions",
"help",
"usage",
"error-context",
"derive",
] }
clap = { version = "4.5.51", default-features = false, features = ["std", "suggestions", "help", "usage", "error-context", "derive"] }
dirs = "6.0.0" dirs = "6.0.0"
itertools = "0.15.0"
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.1"
anyhow = "1.0.102"
rustix = { version = "1.1.4", features = ["process"] }
rust-i18n = "4.0.0" rodio = { version = "0.21.1", default-features = false, features = ["symphonia-all", "playback"] }
sys-locale = "0.3.2" pipewire = "0.9.2"
rfd = "0.16.0"
rodio = { git = "https://github.com/RustAudio/rodio.git", rev = "c5f1e94290922fe4ee963ce6f85754ea6ba36243", default-features = false, features = [ egui = { version = "0.33.0", default-features = false, features = ["default_fonts", "rayon"] }
"symphonia-all", eframe = { version = "0.33.0", default-features = false, features = ["default_fonts", "glow", "x11", "wayland"] }
"symphonia-libopus", egui_material_icons = "0.5.0"
"playback",
] }
pipewire = "0.10.0"
egui = { version = "0.35.0", default-features = false, features = [ [[bin]]
"default_fonts", name = "pwsp-daemon"
"rayon", path = "src/bin/daemon.rs"
] }
eframe = { version = "0.35.0", default-features = false, features = [
"default_fonts",
"glow",
"x11",
"wayland",
] }
egui_extras = "0.35.0"
egui_material_icons = "0.7.0"
egui_dnd = "0.16.0"
reqwest = { version = "0.13.4", default-features = false, features = ["native-tls"] } [[bin]]
percent-encoding = "2.3.2" name = "pwsp-cli"
path = "src/bin/cli.rs"
[profile.dev] [[bin]]
name = "pwsp-gui"
[profile.dev.package."*"] path = "src/main.rs"
opt-level = 3
debug = false
[profile.release] [profile.release]
strip = true strip = true
@@ -88,3 +48,13 @@ lto = true
codegen-units = 1 codegen-units = 1
opt-level = "z" opt-level = "z"
panic = "abort" panic = "abort"
[package.metadata.deb]
assets = [
["target/release/pwsp-daemon", "usr/bin/", "755"],
["target/release/pwsp-cli", "usr/bin/", "755"],
["target/release/pwsp-gui", "usr/bin/", "755"],
["assets/pwsp-gui.desktop", "usr/share/applications/pwsp.desktop", "644"],
["assets/icon.png", "usr/share/icons/hicolor/256x256/apps/pwsp.png", "644"],
["assets/pwsp-daemon.service", "usr/lib/systemd/user/pwsp-daemon.service", "644"],
]
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2026 arabianq Copyright (c) 2025 Tarasov Alexander
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+126 -160
View File
@@ -1,219 +1,185 @@
# PipeWire Soundpad (PWSP) 🎵 # **🎵 Pipewire Soundpad (PWSP)**
<table border="0"> **PipeWire Soundpad (PWSP)** is a simple yet powerful **soundboard application** written in **Rust**. It provides a
<tr> user-friendly graphical interface for **managing and playing audio files, directing their output directly to the virtual
<td colspan="2" align="center"> microphone.** This makes it an ideal tool for gamers, streamers, and anyone looking to inject sound effects into voice
<h3>Main Screen</h3> chats on platforms like **Discord, Zoom, or Teamspeak**.
<img src="./pwsp-gui/assets/screenshots/main.png" alt="Main UI">
</td>
</tr>
<tr>
<td width="50%" align="center">
<h3>Settings</h3>
<img src="./pwsp-gui/assets/screenshots/settings.png" alt="Settings">
</td>
<td width="50%" align="center">
<h3>Hotkeys</h3>
<img src="./pwsp-gui/assets/screenshots/hotkeys.png" alt="Hotkeys">
</td>
</tr>
</table>
[🇷🇺 Читать на русском](README.ru.md) | 🇬🇧 English (you are here) ![screenshot.png](assets/screenshot.png)
[![GitHub Actions Build Status](https://img.shields.io/github/actions/workflow/status/arabianq/pipewire-soundpad/build.yml?branch=main&style=flat-square)](https://github.com/arabianq/pipewire-soundpad/actions) # **🌟 Key Features**
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT)
[![GitHub Release](https://img.shields.io/github/v/release/arabianq/pipewire-soundpad?style=flat-square)](https://github.com/arabianq/pipewire-soundpad/releases/latest)
[![Platform](https://img.shields.io/badge/Platform-Linux%20%7C%20PipeWire-blue?style=flat-square)](https://pipewire.org/)
**PipeWire Soundpad (PWSP)** is a modern application that lets you play audio files directly through your microphone. Designed specifically for Linux, it talks to PipeWire natively: it creates its own virtual microphone and wires everything up itself, so you never have to build a routing setup by hand. * **Multi-Format Support**: Play audio files in popular formats, including _**mp3**_, _**wav**_, _**ogg**_, _**flac**_,
_**mp4**_, and _**aac**_.
* **Virtual Microphone Output**: The application routes audio through a virtual device created by PipeWire, allowing
other users to hear the sounds as if you were speaking into your microphone.
* **Modern and Clean GUI**: The interface is built with the [egui](https://egui.rs) library, ensuring an intuitive and
responsive user experience.
* **Sound Collection Management**: Easily add and remove directories containing your audio files. The application scans
these folders and displays all supported files for quick access.
* **Quick Search**: Use the built-in search bar to instantly find any sound file within your library.
* **Detailed Playback Controls**:
* **Play/Pause button**.
* **Volume slider** for individual sound adjustment.
* **Position slider** to fast-forward or rewind the audio.
* **Persistent Configuration**: The list of added directories and your selected audio output device are saved
automatically, so you won't need to reconfigure them every time you launch the application.
--- # **⚙️ How It Works**
## ✨ Features PWSP is designed with a clear separation of concerns, operating through a client-server architecture. It consists of
three main components:
- **Native PipeWire Integration:** PWSP creates its own virtual microphone and manages the graph through the PipeWire API — no manual `pw-link` juggling, no PulseAudio modules to load. * **pwsp-daemon**: This is the core of the application. It runs silently in the background, managing all the
- **Separate Monitoring and Microphone Volumes:** Two independent streams, so you can amplify what your friends hear without deafening yourself, and pick which device the monitoring plays on. heavy-lifting tasks. The daemon is responsible for:
- **Modular Architecture:** Consists of a background `daemon`, a command-line interface (`cli`), and a graphical user interface (`gui`). * Creating and managing virtual audio devices.
- **Modern GUI:** Built with `egui`, supporting both Wayland and X11 seamlessly. * Linking these devices within the PipeWire graph.
- **Localized:** Ships in 9 languages, following your system locale or one you pick yourself in the settings. * Handling all audio playback.
- **Global Hotkeys:** Powered by `evdev`, allowing you to play sounds from anywhere. * **pwsp-gui**: This is the graphical user interface. It acts as a client that communicates with pwsp-daemon via a *
- **Broad Audio Support:** Powered by `rodio` and `symphonia` to support a wide range of audio formats, including Opus. *UnixSocket**. This is how you interact with your sound collection, control playback, and configure settings.
* **pwsp-cli**: This is the command-line interface, also acting as a client. It provides a way to control the daemon
without a GUI, allowing for scripting or quick command-based actions.
--- # **🚀 Installation**
## 🚀 Installation ## **Pre-built Packages**
We provide multiple ways to install PWSP, including stable releases and rolling "nightly" builds directly from the `main` branch. You can download pre-built binaries, .deb and .rpm packages from
the [releases page](https://github.com/arabianq/pipewire-soundpad/releases).
### 📦 Flatpak (Recommended) ## **Fedora Linux**
Add our official OSTree repository and install the application: If you're using Fedora, you can install PWSP from a dedicated repository using DNF.
```bash Add the repository:
# Add the repository
flatpak remote-add --if-not-exists pwsp https://arabianq.github.io/pipewire-soundpad/pwsp.flatpakrepo
# Install the Stable version
flatpak install pwsp ru.arabianq.pwsp//stable
# OR Install the Nightly version (rolling updates)
flatpak install pwsp ru.arabianq.pwsp//nightly
```
### 🟠 Debian / Ubuntu (APT Repository)
We maintain an official APT repository for seamless updates via `apt`:
```bash
# 1. Download the public GPG key
sudo mkdir -p /etc/apt/keyrings
wget -O- https://arabianq.github.io/pipewire-soundpad/apt/pubkey.gpg | sudo gpg --dearmor -o /etc/apt/keyrings/pwsp.gpg
# 2. Add the repository (Choose STABLE or NIGHTLY)
# For Stable:
echo "deb [signed-by=/etc/apt/keyrings/pwsp.gpg] https://arabianq.github.io/pipewire-soundpad/apt/ stable main" | sudo tee /etc/apt/sources.list.d/pwsp.list
# For Nightly:
# echo "deb [signed-by=/etc/apt/keyrings/pwsp.gpg] https://arabianq.github.io/pipewire-soundpad/apt/ nightly main" | sudo tee /etc/apt/sources.list.d/pwsp.list
# 3. Update and install
sudo apt update
sudo apt install pwsp
```
### 🐧 Fedora / RHEL (COPR)
Available via the Fedora COPR repository:
```bash ```bash
sudo dnf copr enable arabianq/pwsp sudo dnf copr enable arabianq/pwsp
```
Update cache:
```bash
sudo dnf makecache
```
Install PWSP:
```bash
sudo dnf install pwsp sudo dnf install pwsp
``` ```
### 🐦 Arch Linux (AUR) ## **Arch Linux**
There is pwsp package in AUR.
Two packages are available: `pwsp` builds from source, `pwsp-bin` installs the prebuilt binaries. You can install it using yay, paru or any other AUR helper.
```bash ```bash
# Using your preferred AUR helper paru pwsp
paru -S pwsp # or: pwsp-bin
``` ```
### ⚙️ Manual / Standalone ## **Installing using cargo**
You can manually download `.deb` packages or standalone `.zip` binaries from the [Releases page](https://github.com/arabianq/pipewire-soundpad/releases).
### 🦀 Build from Source
Make sure you have Rust and Cargo installed, along with the build dependencies. On Debian/Ubuntu:
```bash ```bash
sudo apt install libpipewire-0.3-dev libclang-dev libasound2-dev libdbus-1-dev libssl-dev pkg-config cargo install pwsp
``` ```
## **Building from source**
#### **Requirements**
* **Rust**: Install [Rust](https://www.rust-lang.org/tools/install) (using rustup is recommended).
* **PipeWire**: Ensure that [PipeWire](https://pipewire.org/) is installed and running on your system.
#### **Build Instructions**
Clone the repository:
```bash ```bash
git clone https://github.com/arabianq/pipewire-soundpad.git git clone https://github.com/arabianq/pipewire-soundpad.git
cd pipewire-soundpad cd pipewire-soundpad
cargo build --release --locked
``` ```
The binaries will be located in `target/release/`. Build the project:
---
## 🎮 Usage
### 1. Start the Daemon
PWSP operates via a background daemon that handles the audio routing.
```bash ```bash
# Run the daemon cargo build --release
pwsp-daemon
``` ```
_(Tip: If installed via package managers, a systemd user service is provided. You can enable it with `systemctl --user enable --now pwsp-daemon.service`)_ Now you have three binary files inside ./target/release/:
### 2. Launch the GUI - **pwsp-gui**
- **pwsp-cli**
- **pwsp-daemon**
Simply run the graphical interface to manage and play your sounds: # **🎮 Usage**
Before using pwsp-gui or pwsp-cli, you **must** first run the pwsp-daemon in the background.
### **Running the Daemon**
You can start the daemon from the terminal or enable the systemd service for automatic startup.
* **Manual Start:**
```bash ```bash
pwsp-gui /path/to/your/pwsp-daemon &
``` ```
### 3. Use the CLI * **Using systemd (recommended):**
If you installed PWSP using prebuilt packages, the systemd service is added automatically.
1. **Start the service:**
```bash
systemctl --user start pwsp-daemon
```
2. **Enable autostart (starts on login):**
```bash
systemctl --user enable --now pwsp-daemon
```
You can also interact with the daemon directly via the command line. Every command is grouped under `action`, `get` or `set`: ### **Using the GUI**
1. **Add Sounds**: Click the **"Add Directory"** button and select a folder containing your audio files. The application
will automatically list all supported files.
2. **Select Microphone**: In the main application window, select your **physical microphone**. PWSP will automatically
create a virtual microphone and feed it sound from two sources: **your microphone** and the **audio files**.
3. **Playback**: Click on a file in the list to load it, then use the **"Play"** and **"Pause"** buttons to control
playback.
### **Using the CLI**
The pwsp-cli tool allows you to control the daemon from the command line.
* **General Help**: To see a list of all available commands, run:
```bash ```bash
pwsp-cli action play /path/to/sound.mp3 pwsp-cli --help
pwsp-cli action stop
pwsp-cli get state
# Separate volumes for what you hear and what is sent to the microphone.
# Values above 1.0 amplify.
pwsp-cli set monitoring-volume 0.3
pwsp-cli set mic-volume 1.5
# Devices
pwsp-cli get inputs
pwsp-cli set input <name>
``` ```
Run `pwsp-cli <group> --help` for the full list. * **Example Commands**:
* **Play a file**:
### 🔑 Enabling Global Hotkeys
Hotkeys are read straight from the kernel via `evdev`, which means the daemon needs access to `/dev/input/event*`. On most distributions that means adding yourself to the `input` group:
```bash ```bash
sudo usermod -aG input $USER pwsp-cli action play <file_path>
``` ```
Log out and back in for it to take effect. Without this, everything else works — only the global hotkeys stay silent. * **Get the current volume**:
### 📦 A Note for Flatpak Users
The Flatpak ships a single entry point, so the commands above are reached through `flatpak run`:
```bash ```bash
# No arguments: starts the daemon and the GUI together pwsp-cli get volume
flatpak run ru.arabianq.pwsp
# The daemon on its own
flatpak run ru.arabianq.pwsp daemon --start
flatpak run ru.arabianq.pwsp daemon --kill
# Anything after "cli" is passed to pwsp-cli
flatpak run ru.arabianq.pwsp cli action play /path/to/sound.mp3
``` ```
--- * **Set playback position to 20 seconds**:
## 📚 Documentation & DeepWiki ```bash
pwsp-cli set position 20
```
DeepWiki generates a browsable overview of the architecture from the source, and lets you ask questions about it. It is generated automatically rather than written by hand, so treat it as a map, not as a specification: # **🤝 Contributing**
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/arabianq/pipewire-soundpad) Contributions are welcome\! If you have ideas for improvements or find a bug, feel free to create
an [issue](https://github.com/arabianq/pipewire-soundpad/issues) or submit
a [pull request](https://github.com/arabianq/pipewire-soundpad/pulls).
--- # **📜 License**
## 🤝 Contributing This project is licensed under
the [MIT License](https://github.com/arabianq/pipewire-soundpad/blob/main/LICENSE).
Contributions, issues, and feature requests are welcome!
1. Fork the project.
2. Create your feature branch (`git checkout -b feat/amazing-feature`).
3. Commit your changes (`git commit -m 'Add some amazing feature'`).
4. Push to the branch (`git push origin feat/amazing-feature`).
5. Open a Pull Request.
---
## 📝 License
Distributed under the MIT License. See `LICENSE` for more information.
_Built with ❤️ for the Linux community._
-219
View File
@@ -1,219 +0,0 @@
# PipeWire Soundpad (PWSP) 🎵
<table border="0">
<tr>
<td colspan="2" align="center">
<h3>Главная страница</h3>
<img src="./pwsp-gui/assets/screenshots/main.png" alt="Main UI">
</td>
</tr>
<tr>
<td width="50%" align="center">
<h3>Настройки</h3>
<img src="./pwsp-gui/assets/screenshots/settings.png" alt="Settings">
</td>
<td width="50%" align="center">
<h3>Горячие клавиши</h3>
<img src="./pwsp-gui/assets/screenshots/hotkeys.png" alt="Hotkeys">
</td>
</tr>
</table>
🇷🇺 Русский (вы здесь) | [🇬🇧 Read in English](README.md)
[![GitHub Actions Build Status](https://img.shields.io/github/actions/workflow/status/arabianq/pipewire-soundpad/build.yml?branch=main&style=flat-square)](https://github.com/arabianq/pipewire-soundpad/actions)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT)
[![GitHub Release](https://img.shields.io/github/v/release/arabianq/pipewire-soundpad?style=flat-square)](https://github.com/arabianq/pipewire-soundpad/releases/latest)
[![Platform](https://img.shields.io/badge/Platform-Linux%20%7C%20PipeWire-blue?style=flat-square)](https://pipewire.org/)
**PipeWire Soundpad (PWSP)** — приложение, которое позволяет воспроизводить аудиофайлы прямо в ваш микрофон. Разработано специально для Linux и работает с PipeWire напрямую: создаёт собственный виртуальный микрофон и само выстраивает маршрутизацию, так что настраивать её руками не придётся.
---
## ✨ Возможности
- **Нативная интеграция с PipeWire:** PWSP создаёт собственный виртуальный микрофон и управляет графом через PipeWire API — без ручного `pw-link` и без подгрузки модулей PulseAudio.
- **Раздельная громкость мониторинга и микрофона:** Два независимых потока: можно усилить то, что слышат собеседники, не оглушив себя, и выбрать устройство для мониторинга.
- **Модульная архитектура:** Состоит из фонового демона (`daemon`), интерфейса командной строки (`cli`) и графического интерфейса (`gui`).
- **Современный GUI:** Построен на `egui`, плавно работает как на Wayland, так и на X11.
- **Локализация:** 9 языков; используется системный или выбранный вручную в настройках.
- **Глобальные горячие клавиши:** Работают через `evdev`, позволяя воспроизводить звуки из любого окна.
- **Широкая поддержка форматов:** Использует `rodio` и `symphonia` и поддерживает большинство аудиоформатов, включая Opus.
---
## 🚀 Установка
Мы предоставляем несколько способов установки PWSP, включая стабильные релизы и постоянно обновляемые "nightly" сборки напрямую из ветки `main`.
### 📦 Flatpak (Рекомендуется)
Добавьте наш официальный OSTree репозиторий и установите приложение:
```bash
# Добавьте репозиторий
flatpak remote-add --if-not-exists pwsp https://arabianq.github.io/pipewire-soundpad/pwsp.flatpakrepo
# Установите стабильную (Stable) версию
flatpak install pwsp ru.arabianq.pwsp//stable
# ИЛИ установите Nightly версию (самые свежие обновления)
flatpak install pwsp ru.arabianq.pwsp//nightly
```
### 🟠 Debian / Ubuntu (APT Репозиторий)
Мы поддерживаем официальный APT-репозиторий для бесшовных обновлений через `apt`:
```bash
# 1. Скачайте публичный GPG ключ
sudo mkdir -p /etc/apt/keyrings
wget -O- https://arabianq.github.io/pipewire-soundpad/apt/pubkey.gpg | sudo gpg --dearmor -o /etc/apt/keyrings/pwsp.gpg
# 2. Добавьте репозиторий (Выберите STABLE или NIGHTLY)
# Для Stable:
echo "deb [signed-by=/etc/apt/keyrings/pwsp.gpg] https://arabianq.github.io/pipewire-soundpad/apt/ stable main" | sudo tee /etc/apt/sources.list.d/pwsp.list
# Для Nightly:
# echo "deb [signed-by=/etc/apt/keyrings/pwsp.gpg] https://arabianq.github.io/pipewire-soundpad/apt/ nightly main" | sudo tee /etc/apt/sources.list.d/pwsp.list
# 3. Обновите индексы и установите
sudo apt update
sudo apt install pwsp
```
### 🐧 Fedora / RHEL (COPR)
Доступно через репозиторий Fedora COPR:
```bash
sudo dnf copr enable arabianq/pwsp
sudo dnf install pwsp
```
### 🐦 Arch Linux (AUR)
Доступны два пакета: `pwsp` собирается из исходников, `pwsp-bin` ставит готовые бинарники.
```bash
# Через любой удобный AUR-хелпер
paru -S pwsp # или: pwsp-bin
```
### ⚙️ Ручная установка
Вы можете вручную скачать пакеты `.deb` или готовые бинарники `.zip` на [странице релизов](https://github.com/arabianq/pipewire-soundpad/releases).
### 🦀 Сборка из исходников
Убедитесь, что у вас установлены Rust и Cargo, а также зависимости для сборки. Для Debian/Ubuntu:
```bash
sudo apt install libpipewire-0.3-dev libclang-dev libasound2-dev libdbus-1-dev libssl-dev pkg-config
```
```bash
git clone https://github.com/arabianq/pipewire-soundpad.git
cd pipewire-soundpad
cargo build --release --locked
```
Собранные бинарники будут находиться в папке `target/release/`.
---
## 🎮 Использование
### 1. Запуск демона
PWSP работает через фоновый демон, который маршрутизирует аудио.
```bash
# Запуск демона
pwsp-daemon
```
_(Подсказка: Если вы установили программу через пакетный менеджер, она включает пользовательский systemd-сервис. Вы можете включить его командой: `systemctl --user enable --now pwsp-daemon.service`)_
### 2. Запуск GUI
Просто запустите графический интерфейс для управления и воспроизведения звуков:
```bash
pwsp-gui
```
### 3. Использование CLI
Вы также можете взаимодействовать с демоном напрямую через командную строку. Все команды сгруппированы в `action`, `get` и `set`:
```bash
pwsp-cli action play /path/to/sound.mp3
pwsp-cli action stop
pwsp-cli get state
# Раздельная громкость: что слышите вы и что уходит в микрофон.
# Значения выше 1.0 усиливают.
pwsp-cli set monitoring-volume 0.3
pwsp-cli set mic-volume 1.5
# Устройства
pwsp-cli get inputs
pwsp-cli set input <имя>
```
Полный список — `pwsp-cli <группа> --help`.
### 🔑 Включение глобальных горячих клавиш
Горячие клавиши читаются напрямую из ядра через `evdev`, поэтому демону нужен доступ к `/dev/input/event*`. В большинстве дистрибутивов для этого достаточно добавить себя в группу `input`:
```bash
sudo usermod -aG input $USER
```
Изменение вступит в силу после перезахода в систему. Без него всё остальное работает — молчат только глобальные горячие клавиши.
### 📦 Замечание для пользователей Flatpak
У Flatpak-версии одна точка входа, поэтому команды выше вызываются через `flatpak run`:
```bash
# Без аргументов: запускает демон и GUI вместе
flatpak run ru.arabianq.pwsp
# Отдельно демон
flatpak run ru.arabianq.pwsp daemon --start
flatpak run ru.arabianq.pwsp daemon --kill
# Всё после "cli" передаётся в pwsp-cli
flatpak run ru.arabianq.pwsp cli action play /path/to/sound.mp3
```
---
## 📚 Документация и DeepWiki
DeepWiki автоматически строит по исходникам обзор архитектуры и позволяет задавать по нему вопросы. Он генерируется, а не пишется руками, поэтому воспринимайте его как карту, а не как спецификацию:
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/arabianq/pipewire-soundpad)
---
## 🤝 Вклад в проект (Contributing)
Будем рады вашей помощи, баг-репортам и идеям!
1. Сделайте Fork проекта.
2. Создайте свою ветку (`git checkout -b feat/amazing-feature`).
3. Закоммитьте изменения (`git commit -m 'Add some amazing feature'`).
4. Запушьте ветку (`git push origin feat/amazing-feature`).
5. Откройте Pull Request.
---
## 📝 Лицензия
Распространяется под лицензией MIT. Подробнее см. в файле `LICENSE`.
_Сделано с ❤️ для сообщества Linux._
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+12
View File
@@ -0,0 +1,12 @@
[Unit]
Description=Pipewire Soundpad Daemon
After=pipewire.service
[Service]
ExecStartPre=/usr/bin/sleep 10
ExecStart=/usr/bin/pwsp-daemon
Restart=no
RuntimeDirectory=pwsp
[Install]
WantedBy=default.target
@@ -1,9 +1,8 @@
[Desktop Entry] [Desktop Entry]
Name=PWSP (Soundpad) Name=PWSP (Soundpad)
Comment=Let's you play audio files through you microphone Comment=Let's you play audio files through you microphone
Exec=/usr/bin/pwsp-gui %u Exec=pwsp-gui %u
Icon=pwsp Icon=pwsp
Terminal=false Terminal=false
Type=Application Type=Application
Categories=Audio Categories=Audio
MimeType=x-scheme-handler/soundpad;
Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

-20
View File
@@ -1,20 +0,0 @@
pkgbase = pwsp-bin
pkgdesc = Lets you play audio files through your microphone (Pre-built binaries)
pkgver = 1.15.0
pkgrel = 1
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.15.0.tar.gz :: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.15.0.tar.gz
sha256sums = SKIP
source_x86_64 = pwsp-1.15.0-x86_64.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.15.0/pwsp-v1.15.0-linux-x64.zip
sha256sums_x86_64 = SKIP
source_aarch64 = pwsp-1.15.0-aarch64.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.15.0/pwsp-v1.15.0-linux-arm64.zip
sha256sums_aarch64 = SKIP
pkgname = pwsp-bin
-163
View File
@@ -1,163 +0,0 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
*.db
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
poetry.lock
.poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cythikaaryhon_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
#.vscode/
-34
View File
@@ -1,34 +0,0 @@
# Maintainer: Alexander Tarasov <a.tevg@ya.ru>
pkgname=pwsp-bin
_pkgname=pipewire-soundpad
pkgver=1.15.0
pkgrel=1
pkgdesc="Lets you play audio files through your microphone (Pre-built binaries)"
arch=('x86_64' 'aarch64')
url="https://github.com/arabianq/pipewire-soundpad"
license=('MIT')
depends=('pipewire' 'alsa-lib' 'openssl')
provides=('pwsp')
conflicts=('pwsp')
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')
sha256sums_x86_64=('SKIP')
sha256sums_aarch64=('SKIP')
package() {
_srcsrc="${srcdir}/${_pkgname}-${pkgver}"
install -Dm755 "${srcdir}/pwsp-cli" "${pkgdir}/usr/bin/pwsp-cli"
install -Dm755 "${srcdir}/pwsp-daemon" "${pkgdir}/usr/bin/pwsp-daemon"
install -Dm755 "${srcdir}/pwsp-gui" "${pkgdir}/usr/bin/pwsp-gui"
install -Dm644 "$_srcsrc/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-daemon/assets/pwsp-daemon.service" "${pkgdir}/usr/lib/systemd/user/pwsp-daemon.service"
install -Dm644 "$_srcsrc/LICENSE" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
}
-18
View File
@@ -1,18 +0,0 @@
pkgbase = pwsp
pkgdesc = Lets you play audio files through your microphone
pkgver = 1.15.0
pkgrel = 1
url = https://github.com/arabianq/pipewire-soundpad
arch = x86_64
arch = aarch64
license = MIT
makedepends = clang
makedepends = rust
makedepends = cargo
makedepends = cmake
makedepends = pipewire
makedepends = alsa-lib
source = https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.15.0.tar.gz
sha256sums = SKIP
pkgname = pwsp
-163
View File
@@ -1,163 +0,0 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
*.db
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
poetry.lock
.poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cythikaaryhon_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
#.vscode/
-48
View File
@@ -1,48 +0,0 @@
# Maintainer: Alexander Tarasov <a.tevg@ya.ru>
pkgsubn=pwsp
pkgname=pwsp
pkgver=1.15.0
pkgrel=1
pkgdesc="Lets you play audio files through your microphone"
arch=('x86_64' 'aarch64')
url="https://github.com/arabianq/pipewire-soundpad"
license=('MIT')
depends=('pipewire' 'alsa-lib' 'openssl')
makedepends=(clang rust cargo cmake pipewire alsa-lib)
source=("$url/archive/refs/tags/v$pkgver.tar.gz")
sha256sums=('SKIP')
prepare() {
cd "${srcdir}/pipewire-soundpad-${pkgver}"
export CARGO_HOME="${srcdir}/${pkgname%}/.cargo" # Download all to src directory, not in ~/.cargo
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
}
build() {
cd "${srcdir}/pipewire-soundpad-${pkgver}"
export CARGO_ENCODED_RUSTFLAGS="--remap-path-prefix=${srcdir}=/" # Prevent warning: 'Package contains reference to $srcdir'
[[ -n "${_sccache}" ]] && export RUSTC_WRAPPER=sccache # If $_sccache not empty, build using binary cache
export CARGO_HOME="${srcdir}/${pkgname%}/.cargo" # Use downloaded earlier from src directory, not from ~/.cargo
export CARGO_TARGET_DIR=target # Place the output in target relative to the current directory
cargo build --frozen --release
}
package() {
cd "${srcdir}/pipewire-soundpad-${pkgver}"
install -Dm755 "target/release/pwsp-cli" "${pkgdir}/usr/bin/pwsp-cli"
install -Dm755 "target/release/pwsp-daemon" "${pkgdir}/usr/bin/pwsp-daemon"
install -Dm755 "target/release/pwsp-gui" "${pkgdir}/usr/bin/pwsp-gui"
install -Dm644 "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 "pwsp-daemon/assets/pwsp-daemon.service" "${pkgdir}/usr/lib/systemd/user/pwsp-daemon.service"
}
File diff suppressed because one or more lines are too long
-40
View File
@@ -1,40 +0,0 @@
#!/usr/bin/env python3
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
)
subparsers = parser.add_subparsers(dest="command")
cli_parser = subparsers.add_parser("cli", add_help=False, prefix_chars=" ")
cli_parser.add_argument(
"args", nargs=argparse.REMAINDER, help="Arguments for pwsp-cli"
)
daemon_parser = subparsers.add_parser("daemon", add_help=True)
daemon_group = daemon_parser.add_mutually_exclusive_group(required=True)
daemon_group.add_argument("--start", action="store_true", help="Start pwsp-daemon")
daemon_group.add_argument("--kill", action="store_true", help="Kill pwsp-daemon")
args = parser.parse_args()
command = args.command
if not command:
subprocess.Popen("pwsp-daemon")
subprocess.Popen("pwsp-gui")
else:
if command == "cli":
subprocess.Popen(["pwsp-cli"] + args.args)
elif command == "daemon":
if args.start:
subprocess.Popen("pwsp-daemon")
elif args.kill:
subprocess.Popen(["pwsp-cli", "action", "kill"])
-10
View File
@@ -1,10 +0,0 @@
[Desktop Entry]
Name=PWSP (Soundpad)
Comment=Let's you play audio files through you microphone
Exec=pwsp-wrapper.py %u
Icon=ru.arabianq.pwsp
Terminal=false
Type=Application
Categories=AudioVideo;Audio;
Keywords=soundpad;pipewire;audio;
MimeType=x-scheme-handler/soundpad;
@@ -1,37 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>ru.arabianq.pwsp</id>
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT</project_license>
<name>PWSP</name>
<summary>Play audio files through your microphone using PipeWire</summary>
<description>
<p>
PWSP (PipeWire Soundpad) is a tool that allows you to play audio files through your
microphone.
It features both a graphical user interface and a command-line interface.
</p>
</description>
<launchable type="desktop-id">ru.arabianq.pwsp.desktop</launchable>
<screenshots>
<screenshot type="default">
<image>https://raw.githubusercontent.com/arabianq/pipewire-soundpad/main/pwsp-gui/assets/screenshots/main.png</image>
</screenshot>
<screenshot>
<image>https://raw.githubusercontent.com/arabianq/pipewire-soundpad/main/pwsp-gui/assets/screenshots/settings.png</image>
</screenshot>
<screenshot>
<image>https://raw.githubusercontent.com/arabianq/pipewire-soundpad/main/pwsp-gui/assets/screenshots/hotkeys.png</image>
</screenshot>
</screenshots>
<url type="homepage">https://pwsp.arabianq.ru</url>
<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.15.0" date="2026-07-26" />
</releases>
<content_rating type="oars-1.1" />
</component>
-45
View File
@@ -1,45 +0,0 @@
app-id: ru.arabianq.pwsp
runtime: org.freedesktop.Platform
runtime-version: "25.08"
sdk: org.freedesktop.Sdk
sdk-extensions:
- org.freedesktop.Sdk.Extension.rust-stable
- org.freedesktop.Sdk.Extension.llvm20
command: pwsp-wrapper.py
finish-args:
- --share=ipc
- --socket=fallback-x11
- --socket=wayland
- --socket=pulseaudio
- --filesystem=xdg-run/pipewire-0
- --filesystem=xdg-run/pwsp:create
- --filesystem=xdg-run/app/ru.arabianq.pwsp:create
- --filesystem=home
- --device=all
- --device=dri
- --talk-name=org.freedesktop.portal.Desktop
- --talk-name=org.freedesktop.portal.Documents
build-options:
append-path: /usr/lib/sdk/rust-stable/bin:/usr/lib/sdk/llvm20/bin
env:
CARGO_HOME: /run/build/pwsp/cargo
LIBCLANG_PATH: /usr/lib/sdk/llvm20/lib
modules:
- name: pwsp
buildsystem: simple
build-commands:
- if [ -w /run/ccache ]; then mkdir -p /run/ccache/cargo-target && rm -rf target && ln -s /run/ccache/cargo-target target; fi
- export CARGO_HOME=$PWD/cargo && cargo build --release --offline
- install -Dm755 target/release/pwsp-daemon /app/bin/pwsp-daemon
- install -Dm755 target/release/pwsp-cli /app/bin/pwsp-cli
- install -Dm755 target/release/pwsp-gui /app/bin/pwsp-gui
- install -Dm755 packages/flatpak/pwsp-wrapper.py /app/bin/pwsp-wrapper.py
- install -Dm644 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:
- type: dir
path: ../../
- cargo-sources.json
-94
View File
@@ -1,94 +0,0 @@
# prevent library files from being installed
%global cargo_install_lib 0
# disable debuginfo package generation (debugsourcefiles.list is empty for Rust)
%global debug_package %{nil}
Name: pwsp-git
Version: {{{ git describe --tags --always | sed 's/^v//' | sed -E 's/-([0-9]+)-(g[0-9a-f]+)/^git.\1.\2/' }}}
Release: 1%{?dist}
Summary: Lets you play audio files through your microphone (git version)
License: MIT
URL: https://github.com/arabianq/pipewire-soundpad
VCS: {{{ git_dir_vcs }}}
Source: {{{ git_cwd_pack }}}
BuildRequires: rust
BuildRequires: cargo
BuildRequires: pipewire-devel
%if 0%{?suse_version}
BuildRequires: alsa-devel
BuildRequires: dbus-1-devel
BuildRequires: libopenssl-devel
%else
BuildRequires: alsa-lib-devel
BuildRequires: dbus-devel
BuildRequires: openssl-devel
%endif
BuildRequires: clang-devel
BuildRequires: cmake
BuildRequires: pkgconfig
%if 0%{?suse_version} && 0%{?suse_version} <= 1500
BuildRequires: gcc13-c++
%endif
# Declare compatibility and conflicts with the stable package
Provides: pwsp = %{version}-%{release}
Conflicts: pwsp
%global _description %{expand:
PWSP lets you play audio files through your microphone. Has both CLI and
GUI clients. This is the latest development (git) version.}
%description %{_description}
%prep
{{{ git_cwd_setup_macro }}}
%build
%if 0%{?suse_version} && 0%{?suse_version} <= 1500
export CC=gcc-13
export CXX=g++-13
%endif
cargo build --release --locked
%install
install -Dm755 target/release/pwsp-cli %{buildroot}%{_bindir}/pwsp-cli
install -Dm755 target/release/pwsp-daemon %{buildroot}%{_bindir}/pwsp-daemon
install -Dm755 target/release/pwsp-gui %{buildroot}%{_bindir}/pwsp-gui
install -Dm644 pwsp-gui/assets/pwsp-gui.desktop %{buildroot}%{_datadir}/applications/pwsp.desktop
install -Dm644 pwsp-gui/assets/icon.png %{buildroot}%{_datadir}/icons/hicolor/256x256/apps/pwsp.png
install -Dm644 pwsp-daemon/assets/pwsp-daemon.service %{buildroot}/usr/lib/systemd/user/pwsp-daemon.service
%post
%systemd_user_post pwsp-daemon.service
%preun
%systemd_user_preun pwsp-daemon.service
%postun
%systemd_user_postun_with_restart 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 }}}
-93
View File
@@ -1,93 +0,0 @@
# 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.15.0
Release: %autorelease
Summary: Lets you play audio files through your microphone
License: MIT
URL: https://github.com/arabianq/pipewire-soundpad
Source: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v%{version}.tar.gz
BuildRequires: rust
BuildRequires: cargo
BuildRequires: pipewire-devel
%if 0%{?suse_version}
BuildRequires: alsa-devel
BuildRequires: dbus-1-devel
BuildRequires: libopenssl-devel
%else
BuildRequires: alsa-lib-devel
BuildRequires: dbus-devel
BuildRequires: openssl-devel
%endif
BuildRequires: clang-devel
BuildRequires: cmake
BuildRequires: pkgconfig
%if 0%{?suse_version} && 0%{?suse_version} <= 1500
BuildRequires: gcc13-c++
%endif
%global _description %{expand:
PWSP lets you play audio files through your microphone. Has both CLI and
GUI clients.}
%description %{_description}
%prep
%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 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-daemon/assets/pwsp-daemon.service %{buildroot}/usr/lib/systemd/user/pwsp-daemon.service
%post
%systemd_user_post pwsp-daemon.service
%preun
%systemd_user_preun pwsp-daemon.service
%postun
%systemd_user_postun_with_restart 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
%autochangelog
-18
View File
@@ -1,18 +0,0 @@
[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
-245
View File
@@ -1,245 +0,0 @@
use anyhow::{Result, anyhow};
use clap::{Parser, Subcommand};
use pwsp_lib::{
types::socket::Request,
utils::daemon::{make_request, wait_for_daemon},
};
use std::path::PathBuf;
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Cli {
#[clap(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// Tell the player to do something: play, stop, pause, manage hotkeys
Action {
#[clap(subcommand)]
action: Actions,
},
/// Read player state: volumes, position, tracks, devices, hotkeys
Get {
#[clap(subcommand)]
parameter: GetCommands,
},
/// Change player settings: volumes, position, devices, loop, hotkeys
Set {
#[clap(subcommand)]
parameter: SetCommands,
},
}
#[derive(Subcommand, Debug)]
enum Actions {
/// Ping the daemon
Ping,
/// Kill the daemon
Kill,
/// Pause audio playback
Pause {
#[clap(short, long)]
id: Option<u32>,
},
/// Resume audio playback
Resume {
#[clap(short, long)]
id: Option<u32>,
},
/// Toggle pause
TogglePause {
#[clap(short, long)]
id: Option<u32>,
},
/// Stop audio playback and clear the queue
Stop {
#[clap(short, long)]
id: Option<u32>,
},
/// Play a file
Play {
file_path: PathBuf,
#[clap(short, long)]
concurrent: bool,
},
/// Toggle loop
ToggleLoop {
#[clap(short, long)]
id: Option<u32>,
},
/// Write the daemon's current settings to disk
SaveDaemonConfig,
/// Play a sound by hotkey slot name
PlayHotkey { slot: String },
/// Remove the hotkey slot
ClearHotkey { slot: String },
/// Clear the key chord for a hotkey slot
ClearHotkeyKey { slot: String },
}
#[derive(Subcommand, Debug)]
enum GetCommands {
/// Check if the player is paused
IsPaused,
/// Playback volume
Volume {
#[clap(short, long)]
id: Option<u32>,
},
/// Volume of what you hear locally
MonitoringVolume,
/// Volume of what is sent to the virtual microphone
MicVolume,
/// Volume multiplier for all tracks
VolumeMultiplier,
/// Playback position (in seconds)
Position {
#[clap(short, long)]
id: Option<u32>,
},
/// Duration of the current file
Duration {
#[clap(short, long)]
id: Option<u32>,
},
/// Player state (Playing, Paused or Stopped)
State,
/// Get all playing tracks
Tracks,
/// Current audio input
Input,
/// All audio inputs
Inputs,
/// Current audio output used for monitoring
Output,
/// All audio outputs
Outputs,
/// Version of the daemon
DaemonVersion,
/// Daemon configuration
DaemonConfig,
/// Full player state
FullState,
/// All hotkey slots
Hotkeys,
}
#[derive(Subcommand, Debug)]
enum SetCommands {
/// Playback volume. Without --id it sets both the monitoring and mic volumes
Volume {
volume: f32,
#[clap(short, long)]
id: Option<u32>,
},
/// Volume of what you hear locally. Values above 1.0 amplify
MonitoringVolume { volume: f32 },
/// Volume of what is sent to the virtual microphone. Values above 1.0 amplify
MicVolume { volume: f32 },
/// Volume multiplier for all tracks
VolumeMultiplier { volume: f32 },
/// Playback position (in seconds)
Position {
position: f32,
#[clap(short, long)]
id: Option<u32>,
},
/// Audio input id (see pwsp-cli get inputs)
Input { name: String },
/// Audio output used for monitoring (see pwsp-cli get outputs)
Output { name: String },
/// Enable or disable loop (true or false)
Loop {
enabled: String,
#[clap(short, long)]
id: Option<u32>,
},
/// Assign a sound file to a hotkey slot
Hotkey { slot: String, file_path: PathBuf },
/// Set the key chord for a hotkey slot (e.g. "Ctrl+Alt+1")
HotkeyKey { slot: String, key_chord: String },
/// Atomically set the action and key chord for a hotkey slot
HotkeyActionAndKey {
slot: String,
action: String,
key_chord: String,
},
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
wait_for_daemon().await?;
let request = match cli.command {
Commands::Action { action } => match action {
Actions::Ping => Request::ping(),
Actions::Kill => Request::kill(),
Actions::Pause { id } => Request::pause(id),
Actions::Resume { id } => Request::resume(id),
Actions::TogglePause { id } => Request::toggle_pause(id),
Actions::Stop { id } => Request::stop(id),
Actions::Play {
file_path,
concurrent,
} => Request::play(&file_path.to_string_lossy(), concurrent),
Actions::ToggleLoop { id } => Request::toggle_loop(id),
Actions::SaveDaemonConfig => Request::save_daemon_config(),
Actions::PlayHotkey { slot } => Request::play_hotkey(&slot),
Actions::ClearHotkey { slot } => Request::clear_hotkey(&slot),
Actions::ClearHotkeyKey { slot } => Request::clear_hotkey_key(&slot),
},
Commands::Get { parameter } => match parameter {
GetCommands::IsPaused => Request::get_is_paused(),
GetCommands::Volume { id } => Request::get_volume(id),
GetCommands::MonitoringVolume => Request::get_monitoring_volume(),
GetCommands::MicVolume => Request::get_mic_volume(),
GetCommands::VolumeMultiplier => Request::get_volume_multiplier(),
GetCommands::Position { id } => Request::get_position(id),
GetCommands::Duration { id } => Request::get_duration(id),
GetCommands::State => Request::get_state(),
GetCommands::Tracks => Request::get_tracks(),
GetCommands::Input => Request::get_input(),
GetCommands::Inputs => Request::get_inputs(),
GetCommands::Output => Request::get_output(),
GetCommands::Outputs => Request::get_outputs(),
GetCommands::DaemonVersion => Request::get_daemon_version(),
GetCommands::DaemonConfig => Request::get_daemon_config(),
GetCommands::FullState => Request::get_full_state(),
GetCommands::Hotkeys => Request::get_hotkeys(),
},
Commands::Set { parameter } => match parameter {
SetCommands::Volume { volume, id } => Request::set_volume(volume, id),
SetCommands::MonitoringVolume { volume } => Request::set_monitoring_volume(volume),
SetCommands::MicVolume { volume } => Request::set_mic_volume(volume),
SetCommands::VolumeMultiplier { volume } => Request::set_volume_multiplier(volume),
SetCommands::Position { position, id } => Request::seek(position, id),
SetCommands::Input { name } => Request::set_input(&name),
SetCommands::Output { name } => Request::set_output(&name),
SetCommands::Loop { enabled, id } => Request::set_loop(&enabled, id),
SetCommands::Hotkey { slot, file_path } => {
Request::set_hotkey(&slot, &file_path.to_string_lossy())
}
SetCommands::HotkeyKey { slot, key_chord } => {
Request::set_hotkey_key(&slot, &key_chord)
}
SetCommands::HotkeyActionAndKey {
slot,
action,
key_chord,
} => Request::set_hotkey_action_and_key(
&slot,
&serde_json::from_str::<Request>(&action)?,
&key_chord,
),
},
};
let response = make_request(request).await.map_err(|e| anyhow!(e))?;
println!("{} : {}", response.status, response.message);
Ok(())
}
-20
View File
@@ -1,20 +0,0 @@
[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
-16
View File
@@ -1,16 +0,0 @@
[Unit]
Description=Pipewire Soundpad Daemon
Requires=pipewire.service
After=pipewire.service
PartOf=graphical-session.target
[Service]
Type=simple
ExecStart=/usr/bin/pwsp-daemon
Restart=on-failure
RestartSec=3
RuntimeDirectory=pwsp
SyslogIdentifier=pwsp-daemon
[Install]
WantedBy=default.target
-200
View File
@@ -1,200 +0,0 @@
use anyhow::{Result, anyhow};
use pwsp_lib::{
types::socket::{MAX_MESSAGE_SIZE, Request, Response},
utils::{
commands::parse_command,
daemon::{
create_runtime_dir, get_audio_player, get_daemon_config, get_runtime_dir,
is_daemon_running,
},
global_hotkeys::start_global_hotkey_listener,
pipewire::create_virtual_mic,
},
};
use std::os::unix::fs::PermissionsExt;
use std::{fs, time::Duration};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::{UnixListener, UnixStream},
time::sleep,
};
#[tokio::main]
async fn main() -> Result<()> {
create_runtime_dir()?;
if is_daemon_running()? {
return Err(anyhow!("Another instance is already running."));
}
get_daemon_config(); // Initialize daemon config
// Virtual mic object must be kept alive by some variable until daemon exits
let _virtual_mic = create_virtual_mic().await?;
if let Err(err) = get_audio_player().await {
eprintln!("Failed to initialize audio player: {}", err);
} // Initialize audio player
tokio::spawn(async {
start_global_hotkey_listener().await;
});
let runtime_dir = get_runtime_dir();
let lock_file = fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(runtime_dir.join("daemon.lock"))?;
lock_file.lock()?;
let socket_path = runtime_dir.join("daemon.sock");
if let Err(e) = fs::remove_file(&socket_path)
&& e.kind() != std::io::ErrorKind::NotFound
{
return Err(e.into());
}
let listener = UnixListener::bind(&socket_path)?;
fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600))?;
println!(
"Daemon started. Listening on {}",
socket_path.to_str().unwrap_or_default()
);
let commands_loop_handle = tokio::spawn(async {
commands_loop(listener).await.ok();
});
let player_loop_handle = tokio::spawn(async {
player_loop().await;
});
tokio::select! {
_ = commands_loop_handle => {
eprint!("Commands loop was finished, stopping program...");
}
_ = player_loop_handle => {
eprint!("Audio Player loop was finished, stopping program...");
}
}
Ok(())
}
async fn commands_loop(listener: UnixListener) -> Result<()> {
loop {
let (stream, _addr) = listener.accept().await?;
tokio::spawn(async move {
handle_connection(stream).await;
});
}
}
async fn handle_connection(mut stream: UnixStream) {
// ---------- Read request (start) ----------
let mut len_bytes = [0u8; 4];
if stream.read_exact(&mut len_bytes).await.is_err() {
eprintln!("Failed to read message length from client!");
return;
}
let request_len = u32::from_le_bytes(len_bytes) as usize;
if request_len > MAX_MESSAGE_SIZE {
eprintln!(
"Failed to read message from client: request too large ({} bytes)!",
request_len
);
return;
}
let mut buffer = Vec::new();
if (&mut stream)
.take(request_len as u64)
.read_to_end(&mut buffer)
.await
.is_err()
|| buffer.len() != request_len
{
eprintln!("Failed to read message from client!");
return;
}
let request: Request = match serde_json::from_slice(&buffer) {
Ok(req) => req,
Err(err) => {
let response = Response::new(false, format!("Failed to parse request: {}", err));
let response_data = match serde_json::to_vec(&response) {
Ok(data) => data,
Err(_) => return, // Should not happen with this simple Response
};
let response_len = response_data.len() as u32;
let _ = stream.write_all(&response_len.to_le_bytes()).await;
let _ = stream.write_all(&response_data).await;
return;
}
};
// ---------- Read request (end) ----------
// ---------- Generate response (start) ----------
let command = parse_command(&request);
let response: Response;
if let Some(command) = command {
response = command.execute().await;
} else {
response = Response::new(false, "Unknown command");
}
// ---------- Generate response (end) ----------
// ---------- Send response (start) ----------
let response_data = match serde_json::to_vec(&response) {
Ok(data) => data,
Err(err) => {
eprintln!("Failed to serialize response: {}", err);
return;
}
};
let response_len = response_data.len() as u32;
if stream.write_all(&response_len.to_le_bytes()).await.is_err() {
eprintln!("Failed to write response length to client!");
return;
}
if stream.write_all(&response_data).await.is_err() {
eprintln!("Failed to write response to client!");
return;
}
// ---------- Send response (end) ----------
if response.status && response.message.eq("killed") {
std::process::exit(0);
}
}
async fn player_loop() {
let mut device_check_counter: u32 = 0;
loop {
let is_idle = match get_audio_player().await {
Ok(player_mutex) => {
let mut audio_player = player_mutex.lock().await;
let check_devices = device_check_counter == 0;
audio_player.update(check_devices).await;
audio_player.tracks.is_empty()
}
Err(_err) => true,
};
if is_idle {
device_check_counter = 0;
sleep(Duration::from_secs(2)).await;
} else {
// Check devices every ~5 seconds (50 * 100ms) while playing
device_check_counter = (device_check_counter + 1) % 50;
sleep(Duration::from_millis(100)).await;
}
}
}
-74
View File
@@ -1,74 +0,0 @@
[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
dirs.workspace = true
egui.workspace = true
eframe.workspace = true
egui_extras.workspace = true
egui_material_icons.workspace = true
egui_dnd.workspace = true
system-fonts.workspace = true
rust-i18n.workspace = true
sys-locale.workspace = true
reqwest.workspace = true
percent-encoding.workspace = true
[package.metadata.deb]
name = "pwsp"
section = "sound"
priority = "optional"
maintainer-scripts = "maintainer-scripts"
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",
],
[
"../pwsp-daemon/assets/pwsp-daemon.service",
"usr/lib/systemd/user/pwsp-daemon.service",
"644",
],
]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

-585
View File
@@ -1,585 +0,0 @@
_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_volume_multiplier]
en = "Always remember volume multiplier"
ru = "Всегда запоминать множитель громкости"
es = "Recordar siempre el multiplicador de volumen"
fr = "Toujours se souvenir du multiplicateur de volume"
zh = "始终记住音量乘数"
ar = "تذكر ضرب الصوت دائمًا"
kz = "Әрқашан дыбыс деңгейін есте сақтау"
he = "זכור תמיד עוצמת קול"
pt-BR = "Lembrar volume"
[gui.settings.volume_multiplier]
en = "Volume multiplier"
ru = "Множитель громкости"
es = "Multiplicador de volumen"
fr = "Multiplicateur de volume"
zh = "音量乘数"
ar = "ضرب الصوت"
kz = "Дыбыс деңгейі"
he = "עוצמת קול"
pt-BR = "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.search_truncated_warning]
en = "... and %{count} more results"
ru = "... и ещё %{count} результатов"
es = "... y %{count} resultados más"
fr = "... et %{count} résultats de plus"
zh = "... 还有 %{count} 个结果"
ar = "... و %{count} نتائج أخرى"
kz = "... және тағы %{count} нәтиже"
he = "... ועוד %{count} תוצאות"
pt-BR = "... e mais %{count} resultados"
[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"
[gui.choose_output_select]
en = "Select output"
ru = "Выбрать вывод"
es = "Seleccionar salida"
fr = "Sélectionner la sortie"
zh = "选择输出"
ar = "اختر المخرج"
kz = "Шығысты таңдау"
he = "בחר פלט"
pt-BR = "Selecionar saída"
[gui.default_output]
en = "System default"
ru = "Системный по умолчанию"
es = "Predeterminado del sistema"
fr = "Défaut du système"
zh = "系统默认"
ar = "افتراضي النظام"
kz = "Жүйелік әдепкі"
he = "ברירת מחדל של המערכת"
pt-BR = "Padrão do sistema"
[gui.monitoring_volume]
en = "Monitoring volume"
ru = "Громкость мониторинга"
es = "Volumen de monitorización"
fr = "Volume d'écoute"
zh = "监听音量"
ar = "مستوى صوت المراقبة"
kz = "Мониторинг дыбысы"
he = "עוצמת ניטור"
pt-BR = "Volume de monitoramento"
[gui.mic_volume]
en = "Microphone volume"
ru = "Громкость микрофона"
es = "Volumen del micrófono"
fr = "Volume du microphone"
zh = "麦克风音量"
ar = "مستوى صوت الميكروفون"
kz = "Микрофон дыбысы"
he = "עוצמת מיקרופון"
pt-BR = "Volume do microfone"
[gui.settings.language.label]
en = "Language"
ru = "Язык"
es = "Idioma"
fr = "Langue"
zh = "语言"
ar = "اللغة"
kz = "Тіл"
he = "שפה"
pt-BR = "Idioma"
[gui.settings.language.system]
en = "System"
ru = "Системный"
es = "Sistema"
fr = "Système"
zh = "系统"
ar = "النظام"
kz = "Жүйе"
he = "מערכת"
pt-BR = "Sistema"
-21
View File
@@ -1,21 +0,0 @@
#!/bin/sh
set -e
if [ "$1" = "configure" ]; then
if [ -x "/usr/bin/systemctl" ]; then
systemctl --global enable pwsp-daemon.service || true
for uid_dir in /run/user/*; do
if [ -d "$uid_dir/systemd" ]; then
uid=$(basename "$uid_dir")
if [ "$uid" != "0" ]; then
username=$(id -un "$uid" 2>/dev/null) || continue
su -s /bin/sh -c "XDG_RUNTIME_DIR=$uid_dir systemctl --user daemon-reload" "$username" || true
su -s /bin/sh -c "XDG_RUNTIME_DIR=$uid_dir systemctl --user try-restart pwsp-daemon.service" "$username" || true
fi
fi
done
fi
fi
exit 0
-18
View File
@@ -1,18 +0,0 @@
#!/bin/sh
set -e
if [ "$1" = "remove" ]; then
if [ -x "/usr/bin/systemctl" ]; then
for uid_dir in /run/user/*; do
if [ -d "$uid_dir/systemd" ]; then
uid=$(basename "$uid_dir")
if [ "$uid" != "0" ]; then
username=$(id -un "$uid" 2>/dev/null) || continue
su -s /bin/sh -c "XDG_RUNTIME_DIR=$uid_dir systemctl --user daemon-reload" "$username" || true
fi
fi
done
fi
fi
exit 0
-20
View File
@@ -1,20 +0,0 @@
#!/bin/sh
set -e
if [ "$1" = "remove" ] || [ "$1" = "deconfigure" ]; then
if [ -x "/usr/bin/systemctl" ]; then
systemctl --global disable pwsp-daemon.service || true
for uid_dir in /run/user/*; do
if [ -d "$uid_dir/systemd" ]; then
uid=$(basename "$uid_dir")
if [ "$uid" != "0" ]; then
username=$(id -un "$uid" 2>/dev/null) || continue
su -s /bin/sh -c "XDG_RUNTIME_DIR=$uid_dir systemctl --user stop pwsp-daemon.service" "$username" || true
fi
fi
done
fi
fi
exit 0
-287
View File
@@ -1,287 +0,0 @@
use crate::gui::SoundpadGui;
use egui::{Context, Id, Key, Modifiers};
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> {
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 {
return None;
}
let mut parts = vec![];
if modifiers.ctrl {
parts.push("Ctrl");
}
if modifiers.alt {
parts.push("Alt");
}
if modifiers.shift {
parts.push("Shift");
}
// We intentionally ignore modifiers.command (Super) here to bypass a Wayland/Niri bug
// where the Super key modifier is constantly active.
parts.push(key_name);
Some(parts.join("+"))
}
/// Parse a chord string back to (Modifiers, Key) for matching.
pub fn parse_chord(chord: &str) -> Option<(Modifiers, Key)> {
let parts: Vec<&str> = chord.split('+').collect();
if parts.is_empty() {
return None;
}
let mut modifiers = Modifiers::NONE;
for &part in &parts[..parts.len() - 1] {
match part {
"Ctrl" => modifiers.ctrl = true,
"Alt" => modifiers.alt = true,
"Shift" => modifiers.shift = true,
"Super" => modifiers.command = true,
_ => return None,
}
}
let key_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))
}
impl SoundpadGui {
fn key_pressed(&self, ctx: &Context, key: Key) -> bool {
ctx.input(|i| i.key_pressed(key))
}
fn modifiers(&self, ctx: &Context) -> Modifiers {
ctx.input(|i| i.modifiers)
}
fn get_focused(&self, ctx: &Context) -> Option<Id> {
ctx.memory(|m| m.focused())
}
pub fn handle_input(&mut self, ctx: &Context) {
let modifiers = self.modifiers(ctx);
let search_focused = {
if let Some(focused_id) = self.get_focused(ctx)
&& let Some(search_id) = self.app_state.search_field_id
&& focused_id.eq(&search_id)
{
true
} else {
false
}
};
// Handle hotkey capture mode: listen for a key chord to assign
if self.app_state.hotkey_capture_active {
if self.key_pressed(ctx, Key::Escape) {
self.app_state.hotkey_capture_active = false;
self.app_state.assigning_hotkey_slot = None;
self.app_state.assigning_hotkey_for_file = None;
return;
}
// Try to capture a chord from any key press
let captured = ctx.input(|i| {
for event in &i.events {
if let egui::Event::Key {
key,
pressed: true,
modifiers: mods,
..
} = event
&& let Some(chord) = chord_from_event(mods, key)
{
return Some(chord);
}
}
None
});
if let Some(chord) = captured {
if let Some(slot) = self.app_state.assigning_hotkey_slot.take() {
make_request_async(Request::set_hotkey_key(&slot, &chord));
self.app_state
.hotkey_config
.set_key_chord(&slot, Some(chord));
} else if let Some(file_path) = self.app_state.assigning_hotkey_for_file.take() {
// Auto-create a slot from the file name
let slot_name = file_path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let action = Request::play(&file_path.to_string_lossy(), false);
make_request_async(Request::set_hotkey_action_and_key(
&slot_name, &action, &chord,
));
self.app_state
.hotkey_config
.set_slot(slot_name.clone(), action);
self.app_state
.hotkey_config
.set_key_chord(&slot_name, Some(chord.clone()));
}
self.app_state.hotkey_capture_active = false;
self.app_state.assigning_hotkey_slot = None;
self.app_state.assigning_hotkey_for_file = None;
}
return;
}
// Open/close settings
if !search_focused && self.key_pressed(ctx, Key::I) {
self.app_state.show_settings = !self.app_state.show_settings;
}
// Toggle hotkeys view
if !search_focused && self.key_pressed(ctx, Key::H) {
self.app_state.show_hotkeys = !self.app_state.show_hotkeys;
}
if !self.app_state.show_settings && !self.app_state.show_hotkeys {
// Pause / resume audio on space
if !search_focused && self.key_pressed(ctx, Key::Space) {
self.play_toggle();
}
// Stop all audio tracks on backspace
if !search_focused && self.key_pressed(ctx, Key::Backspace) {
self.stop(None);
}
// Focus search field
if modifiers.ctrl && self.key_pressed(ctx, Key::F) {
self.app_state.force_focus_search = true;
}
// Unfocus search field
if search_focused && self.key_pressed(ctx, Key::Escape) {
ctx.memory_mut(|m| {
m.request_focus(Id::NULL);
});
}
// Check for hotkey chord triggers
let slots_to_play: Vec<String> = ctx.input(|i| {
let mut result = vec![];
for slot in &self.app_state.hotkey_config.slots {
if let Some(chord) = &slot.key_chord
&& let Some((mods, key)) = parse_chord(chord)
&& i.modifiers == mods
&& i.key_pressed(key)
{
result.push(slot.slot.clone());
}
}
result
});
for slot in slots_to_play {
self.play_hotkey_slot(&slot);
}
}
// });
}
}
#[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());
}
}
-501
View File
@@ -1,501 +0,0 @@
mod input;
mod update;
mod views;
use anyhow::{Result, anyhow};
use eframe::{NativeOptions, icon_data::from_png_bytes, run_native};
use egui::{Context, FontData, FontDefinitions, FontFamily, FontTweak, Vec2, ViewportBuilder};
use itertools::Itertools;
use pwsp_lib::{
types::{
audio_player::PlayerState,
config::{GuiConfig, HotkeyConfig},
gui::{AppState, AudioPlayerState},
socket::Request,
},
utils::gui::{
get_daemon_config, get_gui_config, make_request_async, make_request_sync,
start_app_state_thread, update_daemon_config,
},
};
use rfd::FileDialog;
use std::{
cmp::Ordering,
collections::{HashMap, HashSet},
fs,
hash::{DefaultHasher, Hash, Hasher},
path::{Path, PathBuf},
sync::{Arc, Mutex},
thread,
};
use system_fonts::{FontStyle, FoundFontSource, find_for_locale};
const SUPPORTED_EXTENSIONS: [&str; 13] = [
"mp3", "wav", "ogg", "flac", "mp4", "m4a", "aac", "mov", "mkv", "mka", "webm", "avi", "opus",
];
fn get_cache_file_for(path: &Path) -> PathBuf {
let mut hasher = DefaultHasher::new();
path.hash(&mut hasher);
let hash = hasher.finish();
dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join("pwsp")
.join(format!("{:x}.json", hash))
}
struct SoundpadGui {
pub app_state: AppState,
pub config: GuiConfig,
pub audio_player_state: AudioPlayerState,
pub audio_player_state_shared: Arc<Mutex<AudioPlayerState>>,
}
impl SoundpadGui {
fn new(ctx: &Context) -> Self {
let audio_player_state = Arc::new(Mutex::new(AudioPlayerState::default()));
start_app_state_thread(audio_player_state.clone());
let config = get_gui_config();
ctx.set_zoom_factor(config.scale_factor);
let mut soundpad_gui = SoundpadGui {
app_state: AppState::default(),
config: config.clone(),
audio_player_state: AudioPlayerState::default(),
audio_player_state_shared: audio_player_state.clone(),
};
soundpad_gui.app_state.dirs = config.dirs;
soundpad_gui.app_state.hotkey_config = HotkeyConfig::load().unwrap_or_default();
soundpad_gui
}
pub fn play_toggle(&mut self) {
let (new_state, request) = {
let guard = self
.audio_player_state_shared
.lock()
.unwrap_or_else(|e| e.into_inner());
match guard.state {
PlayerState::Playing => (Some(PlayerState::Paused), Some(Request::pause(None))),
PlayerState::Paused => (Some(PlayerState::Playing), Some(Request::resume(None))),
PlayerState::Stopped => (None, None),
}
};
if let Some(req) = request {
make_request_async(req);
}
if let Some(state) = new_state {
let mut guard = self
.audio_player_state_shared
.lock()
.unwrap_or_else(|e| e.into_inner());
guard.new_state = Some(state.clone());
guard.state = state;
}
}
pub fn open_file(&mut self) {
let file_dialog = FileDialog::new().add_filter("Audio File", &SUPPORTED_EXTENSIONS);
if let Some(path) = file_dialog.pick_file() {
self.play_file(&path, false);
}
}
pub fn add_dirs(&mut self) {
let file_dialog = FileDialog::new();
if let Some(paths) = file_dialog.pick_folders() {
for path in paths {
self.app_state.dirs.push(path);
}
self.app_state.dirs = self.app_state.dirs.iter().unique().cloned().collect();
self.config.dirs = self.app_state.dirs.clone();
self.config.save_to_file().ok();
}
}
pub fn open_dir(&mut self, path: &PathBuf, ctx: Option<Context>, force_rescan: bool) {
self.app_state.current_dir = Some(path.clone());
if !self.app_state.dir_cache.contains_key(path) || force_rescan {
match fs::read_dir(path) {
Ok(read_dir) => {
let files = read_dir
.filter_map(|res| res.ok())
.map(|entry| entry.path())
.collect::<Vec<_>>();
self.app_state.listed_files = files.iter().cloned().collect();
self.app_state.dir_cache.insert(path.clone(), files);
}
Err(e) => {
eprintln!("Failed to read directory {:?}: {}", path, e);
self.app_state.listed_files.clear();
}
}
} else {
self.app_state.listed_files = self
.app_state
.dir_cache
.get(path)
.unwrap()
.clone()
.into_iter()
.collect();
}
let is_scanning = self.app_state.scanning_dirs.lock().unwrap().contains(path);
let is_cached = self.app_state.recursive_files_cache.contains_key(path);
let has_scanned_this_session = self.app_state.scanned_this_session.contains(path);
let needs_scan = force_rescan || !has_scanned_this_session || !is_cached;
if !is_scanning && needs_scan {
self.app_state
.scanning_dirs
.lock()
.unwrap()
.insert(path.clone());
self.app_state.scanned_this_session.insert(path.clone());
let finished_scans = self.app_state.finished_scans.clone();
let scanning_dirs = self.app_state.scanning_dirs.clone();
let path_clone = path.clone();
thread::spawn(move || {
let cache_file = get_cache_file_for(&path_clone);
// 1. Try to load from disk cache if we don't have it in memory yet
if !is_cached
&& let Ok(data) = fs::read_to_string(&cache_file)
&& let Ok((all_files, dir_updates)) = serde_json::from_str::<(
Vec<PathBuf>,
HashMap<PathBuf, Vec<PathBuf>>,
)>(&data)
{
finished_scans.lock().unwrap().push((
path_clone.clone(),
all_files,
dir_updates,
));
if let Some(ctx) = ctx.as_ref() {
ctx.request_repaint();
}
}
// 2. Scan recursively
let mut all_files = Vec::new();
let mut dir_updates = HashMap::new();
let mut dirs_to_visit = vec![path_clone.clone()];
while let Some(dir) = dirs_to_visit.pop() {
if let Ok(entries) = fs::read_dir(&dir) {
let mut children = Vec::new();
for entry in entries.filter_map(|e| e.ok()) {
let p = entry.path();
if p.is_dir() {
dirs_to_visit.push(p.clone());
children.push(p);
} else if crate::gui::SUPPORTED_EXTENSIONS.contains(
&p.extension()
.unwrap_or_default()
.to_str()
.unwrap_or_default(),
) {
all_files.push(p.clone());
children.push(p);
}
}
dir_updates.insert(dir, children);
}
}
// 3. Save to disk cache
if let Some(parent) = cache_file.parent() {
fs::create_dir_all(parent).ok();
}
if let Ok(json) = serde_json::to_string(&(all_files.clone(), dir_updates.clone())) {
fs::write(&cache_file, json).ok();
}
// 4. Send to UI
finished_scans
.lock()
.unwrap()
.push((path_clone.clone(), all_files, dir_updates));
scanning_dirs.lock().unwrap().remove(&path_clone);
if let Some(ctx) = ctx {
ctx.request_repaint();
}
});
}
}
pub fn play_file(&mut self, path: &Path, concurrent: bool) {
make_request_async(Request::play(&path.to_string_lossy(), concurrent));
}
pub fn set_input(&mut self, name: String) {
make_request_async(Request::set_input(&name));
if self.config.save_input
&& let Ok(mut daemon_config) = get_daemon_config()
{
daemon_config.default_input_name = Some(name);
update_daemon_config(&daemon_config).ok();
}
}
pub fn set_output(&mut self, name: String) {
make_request_async(Request::set_output(&name));
if self.config.save_input
&& let Ok(mut daemon_config) = get_daemon_config()
{
// Empty means "follow the system default", which is stored as no device.
daemon_config.default_output_name = Some(name).filter(|n| !n.is_empty());
update_daemon_config(&daemon_config).ok();
}
}
pub fn toggle_loop(&mut self, id: Option<u32>) {
make_request_async(Request::toggle_loop(id));
}
pub fn pause(&mut self, id: Option<u32>) {
make_request_async(Request::pause(id));
}
pub fn resume(&mut self, id: Option<u32>) {
make_request_async(Request::resume(id));
}
pub fn stop(&mut self, id: Option<u32>) {
make_request_async(Request::stop(id));
}
pub fn play_hotkey_slot(&mut self, slot: &str) {
make_request_async(Request::play_hotkey(slot));
}
pub fn get_filtered_files(
&self,
matching_dirs: &HashSet<PathBuf>,
matching_files: &HashSet<PathBuf>,
) -> Vec<PathBuf> {
let search_query = self.app_state.search_query.to_lowercase();
let search_query = search_query.trim();
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 is_cached = self
.app_state
.current_dir
.as_ref()
.is_some_and(|d| self.app_state.recursive_files_cache.contains_key(d));
if !search_query.is_empty() && is_cached {
files
.into_iter()
.filter(|p| {
if p.is_dir() {
matching_dirs.contains(p)
} else {
matching_files.contains(p)
}
})
.collect()
} else {
files
.into_iter()
.filter(|entry_path| {
if !entry_path.is_dir()
&& !SUPPORTED_EXTENSIONS.contains(
&entry_path
.extension()
.unwrap_or_default()
.to_str()
.unwrap_or_default(),
)
{
return false;
}
if !search_query.is_empty() {
let file_name =
entry_path.file_name().unwrap_or_default().to_string_lossy();
if !file_name.to_lowercase().contains(search_query) {
return false;
}
}
true
})
.collect()
}
}
}
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: Some(true),
..Default::default()
});
fonts
.font_data
.insert(font_name.to_owned(), font_data.into());
fonts
.families
.entry(FontFamily::Proportional)
.or_default()
.insert(0, font_name.to_owned());
fonts
.families
.entry(FontFamily::Monospace)
.or_default()
.insert(0, font_name.to_owned());
Ok(())
}
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, ar_sans].concat();
for font in system_fonts.iter().rev() {
let font_bytes = match &font.source {
FoundFontSource::Path(path) => fs::read(path)?,
FoundFontSource::Bytes(bytes) => bytes.to_vec(),
};
add_font(&font.key, &font_bytes, fonts)?;
}
Ok(())
}
pub async fn run() -> Result<()> {
const ICON: &[u8] = include_bytes!("../../assets/icon.png");
let options = NativeOptions {
centered: true,
viewport: ViewportBuilder::default()
.with_app_id("ru.arabianq.pwsp")
.with_inner_size(Vec2::new(1200.0, 800.0))
.with_min_inner_size(Vec2::new(800.0, 600.0))
.with_icon(from_png_bytes(ICON)?),
..Default::default()
};
match run_native(
"Pipewire Soundpad",
options,
Box::new(|cc| {
egui_material_icons::initialize(&cc.egui_ctx);
let mut fonts = FontDefinitions::default();
load_system_fonts(&mut fonts).ok();
cc.egui_ctx.set_fonts(fonts);
Ok(Box::new(SoundpadGui::new(&cc.egui_ctx)))
}),
) {
Ok(_) => {
let config = get_gui_config();
if config.pause_on_exit {
make_request_sync(Request::pause(None)).ok();
}
Ok(())
}
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 empty_dirs = HashSet::new();
let empty_files = HashSet::new();
let filtered = gui.get_filtered_files(&empty_dirs, &empty_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(&empty_dirs, &empty_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(&empty_dirs, &empty_files);
assert_eq!(filtered_desc.len(), 2);
assert_eq!(filtered_desc[0], file_c);
assert_eq!(filtered_desc[1], file_b);
}
}
-149
View File
@@ -1,149 +0,0 @@
use crate::gui::SoundpadGui;
use eframe::{App, Frame as EFrame};
use egui::{CentralPanel, Context, ThemePreference};
use pwsp_lib::{
types::{
config::{DaemonConfig, PreferredTheme},
socket::Request,
},
utils::gui::{get_daemon_config, make_request_async, update_daemon_config},
};
impl SoundpadGui {
/// Persists a master volume to the daemon config when the user asked us to remember it.
fn remember_volume(&self, apply: impl FnOnce(&mut DaemonConfig)) {
if !self.config.save_volume {
return;
}
if let Ok(mut daemon_config) = get_daemon_config() {
apply(&mut daemon_config);
update_daemon_config(&daemon_config).ok();
}
}
}
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);
if let Some(current_dir) = &self.app_state.current_dir
&& current_dir == &path
{
self.app_state.current_dir = None;
self.app_state.listed_files.clear();
}
}
// Save directories if changed
if !self.config.dirs.eq(&self.app_state.dirs) {
self.config.dirs = self.app_state.dirs.clone();
self.config.save_to_file().ok();
}
// Per-track seek and volume requests
for (id, ui_state) in &mut self.app_state.track_ui_states {
if let Some(position) = ui_state.position.take_pending() {
make_request_async(Request::seek(position, Some(*id)));
}
if let Some(volume) = ui_state.volume.take_pending() {
make_request_async(Request::set_volume(volume, Some(*id)));
}
}
// Master volumes
if let Some(volume) = self.app_state.monitoring_volume.take_pending() {
make_request_async(Request::set_monitoring_volume(volume));
self.remember_volume(|config| config.default_monitoring_volume = Some(volume));
}
if let Some(volume) = self.app_state.mic_volume.take_pending() {
make_request_async(Request::set_mic_volume(volume));
self.remember_volume(|config| config.default_mic_volume = Some(volume));
}
if let Some(multiplier) = self.app_state.volume_multiplier.take_pending() {
make_request_async(Request::set_volume_multiplier(multiplier));
if self.config.save_volume_multiplier
&& let Ok(mut daemon_config) = get_daemon_config()
{
daemon_config.default_volume_multiplier = Some(multiplier);
update_daemon_config(&daemon_config).ok();
}
}
// Sync audio player state
{
let mut guard = self
.audio_player_state_shared
.lock()
.unwrap_or_else(|e| e.into_inner());
if let Some(config) = guard.hotkey_config.take() {
self.app_state.hotkey_config = config;
}
self.audio_player_state = guard.clone();
}
// Handle scale factor changes
let old_scale_factor = self.config.scale_factor;
let new_scale_factor = ctx.zoom_factor().clamp(0.5, 2.0);
ctx.set_zoom_factor(new_scale_factor);
self.config.scale_factor = new_scale_factor;
if new_scale_factor != old_scale_factor && self.config.save_scale_factor {
self.config.save_to_file().ok();
}
// Handle input
self.handle_input(ctx);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut EFrame) {
// Draw UI
CentralPanel::default().show(ui, |ui| {
if !self.audio_player_state.is_daemon_running {
self.draw_waiting_for_daemon(ui);
return;
}
if self.app_state.hotkey_capture_active {
self.draw_hotkey_capture(ui);
return;
}
if self.app_state.show_settings {
self.draw_settings(ui);
return;
}
if self.app_state.show_hotkeys {
self.draw_hotkeys(ui);
return;
}
self.draw(ui);
});
// Request repaint
ui.request_repaint_after_secs(1.0 / 60.0);
}
}
-683
View File
@@ -1,683 +0,0 @@
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, collections::HashSet, 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, Some(ui.ctx().clone()), false);
}
});
});
});
self.app_state.dirs = dirs;
if let Some(path) = dir_to_open {
self.open_dir(&path, Some(ui.ctx().clone()), false);
}
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 is_scanning = self
.app_state
.current_dir
.as_ref()
.is_some_and(|d| self.app_state.scanning_dirs.lock().unwrap().contains(d));
let spinner_width = if is_scanning { 20.0 } else { 0.0 };
let refresh_button = Button::new(ICON_REFRESH).frame(false);
let refresh_width = 24.0;
let search_field_response = ui.add_sized(
[
f32::max(
0.0,
ui.available_width() - spinner_width - refresh_width - 8.0,
),
22.0,
],
TextEdit::singleline(&mut self.app_state.search_query)
.hint_text(t!("gui.search_placeholder")),
);
if is_scanning {
ui.spinner();
}
let refresh_response = ui
.add_sized([refresh_width, 22.0], refresh_button)
.on_hover_text("Refresh");
if refresh_response.clicked()
&& let Some(dir) = self.app_state.current_dir.clone()
{
self.open_dir(&dir, Some(ui.ctx().clone()), true);
}
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);
let finished = {
let mut scans = self.app_state.finished_scans.lock().unwrap();
std::mem::take(&mut *scans)
};
for (top_dir, all_files, dir_updates) in finished {
self.app_state
.recursive_files_cache
.insert(top_dir, all_files);
self.app_state.dir_cache.extend(dir_updates);
}
ui.vertical(|ui| {
let mut actions = Vec::new();
let search_query = self.app_state.search_query.to_lowercase();
let search_query = search_query.trim();
let mut matching_dirs = std::collections::HashSet::new();
let mut matching_files = std::collections::HashSet::new();
if !search_query.is_empty()
&& let Some(current_dir) = &self.app_state.current_dir
&& let Some(all_files) = self.app_state.recursive_files_cache.get(current_dir)
{
for file in all_files {
if let Ok(rel_path) = file.strip_prefix(current_dir) {
let rel_path_str =
rel_path.to_string_lossy().to_lowercase().replace("\\", "/");
if rel_path_str.contains(search_query) {
matching_files.insert(file.clone());
let mut p = file.parent();
while let Some(parent) = p {
if parent == current_dir {
break;
}
matching_dirs.insert(parent.to_path_buf());
p = parent.parent();
}
}
}
}
}
let max_results = 300;
let is_truncated = !search_query.is_empty() && matching_files.len() > max_results;
let files = self.get_filtered_files(&matching_dirs, &matching_files);
let mut rendered_count = 0;
for entry_path in files {
Self::draw_tree_node(
ui,
entry_path,
&self.config,
&mut self.app_state,
&self.audio_player_state,
&mut actions,
&matching_dirs,
&matching_files,
search_query,
&mut rendered_count,
max_results,
);
}
if is_truncated {
ui.add_space(8.0);
let text = t!(
"gui.search_truncated_warning",
count = matching_files.len() - max_results
);
ui.label(
egui::RichText::new(text).color(egui::Color32::from_rgb(200, 150, 50)),
);
}
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);
});
}
#[allow(clippy::too_many_arguments)]
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>,
matching_dirs: &std::collections::HashSet<PathBuf>,
matching_files: &std::collections::HashSet<PathBuf>,
search_query: &str,
rendered_count: &mut usize,
max_results: usize,
) {
if *rendered_count >= max_results && !search_query.is_empty() {
return;
}
let dir_name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let id_salt = if search_query.is_empty() {
format!("normal_{:?}", path)
} else {
format!("search_{:?}", path)
};
let mut header = CollapsingHeader::new(dir_name).id_salt(id_salt);
let is_cached = app_state
.current_dir
.as_ref()
.is_some_and(|d| app_state.recursive_files_cache.contains_key(d));
if !search_query.is_empty() && is_cached {
header = header.default_open(true);
}
header.show(ui, |ui| {
let children = if let Some(cached) = app_state.dir_cache.get(&path) {
cached.clone()
} else {
let mut read = Vec::new();
if let Ok(entries) = std::fs::read_dir(&path) {
for entry in entries.filter_map(|e| e.ok()) {
let child_path = entry.path();
if !child_path.is_dir()
&& !crate::gui::SUPPORTED_EXTENSIONS.contains(
&child_path
.extension()
.unwrap_or_default()
.to_str()
.unwrap_or_default(),
)
{
continue;
}
read.push(child_path);
}
}
let sort_order = config.get_sort_order(&path);
read.sort_by(|a, b| {
let a_is_dir = a.is_dir();
let b_is_dir = b.is_dir();
if a_is_dir && !b_is_dir {
Ordering::Less
} else if !a_is_dir && b_is_dir {
Ordering::Greater
} else {
sort_order.compare(a, b)
}
});
app_state.dir_cache.insert(path.clone(), read.clone());
read
};
for child in children {
if *rendered_count >= max_results && !search_query.is_empty() {
break;
}
if !search_query.is_empty() && is_cached {
if child.is_dir() && !matching_dirs.contains(&child) {
continue;
}
if !child.is_dir() && !matching_files.contains(&child) {
continue;
}
} else if !search_query.is_empty() && !child.is_dir() {
let file_name = child
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_lowercase();
if !file_name.contains(search_query) {
continue;
}
}
Self::draw_tree_node(
ui,
child,
config,
app_state,
audio_player_state,
actions,
matching_dirs,
matching_files,
search_query,
rendered_count,
max_results,
);
}
});
}
fn draw_tree_node_file(
ui: &mut Ui,
path: std::path::PathBuf,
app_state: &mut AppState,
audio_player_state: &AudioPlayerState,
actions: &mut Vec<FileAction>,
rendered_count: &mut usize,
) {
*rendered_count += 1;
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('"', "\\\"")
));
}
});
});
}
#[allow(clippy::too_many_arguments)]
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>,
matching_dirs: &HashSet<PathBuf>,
matching_files: &HashSet<PathBuf>,
search_query: &str,
rendered_count: &mut usize,
max_results: usize,
) {
if *rendered_count >= max_results && !search_query.is_empty() {
return;
}
if path.is_dir() {
Self::draw_tree_node_dir(
ui,
path,
config,
app_state,
audio_player_state,
actions,
matching_dirs,
matching_files,
search_query,
rendered_count,
max_results,
);
} else {
Self::draw_tree_node_file(
ui,
path,
app_state,
audio_player_state,
actions,
rendered_count,
);
}
}
}
-106
View File
@@ -1,106 +0,0 @@
use crate::gui::SoundpadGui;
use egui::{AtomExt, Button, Label, RichText, Slider, Ui, Vec2};
use egui_material_icons::icons::*;
use pwsp_lib::types::gui::SliderLatch;
use rust_i18n::t;
/// Masters go past 100% on purpose: amplifying the mic feed without deafening yourself is
/// the point of splitting the two paths.
const MAX_MASTER_VOLUME: f32 = 2.0;
const VOLUME_SLIDER_WIDTH: f32 = 90.0;
const ICON_SIZE: f32 = 18.0;
/// Fixes the row height before anything is placed in it.
///
/// A horizontal layout centres each widget against the row height known at the time, so a
/// row that grows while being filled leaves whatever was added first sitting too high.
const FOOTER_ROW_HEIGHT: f32 = 24.0;
impl SoundpadGui {
pub fn draw_footer(&mut self, ui: &mut Ui) {
ui.add_space(5.0);
ui.horizontal(|ui| {
ui.set_min_height(FOOTER_ROW_HEIGHT);
self.draw_monitoring_volume(ui);
self.draw_mic_volume(ui);
// Right-aligns the icon buttons. Clamped because a negative value pushes them
// out of view instead of merely crowding them.
let spacer = ui.available_width() - ICON_SIZE * 2.0 - ui.spacing().item_spacing.x * 2.0;
ui.add_space(spacer.max(0.0));
self.draw_hotkeys_button(ui);
self.draw_settings_button(ui);
});
}
fn draw_monitoring_volume(&mut self, ui: &mut Ui) {
let volume = self.audio_player_state.monitoring_volume;
let icon = Self::get_volume_icon(volume);
ui.add_sized(
[ICON_SIZE, FOOTER_ROW_HEIGHT],
Label::new(RichText::new(icon).size(ICON_SIZE)),
)
.on_hover_text(format!(
"{}: {:.0}%",
t!("gui.monitoring_volume"),
volume * 100.0
));
Self::draw_volume_slider(ui, &mut self.app_state.monitoring_volume, volume);
}
fn draw_mic_volume(&mut self, ui: &mut Ui) {
let volume = self.audio_player_state.mic_volume;
let icon = if volume <= 0.0 {
ICON_MIC_OFF.codepoint
} else {
ICON_MIC.codepoint
};
ui.add_sized(
[ICON_SIZE, FOOTER_ROW_HEIGHT],
Label::new(RichText::new(icon).size(ICON_SIZE)),
)
.on_hover_text(format!("{}: {:.0}%", t!("gui.mic_volume"), volume * 100.0));
Self::draw_volume_slider(ui, &mut self.app_state.mic_volume, volume);
}
fn draw_volume_slider(ui: &mut Ui, latch: &mut SliderLatch, daemon_value: f32) {
latch.sync(daemon_value);
// A Slider draws its rail at spacing().slider_width regardless of what add_sized
// allocates, so both need the same number or the widget overruns its space.
ui.spacing_mut().slider_width = VOLUME_SLIDER_WIDTH;
let slider = Slider::new(&mut latch.value, 0.0..=MAX_MASTER_VOLUME)
.show_value(false)
.step_by(0.01);
if ui
.add_sized([VOLUME_SLIDER_WIDTH, FOOTER_ROW_HEIGHT], slider)
.drag_stopped()
{
latch.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([ICON_SIZE, FOOTER_ROW_HEIGHT], 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([ICON_SIZE, FOOTER_ROW_HEIGHT], settings_button);
if settings_button_response.clicked() {
self.app_state.show_settings = true;
}
}
}
-177
View File
@@ -1,177 +0,0 @@
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;
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.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.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();
ui_state.position.sync(track.position);
ui_state.volume.sync(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
@@ -1,32 +0,0 @@
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
@@ -1,304 +0,0 @@
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);
}
}
}
}
-59
View File
@@ -1,59 +0,0 @@
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
);
// Masters go past 100%, which must not fall off the top of the ladder.
assert_eq!(SoundpadGui::get_volume_icon(1.0), ICON_VOLUME_UP.codepoint);
assert_eq!(SoundpadGui::get_volume_icon(2.0), ICON_VOLUME_UP.codepoint);
}
}
-216
View File
@@ -1,216 +0,0 @@
use crate::{gui::SoundpadGui, locale};
use egui::{Align, Button, Color32, ComboBox, Layout, RichText, Slider, 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_volume_multiplier_response = ui.checkbox(
&mut self.config.save_volume_multiplier,
t!("gui.settings.remember_volume_multiplier"),
);
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_volume_multiplier_response.changed()
|| save_input_response.changed()
|| save_scale_response.changed()
|| pause_on_exit_response.changed()
{
self.config.save_to_file().ok();
}
// --------------------------------
ui.separator();
// ---------- Selectors -----------
self.draw_mic_selection(ui);
self.draw_output_selection(ui);
self.draw_language_selection(ui);
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.separator();
// ----------- Sliders ------------
// Volume multiplier
self.app_state
.volume_multiplier
.sync(self.audio_player_state.volume_multiplier);
ui.horizontal(|ui| {
let slider = Slider::new(&mut self.app_state.volume_multiplier.value, 0.01..=3.0);
let response = ui.add(slider);
ui.label(t!("gui.settings.volume_multiplier"));
if response.changed() {
// This condition is required to avoid spamming requests while dragging, but to allow changing the value via TextEdit
if !response.dragged() || (response.dragged() && response.drag_stopped()) {
self.app_state.volume_multiplier.dragged = true;
}
}
});
// --------------------------------
ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
ui.label(t!(
"gui.settings.version",
version = env!("CARGO_PKG_VERSION")
));
});
});
}
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_output_selection(&mut self, ui: &mut Ui) {
let outputs = &self.audio_player_state.all_outputs_sorted;
let mut selected_output = self.audio_player_state.current_output.to_owned();
let prev_output = selected_output.to_owned();
// An empty selection means no device is pinned.
let selected_text = self
.audio_player_state
.all_outputs
.get(&selected_output)
.cloned()
.unwrap_or_else(|| t!("gui.default_output").to_string());
ComboBox::from_label(t!("gui.choose_output_select"))
.height(30.0)
.selected_text(selected_text)
.show_ui(ui, |ui| {
// Listed first so pinning a device stays undoable.
ui.selectable_value(
&mut selected_output,
String::new(),
t!("gui.default_output"),
);
for (name, nick) in outputs {
ui.selectable_value(&mut selected_output, name.clone(), nick);
}
});
if selected_output != prev_output {
self.set_output(selected_output);
}
}
fn draw_language_selection(&mut self, ui: &mut Ui) {
let mut selected = self.config.forced_lang.clone();
let previous = selected.clone();
let selected_text = match &selected {
Some(code) => locale::display_name(code).to_string(),
None => t!("gui.settings.language.system").to_string(),
};
ComboBox::from_label(t!("gui.settings.language.label"))
.height(30.0)
.selected_text(selected_text)
.show_ui(ui, |ui| {
// Listed first so picking a language stays undoable.
ui.selectable_value(&mut selected, None, t!("gui.settings.language.system"));
for code in locale::available() {
let name = locale::display_name(&code);
ui.selectable_value(&mut selected, Some(code.clone()), name);
}
});
if selected != previous {
self.config.forced_lang = selected;
self.config.save_to_file().ok();
locale::apply(&self.config);
}
}
}
@@ -1,14 +0,0 @@
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(),
);
});
}
}
-58
View File
@@ -1,58 +0,0 @@
use pwsp_lib::types::config::GuiConfig;
/// Applies the UI language: the one pinned in the config, or the system's when none is.
///
/// Safe to call at any time — `t!` reads the locale on every lookup, so the next frame
/// already renders in the new language.
pub fn apply(config: &GuiConfig) {
let locale = config.forced_lang.clone().unwrap_or_else(system_locale);
rust_i18n::set_locale(&locale);
}
fn system_locale() -> String {
sys_locale::get_locale().unwrap_or_else(|| String::from("en-US"))
}
/// Locales shipped under `locales/`, in a stable order for the menu.
pub fn available() -> Vec<String> {
let mut locales: Vec<String> = rust_i18n::available_locales!()
.into_iter()
.map(|locale| locale.to_string())
.collect();
locales.sort_unstable();
locales
}
/// How a language names itself, which is how it should appear in a language picker
/// regardless of the language the rest of the UI is in. An unnamed locale falls back to
/// its code, so a newly shipped translation is still selectable.
pub fn display_name(code: &str) -> &str {
match code {
"en" => "English",
"ru" => "Русский",
"es" => "Español",
"fr" => "Français",
"zh" => "中文",
"ar" => "العربية",
"kz" => "Қазақша",
"he" => "עברית",
"pt-BR" => "Português (Brasil)",
other => other,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_shipped_locale_has_a_native_name() {
for code in available() {
assert_ne!(
display_name(&code),
code,
"locale {code} falls back to its code in the picker"
);
}
}
}
-76
View File
@@ -1,76 +0,0 @@
mod gui;
mod locale;
use anyhow::{Context, Result, anyhow};
use pwsp_lib::utils::gui::{ensure_pwsp_audio_dir, get_gui_config};
use rust_i18n::i18n;
use std::{
env,
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
i18n!("locales", fallback = "en");
#[tokio::main]
async fn main() -> Result<()> {
locale::apply(&get_gui_config());
let args = env::args().skip(1).collect::<Vec<String>>();
if let Some(uri) = args.first() {
match download_audio_from_url(uri).await {
Ok(path) => println!("Successfully downloaded to: {:?}", path),
Err(e) => eprintln!("Error downloading file: {}", e),
}
} else {
gui::run().await?;
}
Ok(())
}
async fn download_audio_from_url(uri: &str) -> Result<PathBuf> {
let prefix = "soundpad://sound/url/";
let target_url = uri
.strip_prefix(prefix)
.ok_or_else(|| anyhow!("URI does not containt an expected prefix: {}", prefix))?;
let file_name_encoded = match target_url.split('/').next_back() {
Some(path) => path.to_string(),
None => {
let id = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time went back")
.as_nanos();
format!("downloaded_audio_{}.mp3", id)
}
};
let file_name = percent_encoding::percent_decode_str(&file_name_encoded.clone())
.decode_utf8()
.unwrap_or_else(|_| file_name_encoded.into())
.into_owned();
let normalized_file_name = file_name.replace('\\', "/");
let sanitized_file_name = Path::new(&normalized_file_name)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("downloaded_audio.mp3");
let save_path = ensure_pwsp_audio_dir()?.join(sanitized_file_name);
let response = reqwest::get(target_url)
.await?
.error_for_status()
.context("Failed to fetch file")?;
let bytes = response.bytes().await?;
tokio::fs::write(&save_path, bytes)
.await
.context("Failed to save file to disk")?;
Ok(save_path)
}
-28
View File
@@ -1,28 +0,0 @@
[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
-809
View File
@@ -1,809 +0,0 @@
use crate::{
types::pipewire::{AudioDevice, DeviceType},
utils::{
daemon::with_daemon_config,
pipewire::{
PwTerminator, create_link, ensure_route, get_all_devices, get_device, get_device_by_id,
get_sink,
},
},
};
use anyhow::{Result, anyhow};
use rodio::{Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source};
use serde::{Deserialize, Serialize};
use std::{
collections::{HashMap, HashSet},
fs,
io::BufReader,
path::{Path, PathBuf},
time::Duration,
};
const VIRTUAL_MIC_NAME: &str = "pwsp-virtual-mic";
/// Streams are re-opened on the first play after an idle period, so this poll sits in the
/// path between pressing a key and hearing the sound. The node normally shows up within a
/// poll or two; the attempt count only bounds the pathological case.
const NODE_DISCOVERY_ATTEMPTS: u32 = 200;
const NODE_DISCOVERY_INTERVAL: Duration = Duration::from_millis(5);
type FileDecoder = Decoder<BufReader<fs::File>>;
#[derive(Debug, Eq, PartialEq, Default, Clone, Serialize, Deserialize)]
pub enum PlayerState {
#[default]
Stopped,
Paused,
Playing,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrackInfo {
pub id: u32,
pub path: PathBuf,
pub duration: Option<f32>,
pub position: f32,
pub volume: f32,
pub looped: bool,
pub paused: bool,
}
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
pub struct FullState {
pub state: PlayerState,
pub tracks: Vec<TrackInfo>,
pub monitoring_volume: f32,
pub mic_volume: f32,
pub volume_multiplier: f32,
pub current_input: String,
pub all_inputs: HashMap<String, String>,
pub current_output: String,
pub all_outputs: HashMap<String, String>,
}
/// Which of the two independent output paths a volume applies to.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum VolumeTarget {
/// What the user hears locally.
Monitoring,
/// What is fed into the virtual microphone, i.e. what everybody else hears.
Mic,
}
/// The two rodio players backing a single track, one per output path.
///
/// Every playback control has to reach both, so they are wrapped rather than fanned out
/// by hand at each call site.
pub struct PlayerPair {
pub monitoring: Player,
pub mic: Option<Player>,
}
impl PlayerPair {
pub fn for_each(&self, f: impl Fn(&Player)) {
f(&self.monitoring);
if let Some(mic) = &self.mic {
f(mic);
}
}
/// The player that answers queries about position and paused state for the pair.
pub fn primary(&self) -> &Player {
&self.monitoring
}
/// True once every player of the pair has run out of samples.
pub fn empty(&self) -> bool {
self.monitoring.empty() && self.mic.as_ref().is_none_or(|mic| mic.empty())
}
}
pub struct PlayingSound {
pub id: u32,
pub players: PlayerPair,
pub path: PathBuf,
pub duration: Option<f32>,
pub looped: bool,
pub volume: f32,
}
/// Final linear gain handed to a rodio `Player`.
///
/// Values above `1.0` are allowed on purpose; anything non-finite or negative collapses to
/// silence rather than reaching the mixer.
pub fn effective_gain(master: f32, track: f32, multiplier: f32) -> f32 {
let gain = master * track * multiplier;
if gain.is_finite() && gain > 0.0 {
gain
} else {
0.0
}
}
pub struct AudioPlayer {
monitoring_stream: Option<MixerDeviceSink>,
mic_stream: Option<MixerDeviceSink>,
/// PipeWire nodes backing the two streams, once discovered.
monitoring_node: Option<AudioDevice>,
mic_node: Option<AudioDevice>,
/// Links we created ourselves; dropping them tears the routes down.
monitoring_route: Option<PwTerminator>,
mic_route: Option<PwTerminator>,
input_link_sender: Option<PwTerminator>,
pub tracks: HashMap<u32, PlayingSound>,
pub next_id: u32,
pub input_device_name: Option<String>,
/// `None` pins nothing, leaving the monitoring stream to be routed like any other
/// application's.
pub output_device_name: Option<String>,
pub monitoring_volume: f32,
pub mic_volume: f32,
pub volume_multiplier: f32,
}
impl AudioPlayer {
pub async fn new() -> Result<Self> {
let (
default_input_name,
default_output_name,
default_monitoring_volume,
default_mic_volume,
default_volume_multiplier,
) = with_daemon_config(|c| {
(
c.default_input_name.clone(),
c.default_output_name.clone(),
c.default_monitoring_volume.unwrap_or(1.0),
c.default_mic_volume.unwrap_or(1.0),
c.default_volume_multiplier.unwrap_or(1.0),
)
});
let mut audio_player = AudioPlayer {
monitoring_stream: None,
mic_stream: None,
monitoring_node: None,
mic_node: None,
monitoring_route: None,
mic_route: None,
input_link_sender: None,
tracks: HashMap::new(),
next_id: 1,
input_device_name: default_input_name,
output_device_name: default_output_name,
monitoring_volume: default_monitoring_volume,
mic_volume: default_mic_volume,
volume_multiplier: default_volume_multiplier,
};
if audio_player.input_device_name.is_some() {
audio_player.link_devices().await?;
}
Ok(audio_player)
}
/// Opens both output streams and routes them, if that has not happened yet.
///
/// Streams are opened one at a time: node discovery below tells them apart by diffing
/// the graph around each open, which only works if the opens do not overlap.
async fn ensure_streams(&mut self) -> Result<()> {
if self.monitoring_stream.is_none() {
let (stream, node) = open_stream_and_identify().await?;
self.monitoring_stream = Some(stream);
self.monitoring_node = node;
}
if self.mic_stream.is_none() {
let (stream, node) = open_stream_and_identify().await?;
self.mic_stream = Some(stream);
self.mic_node = node;
}
self.ensure_routes().await;
Ok(())
}
/// Closes both output streams once nothing is playing.
///
/// This is what lets a laptop suspend: an open stream keeps the audio device busy and
/// blocks sleep. Routing is rebuilt on the next `play()`, since re-opening the streams
/// mints new node ids.
fn drop_streams(&mut self) {
if self.monitoring_stream.is_none() && self.mic_stream.is_none() {
return;
}
self.monitoring_stream = None;
self.mic_stream = None;
// The nodes and our links die with the streams, so none of this may outlive them.
self.monitoring_node = None;
self.mic_node = None;
self.monitoring_route = None;
self.mic_route = None;
}
/// Re-asserts both routes. Idempotent, so it can run on every device-check tick.
async fn ensure_routes(&mut self) {
if let Some(node) = self.mic_node.clone() {
match self.route(&node, get_device(VIRTUAL_MIC_NAME).await).await {
Ok(Some(terminator)) => self.mic_route = Some(terminator),
Ok(None) => {}
Err(err) => eprintln!("Failed to route mic stream to virtual mic: {}", err),
}
}
// With nothing pinned the monitoring stream is left alone: picking a target
// ourselves would mean re-implementing the session manager's policy, where the
// default sink is only a fallback that a per-stream target overrides.
if let Some(name) = self.output_device_name.clone()
&& let Some(node) = self.monitoring_node.clone()
{
match self.route(&node, get_sink(&name).await).await {
Ok(Some(terminator)) => self.monitoring_route = Some(terminator),
Ok(None) => {}
Err(err) => eprintln!(
"Failed to route monitoring stream to output device {}: {}",
name, err
),
}
}
}
/// Re-reads the source node before linking, so a route survives the node's ports
/// being rediscovered.
async fn route(
&self,
source: &AudioDevice,
target: Result<AudioDevice>,
) -> Result<Option<PwTerminator>> {
let source = get_device_by_id(source.id).await?;
ensure_route(&source, &target?).await
}
fn abort_link_thread(&mut self) {
if self.input_link_sender.is_some() {
println!("Sent terminate signal to input link thread");
self.input_link_sender = None;
}
}
async fn link_devices(&mut self) -> Result<()> {
self.abort_link_thread();
let input_device;
if let Some(input_device_name) = &self.input_device_name {
if let Ok(device) = get_device(input_device_name).await {
input_device = device;
} else {
eprintln!(
"Could not find selected input device {}, skipping device linking",
input_device_name
);
return Ok(());
}
} else {
eprintln!("No input device selected, skipping device linking");
return Ok(());
}
let daemon_input;
if let Ok(device) = get_device(VIRTUAL_MIC_NAME).await {
daemon_input = device;
} else {
eprintln!("Could not find pwsp-virtual-mic device, skipping device linking");
return Ok(());
}
let Some(output_fl) = input_device.output_fl.clone() else {
eprintln!("Failed to get input device output_fl");
return Ok(());
};
let Some(output_fr) = input_device.output_fr.clone() else {
eprintln!("Failed to get input device output_fr");
return Ok(());
};
let Some(input_fl) = daemon_input.input_fl.clone() else {
eprintln!("Failed to get pwsp-virtual-mic input_fl");
return Ok(());
};
let Some(input_fr) = daemon_input.input_fr.clone() else {
eprintln!("Failed to get pwsp-virtual-mic input_fr");
return Ok(());
};
self.input_link_sender = Some(create_link(output_fl, output_fr, input_fl, input_fr).await?);
Ok(())
}
pub fn pause(&mut self, id: Option<u32>) {
self.for_selected(id, |sound| sound.players.for_each(|p| p.pause()));
}
pub fn resume(&mut self, id: Option<u32>) {
self.for_selected(id, |sound| sound.players.for_each(|p| p.play()));
}
fn for_selected(&mut self, id: Option<u32>, f: impl Fn(&mut PlayingSound)) {
if let Some(id) = id {
if let Some(sound) = self.tracks.get_mut(&id) {
f(sound);
}
} else {
for sound in self.tracks.values_mut() {
f(sound);
}
}
}
pub fn stop(&mut self, id: Option<u32>) {
if let Some(id) = id {
self.tracks.remove(&id);
} else {
self.tracks.clear();
}
if self.tracks.is_empty() {
self.drop_streams();
}
}
pub fn is_paused(&self) -> bool {
if self.tracks.is_empty() {
return false;
}
self.tracks
.values()
.all(|s| s.players.primary().is_paused())
}
pub fn get_state(&self) -> PlayerState {
if self.tracks.is_empty() {
return PlayerState::Stopped;
}
if self
.tracks
.values()
.any(|s| !s.players.primary().is_paused() && !s.players.primary().empty())
{
return PlayerState::Playing;
}
if self.is_paused() {
return PlayerState::Paused;
}
PlayerState::Stopped
}
pub fn get_volume(&self, id: Option<u32>) -> Option<f32> {
match id {
Some(id) => self.tracks.get(&id).map(|sound| sound.volume),
None => Some(self.monitoring_volume),
}
}
/// Pushes the current master/track/multiplier state onto every live player.
///
/// Single source of truth for the gain math — every setter below funnels through it.
fn reapply_volumes(&mut self) {
for sound in self.tracks.values() {
sound.players.monitoring.set_volume(effective_gain(
self.monitoring_volume,
sound.volume,
self.volume_multiplier,
));
if let Some(mic) = &sound.players.mic {
mic.set_volume(effective_gain(
self.mic_volume,
sound.volume,
self.volume_multiplier,
));
}
}
}
pub fn set_master_volume(&mut self, volume: f32, target: VolumeTarget) {
match target {
VolumeTarget::Monitoring => self.monitoring_volume = volume,
VolumeTarget::Mic => self.mic_volume = volume,
}
self.reapply_volumes();
}
pub fn get_master_volume(&self, target: VolumeTarget) -> f32 {
match target {
VolumeTarget::Monitoring => self.monitoring_volume,
VolumeTarget::Mic => self.mic_volume,
}
}
pub fn set_volume_multiplier(&mut self, multiplier: f32) {
self.volume_multiplier = multiplier;
self.reapply_volumes();
}
/// Sets a single track's volume, or — without an id — moves both masters at once,
/// which is the "make everything quieter" shortcut.
pub fn set_volume(&mut self, volume: f32, id: Option<u32>) {
if let Some(id) = id {
if let Some(sound) = self.tracks.get_mut(&id) {
sound.volume = volume;
}
} else {
self.monitoring_volume = volume;
self.mic_volume = volume;
}
self.reapply_volumes();
}
pub fn get_position(&self, id: Option<u32>) -> f32 {
if let Some(id) = id {
if let Some(sound) = self.tracks.get(&id) {
return sound.players.primary().get_pos().as_secs_f32();
}
} else if let Some(sound) = self.tracks.values().last() {
// Fallback to last added track if no ID
return sound.players.primary().get_pos().as_secs_f32();
}
0.0
}
pub fn seek(&mut self, position: f32, id: Option<u32>) -> Result<()> {
let position = if position < 0.0 { 0.0 } else { position };
let position = Duration::from_secs_f32(position);
if let Some(id) = id {
if let Some(sound) = self.tracks.get_mut(&id) {
sound.players.monitoring.try_seek(position)?;
if let Some(mic) = &sound.players.mic {
mic.try_seek(position).ok();
}
}
} else {
for sound in self.tracks.values_mut() {
sound.players.for_each(|p| {
p.try_seek(position).ok();
});
}
}
Ok(())
}
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(anyhow!("Unknown duration"));
}
} else if let Some(sound) = self.tracks.values().last() {
return sound.duration.ok_or(anyhow!("Unknown duration"));
}
Err(anyhow!("No track playing"))
}
pub async fn play(&mut self, file_path: &Path, concurrent: bool) -> Result<u32> {
// One decoder per output path: the two streams run on different device clocks,
// so they cannot share a source anyway, and rodio's `Buffered` (the only shareable
// source) does not support seeking.
let (monitoring_source, mic_source) = tokio::try_join!(
decode(file_path.to_path_buf()),
decode(file_path.to_path_buf()),
)?;
if !concurrent {
self.tracks.clear();
}
self.ensure_streams().await?;
let duration = monitoring_source.total_duration().map(|d| d.as_secs_f32());
let monitoring_mixer = self
.monitoring_stream
.as_ref()
.ok_or_else(|| anyhow!("monitoring stream is unexpectedly missing"))?
.mixer();
let monitoring = Player::connect_new(monitoring_mixer);
monitoring.set_volume(effective_gain(
self.monitoring_volume,
1.0,
self.volume_multiplier,
));
monitoring.append(monitoring_source);
monitoring.play();
// A missing mic stream degrades to monitoring-only playback rather than failing.
let mic = self.mic_stream.as_ref().map(|stream| {
let player = Player::connect_new(stream.mixer());
player.set_volume(effective_gain(self.mic_volume, 1.0, self.volume_multiplier));
player.append(mic_source);
player.play();
player
});
let id = self.next_id;
self.next_id += 1;
self.tracks.insert(
id,
PlayingSound {
id,
players: PlayerPair { monitoring, mic },
path: file_path.to_path_buf(),
duration,
looped: false,
volume: 1.0,
},
);
Ok(id)
}
pub fn set_loop(&mut self, enabled: bool, id: Option<u32>) {
self.for_selected(id, |sound| sound.looped = enabled);
}
pub fn get_tracks(&self) -> Vec<TrackInfo> {
let mut tracks: Vec<_> = self
.tracks
.values()
.map(|sound| TrackInfo {
id: sound.id,
path: sound.path.clone(),
duration: sound.duration,
position: sound.players.primary().get_pos().as_secs_f32(),
volume: sound.volume,
looped: sound.looped,
paused: sound.players.primary().is_paused(),
})
.collect();
tracks.sort_by_key(|t| t.id);
tracks
}
pub async fn update(&mut self, check_devices: bool) {
if check_devices {
if let Some(input_device_name) = &self.input_device_name {
// Unlink devices if selected input device was removed
if self.input_link_sender.is_some() && get_device(input_device_name).await.is_err()
{
eprintln!(
"Selected input device {} was removed, unlinking devices",
input_device_name
);
self.abort_link_thread();
}
// Link devices if not linked
else if self.input_link_sender.is_none() {
self.link_devices().await.ok();
}
}
if self.monitoring_stream.is_some() || self.mic_stream.is_some() {
self.ensure_routes().await;
}
}
self.restart_looped_tracks().await;
self.tracks
.retain(|_, sound| !sound.players.empty() || sound.looped);
if self.tracks.is_empty() {
self.drop_streams();
}
}
async fn restart_looped_tracks(&mut self) {
let restarts: Vec<u32> = self
.tracks
.iter()
.filter(|(_, sound)| sound.looped && sound.players.empty())
.map(|(id, _)| *id)
.collect();
let mut restart_futures = vec![];
for id in restarts {
if let Some(sound) = self.tracks.get(&id) {
let path = sound.path.clone();
restart_futures.push(async move {
tokio::try_join!(decode(path.clone()), decode(path))
.ok()
.map(|(monitoring, mic)| (id, monitoring, mic))
});
}
}
for future in restart_futures {
if let Some((id, monitoring_source, mic_source)) = future.await
&& let Some(sound) = self.tracks.get_mut(&id)
{
if sound.players.monitoring.empty() {
sound.players.monitoring.append(monitoring_source);
sound.players.monitoring.play();
}
if let Some(mic) = &sound.players.mic
&& mic.empty()
{
mic.append(mic_source);
mic.play();
}
}
}
}
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(anyhow!("Selected device is not an input device"));
}
self.input_device_name = Some(name.to_string());
self.link_devices().await?;
Ok(())
}
pub async fn set_current_output_device(&mut self, name: &str) -> Result<()> {
// Fails early with a useful message if the name does not name a sink.
get_sink(name).await?;
self.output_device_name = Some(name.to_string());
self.monitoring_route = None;
self.ensure_routes().await;
Ok(())
}
/// Stops pinning an output device.
///
/// The existing link is deliberately left in place: tearing it down would strand the
/// node with no output at all, since autoconnect only runs when a node first appears.
/// Streams close as soon as playback stops, so the next sound opens a fresh node that
/// gets routed normally.
pub fn clear_current_output_device(&mut self) {
self.output_device_name = None;
}
}
async fn decode(path: PathBuf) -> Result<FileDecoder> {
tokio::task::spawn_blocking(move || {
if !path.exists() {
return Err(anyhow!("File does not exist: {}", path.display()));
}
let file = fs::File::open(&path)?;
Decoder::try_from(file).map_err(|e| anyhow!(e))
})
.await?
}
/// True for the `Stream/Output/Audio` nodes that belong to us.
///
/// The guard matters: node discovery below hands the result to link pruning, and pruning
/// a stranger's node would silence another application.
fn is_own_stream_node(device: &AudioDevice) -> bool {
let is_pwsp = |s: &str| s.to_ascii_lowercase().contains("pwsp");
(is_pwsp(&device.name) || is_pwsp(&device.nick))
&& device.output_fl.is_some()
&& device.output_fr.is_some()
}
async fn own_stream_node_ids() -> HashSet<u32> {
match get_all_devices().await {
Ok(devices) => devices
.outputs
.iter()
.filter(|d| is_own_stream_node(d))
.map(|d| d.id)
.collect(),
Err(_) => HashSet::new(),
}
}
/// Opens one rodio output stream and works out which PipeWire node it produced.
///
/// Identification is a before/after diff of our own stream nodes rather than an index
/// into a sorted list, so it does not depend on enumeration order. Callers must open
/// streams one at a time for the diff to stay unambiguous.
///
/// A stream that cannot be matched to a node yields `None`: playback still works, only
/// explicit routing is skipped.
async fn open_stream_and_identify() -> Result<(MixerDeviceSink, Option<AudioDevice>)> {
let before = own_stream_node_ids().await;
let mut stream = DeviceSinkBuilder::open_default_sink()?;
stream.log_on_drop(false);
// Checked before the first sleep: the PCM is already open by the time open_stream
// returns, so the node is often registered and this costs nothing.
for attempt in 0..NODE_DISCOVERY_ATTEMPTS {
if attempt > 0 {
tokio::time::sleep(NODE_DISCOVERY_INTERVAL).await;
}
let devices = match get_all_devices().await {
Ok(devices) => devices,
Err(_) => continue,
};
if let Some(node) = devices
.outputs
.into_iter()
.find(|d| !before.contains(&d.id) && is_own_stream_node(d))
{
return Ok((stream, Some(node)));
}
}
eprintln!("Timed out waiting for the new PipeWire node, routing will be skipped");
Ok((stream, None))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_effective_gain() {
assert_eq!(effective_gain(1.0, 1.0, 1.0), 1.0);
assert_eq!(effective_gain(0.5, 0.5, 1.0), 0.25);
// Amplification beyond 1.0 is allowed on purpose.
assert_eq!(effective_gain(2.0, 1.0, 1.0), 2.0);
assert_eq!(effective_gain(2.0, 1.0, 3.0), 6.0);
// Anything that is not a usable gain collapses to silence.
assert_eq!(effective_gain(0.0, 1.0, 1.0), 0.0);
assert_eq!(effective_gain(-1.0, 1.0, 1.0), 0.0);
assert_eq!(effective_gain(f32::NAN, 1.0, 1.0), 0.0);
assert_eq!(effective_gain(f32::INFINITY, 1.0, 1.0), 0.0);
}
fn stream_node(name: &str, with_ports: bool) -> AudioDevice {
use crate::types::pipewire::Port;
let mut device = AudioDevice::new(1, None, None, Some(name), DeviceType::Output);
if with_ports {
device.add_port(Port {
node_id: 1,
port_id: 1,
name: "output_FL".to_string(),
});
device.add_port(Port {
node_id: 1,
port_id: 2,
name: "output_FR".to_string(),
});
}
device
}
#[test]
fn test_is_own_stream_node() {
assert!(is_own_stream_node(&stream_node(
"alsa_playback.pwsp-daemon",
true
)));
assert!(is_own_stream_node(&stream_node("PWSP-daemon", true)));
// Somebody else's playback stream must never be treated as ours.
assert!(!is_own_stream_node(&stream_node("Firefox", true)));
// Nor a node whose ports have not been discovered yet.
assert!(!is_own_stream_node(&stream_node(
"alsa_playback.pwsp-daemon",
false
)));
}
}
-932
View File
@@ -1,932 +0,0 @@
use crate::{
types::{
audio_player::{FullState, PlayerState, VolumeTarget},
config::{DaemonConfig, HotkeyConfig},
socket::{Request, Response},
},
utils::{
commands::parse_command,
daemon::{get_audio_player, with_daemon_config},
pipewire::{get_all_devices, get_device},
},
};
use async_trait::async_trait;
use std::{collections::HashMap, path::PathBuf};
#[async_trait]
pub trait Executable {
async fn execute(&self) -> Response;
}
pub struct PingCommand {}
pub struct KillCommand {}
pub struct PauseCommand {
pub id: Option<u32>,
}
pub struct ResumeCommand {
pub id: Option<u32>,
}
pub struct TogglePauseCommand {
pub id: Option<u32>,
}
pub struct StopCommand {
pub id: Option<u32>,
}
pub struct IsPausedCommand {}
pub struct GetStateCommand {}
pub struct GetVolumeCommand {
pub id: Option<u32>,
}
pub struct GetVolumeMultiplierCommand {}
pub struct SetVolumeCommand {
pub volume: Option<f32>,
pub id: Option<u32>,
}
pub struct SetVolumeMultiplierCommand {
pub volume_multiplier: Option<f32>,
}
pub struct GetMasterVolumeCommand {
pub target: VolumeTarget,
}
pub struct SetMasterVolumeCommand {
pub volume: Option<f32>,
pub target: VolumeTarget,
}
pub struct GetPositionCommand {
pub id: Option<u32>,
}
pub struct SeekCommand {
pub position: Option<f32>,
pub id: Option<u32>,
}
pub struct GetDurationCommand {
pub id: Option<u32>,
}
pub struct PlayCommand {
pub file_path: Option<PathBuf>,
pub concurrent: Option<bool>,
}
pub struct GetTracksCommand {}
pub struct GetCurrentInputCommand {}
pub struct GetAllInputsCommand {}
pub struct SetCurrentInputCommand {
pub name: Option<String>,
}
pub struct GetCurrentOutputCommand {}
pub struct GetAllOutputsCommand {}
pub struct SetCurrentOutputCommand {
pub name: Option<String>,
}
pub struct SetLoopCommand {
pub enabled: Option<bool>,
pub id: Option<u32>,
}
pub struct ToggleLoopCommand {
pub id: Option<u32>,
}
pub struct GetDaemonVersionCommand {}
pub struct GetFullStateCommand {}
pub struct GetHotkeysCommand {}
pub struct SetHotkeyCommand {
pub slot: Option<String>,
pub file_path: Option<PathBuf>,
}
pub struct SetHotkeyActionCommand {
pub slot: Option<String>,
pub action: Option<Request>,
}
pub struct SetHotkeyKeyCommand {
pub slot: Option<String>,
pub key_chord: Option<String>,
}
pub struct SetHotkeyActionAndKeyCommand {
pub slot: Option<String>,
pub action: Option<Request>,
pub key_chord: Option<String>,
}
pub struct PlayHotkeyCommand {
pub slot: Option<String>,
}
pub struct ClearHotkeyCommand {
pub slot: Option<String>,
}
pub struct ClearHotkeyKeyCommand {
pub slot: Option<String>,
}
pub struct GetDaemonConfigCommand {}
pub struct SaveDaemonConfigCommand {}
pub struct UpdateDaemonConfigCommand {
pub new_config: DaemonConfig,
}
#[async_trait]
impl Executable for PingCommand {
async fn execute(&self) -> Response {
Response::new(true, "pong")
}
}
#[async_trait]
impl Executable for KillCommand {
async fn execute(&self) -> Response {
Response::new(true, "killed")
}
}
#[async_trait]
impl Executable for PauseCommand {
async fn execute(&self) -> Response {
let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
audio_player.pause(self.id);
Response::new(true, "Audio was paused")
}
}
#[async_trait]
impl Executable for ResumeCommand {
async fn execute(&self) -> Response {
let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
audio_player.resume(self.id);
Response::new(true, "Audio was resumed")
}
}
#[async_trait]
impl Executable for TogglePauseCommand {
async fn execute(&self) -> Response {
let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
if audio_player.get_state() == PlayerState::Stopped {
return Response::new(false, "Audio is not playing");
}
// This logic is a bit tricky with multiple tracks.
// If ID is provided, toggle that track.
// If not, toggle global pause state?
// For now, let's just use pause/resume based on global state if no ID.
if let Some(id) = self.id {
if let Some(track) = audio_player.tracks.get(&id) {
if track.players.primary().is_paused() {
audio_player.resume(Some(id));
Response::new(true, "Audio was resumed")
} else {
audio_player.pause(Some(id));
Response::new(true, "Audio was paused")
}
} else {
Response::new(false, "Track not found")
}
} else {
if audio_player.is_paused() {
audio_player.resume(None);
Response::new(true, "Audio was resumed")
} else {
audio_player.pause(None);
Response::new(true, "Audio was paused")
}
}
}
}
#[async_trait]
impl Executable for StopCommand {
async fn execute(&self) -> Response {
let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
audio_player.stop(self.id);
Response::new(true, "Audio was stopped")
}
}
#[async_trait]
impl Executable for IsPausedCommand {
async fn execute(&self) -> Response {
let audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
let is_paused = audio_player.is_paused().to_string();
Response::new(true, is_paused)
}
}
#[async_trait]
impl Executable for GetStateCommand {
async fn execute(&self) -> Response {
let audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
let state = audio_player.get_state();
match serde_json::to_string(&state) {
Ok(json) => Response::new(true, json),
Err(err) => Response::new(false, format!("Failed to serialize state: {}", err)),
}
}
}
#[async_trait]
impl Executable for GetVolumeCommand {
async fn execute(&self) -> Response {
let audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
let volume = audio_player.get_volume(self.id);
if let Some(volume) = volume {
Response::new(true, volume.to_string())
} else {
Response::new(false, "Failed to get volume")
}
}
}
#[async_trait]
impl Executable for GetVolumeMultiplierCommand {
async fn execute(&self) -> Response {
let audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
Response::new(true, audio_player.volume_multiplier.to_string())
}
}
/// Rejects values that would corrupt the mixer. The daemon is the trust boundary here:
/// hotkeys store raw `Request` JSON, so a bad value can arrive without passing the CLI.
fn validate_volume(volume: Option<f32>) -> Option<f32> {
volume.filter(|v| v.is_finite() && *v >= 0.0)
}
#[async_trait]
impl Executable for SetVolumeCommand {
async fn execute(&self) -> Response {
if let Some(volume) = validate_volume(self.volume) {
let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
audio_player.set_volume(volume, self.id);
Response::new(true, format!("Audio volume was set to {}", volume))
} else {
Response::new(false, "Invalid volume value")
}
}
}
#[async_trait]
impl Executable for GetMasterVolumeCommand {
async fn execute(&self) -> Response {
let audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
Response::new(
true,
audio_player.get_master_volume(self.target).to_string(),
)
}
}
#[async_trait]
impl Executable for SetMasterVolumeCommand {
async fn execute(&self) -> Response {
if let Some(volume) = validate_volume(self.volume) {
let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
audio_player.set_master_volume(volume, self.target);
let name = match self.target {
VolumeTarget::Monitoring => "Monitoring",
VolumeTarget::Mic => "Microphone",
};
Response::new(true, format!("{} volume was set to {}", name, volume))
} else {
Response::new(false, "Invalid volume value")
}
}
}
#[async_trait]
impl Executable for SetVolumeMultiplierCommand {
async fn execute(&self) -> Response {
if let Some(volume_multiplier) = validate_volume(self.volume_multiplier) {
let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
audio_player.set_volume_multiplier(volume_multiplier);
Response::new(
true,
format!("Audio volume multiplier was set to {}", volume_multiplier),
)
} else {
Response::new(false, "Invalid volume multiplier value")
}
}
}
#[async_trait]
impl Executable for GetPositionCommand {
async fn execute(&self) -> Response {
let audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
let position = audio_player.get_position(self.id);
Response::new(true, position.to_string())
}
}
#[async_trait]
impl Executable for SeekCommand {
async fn execute(&self) -> Response {
if let Some(position) = self.position {
let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
match audio_player.seek(position, self.id) {
Ok(_) => Response::new(true, format!("Audio position was set to {}", position)),
Err(err) => Response::new(false, err.to_string()),
}
} else {
Response::new(false, "Invalid position value")
}
}
}
#[async_trait]
impl Executable for GetDurationCommand {
async fn execute(&self) -> Response {
let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
match audio_player.get_duration(self.id) {
Ok(duration) => Response::new(true, duration.to_string()),
Err(err) => Response::new(false, err.to_string()),
}
}
}
#[async_trait]
impl Executable for PlayCommand {
async fn execute(&self) -> Response {
if let Some(file_path) = &self.file_path {
let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
match audio_player
.play(file_path, self.concurrent.unwrap_or(false))
.await
{
Ok(id) => Response::new(true, id.to_string()),
Err(err) => Response::new(false, err.to_string()),
}
} else {
Response::new(false, "Invalid file path")
}
}
}
#[async_trait]
impl Executable for GetTracksCommand {
async fn execute(&self) -> Response {
let audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
let tracks = audio_player.get_tracks();
match serde_json::to_string(&tracks) {
Ok(json) => Response::new(true, json),
Err(err) => Response::new(false, format!("Failed to serialize tracks: {}", err)),
}
}
}
#[async_trait]
impl Executable for GetCurrentInputCommand {
async fn execute(&self) -> Response {
let audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
if let Some(input_device_name) = &audio_player.input_device_name {
if let Ok(input_device) = get_device(input_device_name).await {
Response::new(
true,
format!("{} - {}", input_device.name, input_device.nick),
)
} else {
Response::new(false, "Failed to get current input device")
}
} else {
Response::new(false, "No input device selected")
}
}
}
#[async_trait]
impl Executable for GetAllInputsCommand {
async fn execute(&self) -> Response {
let input_devices = match get_all_devices().await {
Ok(devices) => devices.inputs,
Err(err) => return Response::new(false, format!("Failed to get devices: {}", err)),
};
let mut input_devices_strings = vec![];
for device in input_devices {
if device.name == "pwsp-virtual-mic" {
continue;
}
let string = format!("{} - {}", device.name, device.nick);
input_devices_strings.push(string);
}
let response_message = input_devices_strings.join("; ");
Response::new(true, response_message)
}
}
#[async_trait]
impl Executable for SetCurrentInputCommand {
async fn execute(&self) -> Response {
if let Some(name) = &self.name {
let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
match audio_player.set_current_input_device(name).await {
Ok(_) => Response::new(true, "Input device was set"),
Err(err) => Response::new(false, err.to_string()),
}
} else {
Response::new(false, "Invalid index value")
}
}
}
#[async_trait]
impl Executable for GetCurrentOutputCommand {
async fn execute(&self) -> Response {
let audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
match &audio_player.output_device_name {
Some(name) => Response::new(true, name),
// No pinned device means playback follows whatever the system default is.
None => Response::new(true, ""),
}
}
}
#[async_trait]
impl Executable for GetAllOutputsCommand {
async fn execute(&self) -> Response {
let sinks = match get_all_devices().await {
Ok(devices) => devices.sinks,
Err(err) => return Response::new(false, format!("Failed to get devices: {}", err)),
};
let response_message = sinks
.into_iter()
.map(|device| format!("{} - {}", device.name, device.nick))
.collect::<Vec<_>>()
.join("; ");
Response::new(true, response_message)
}
}
#[async_trait]
impl Executable for SetCurrentOutputCommand {
async fn execute(&self) -> Response {
let Some(name) = &self.name else {
return Response::new(false, "Invalid device name");
};
let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
// An empty name is how a client asks to stop pinning a device and follow the
// system default again.
if name.is_empty() {
audio_player.clear_current_output_device();
return Response::new(true, "Output device follows the system default");
}
match audio_player.set_current_output_device(name).await {
Ok(_) => Response::new(true, "Output device was set"),
Err(err) => Response::new(false, err.to_string()),
}
}
}
#[async_trait]
impl Executable for SetLoopCommand {
async fn execute(&self) -> Response {
let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
match self.enabled {
Some(enabled) => {
audio_player.set_loop(enabled, self.id);
Response::new(true, format!("Loop was set to {}", enabled))
}
None => Response::new(false, "Invalid enabled value"),
}
}
}
#[async_trait]
impl Executable for ToggleLoopCommand {
async fn execute(&self) -> Response {
let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
if let Some(id) = self.id {
if let Some(track) = audio_player.tracks.get_mut(&id) {
track.looped = !track.looped;
Response::new(true, format!("Loop was set to {}", track.looped))
} else {
Response::new(false, "Track not found")
}
} else {
// Toggle all?
for track in audio_player.tracks.values_mut() {
track.looped = !track.looped;
}
Response::new(true, "Loop toggled for all tracks")
}
}
}
#[async_trait]
impl Executable for GetDaemonVersionCommand {
async fn execute(&self) -> Response {
Response::new(true, env!("CARGO_PKG_VERSION"))
}
}
#[async_trait]
impl Executable for GetFullStateCommand {
async fn execute(&self) -> Response {
let devices = match get_all_devices().await {
Ok(devices) => devices,
Err(err) => return Response::new(false, format!("Failed to get devices: {}", err)),
};
let input_devices = devices.inputs;
let mut all_inputs = HashMap::new();
let mut current_input_nick = String::new();
let audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
if let Some(current_input_name) = &audio_player.input_device_name {
for device in input_devices {
if device.name == "pwsp-virtual-mic" {
continue;
}
if device.name == *current_input_name {
current_input_nick = format!("{} - {}", device.name, device.nick);
}
all_inputs.insert(device.name, device.nick);
}
} else {
for device in input_devices {
if device.name == "pwsp-virtual-mic" {
continue;
}
all_inputs.insert(device.name, device.nick);
}
}
let all_outputs: HashMap<String, String> = devices
.sinks
.into_iter()
.map(|device| (device.name, device.nick))
.collect();
let full_state = FullState {
state: audio_player.get_state(),
tracks: audio_player.get_tracks(),
monitoring_volume: audio_player.monitoring_volume,
mic_volume: audio_player.mic_volume,
volume_multiplier: audio_player.volume_multiplier,
current_input: current_input_nick,
all_inputs,
current_output: audio_player.output_device_name.clone().unwrap_or_default(),
all_outputs,
};
match serde_json::to_string(&full_state) {
Ok(json) => Response::new(true, json),
Err(err) => Response::new(false, format!("Failed to serialize full state: {}", err)),
}
}
}
#[async_trait]
impl Executable for GetHotkeysCommand {
async fn execute(&self) -> Response {
match HotkeyConfig::load() {
Ok(config) => match serde_json::to_string(&config) {
Ok(json) => Response::new(true, json),
Err(err) => Response::new(false, format!("Failed to serialize hotkeys: {}", err)),
},
Err(err) => Response::new(false, format!("Failed to load hotkeys: {}", err)),
}
}
}
#[async_trait]
impl Executable for SetHotkeyCommand {
async fn execute(&self) -> Response {
let Some(slot) = &self.slot else {
return Response::new(false, "Missing slot name");
};
let Some(file_path) = &self.file_path else {
return Response::new(false, "Missing file path");
};
let mut config = match HotkeyConfig::load() {
Ok(c) => c,
Err(err) => return Response::new(false, format!("Failed to load hotkeys: {}", err)),
};
config.set_slot(
slot.clone(),
Request::play(&file_path.to_string_lossy(), false),
);
match config.save() {
Ok(_) => Response::new(true, format!("Hotkey slot '{}' set", slot)),
Err(err) => Response::new(false, format!("Failed to save hotkeys: {}", err)),
}
}
}
#[async_trait]
impl Executable for SetHotkeyActionCommand {
async fn execute(&self) -> Response {
let Some(slot) = &self.slot else {
return Response::new(false, "Missing slot name");
};
let Some(action) = &self.action else {
return Response::new(false, "Missing or invalid action");
};
let mut config = match HotkeyConfig::load() {
Ok(c) => c,
Err(err) => return Response::new(false, format!("Failed to load hotkeys: {}", err)),
};
config.set_slot(slot.clone(), action.clone());
match config.save() {
Ok(_) => Response::new(true, format!("Hotkey slot '{}' set", slot)),
Err(err) => Response::new(false, format!("Failed to save hotkeys: {}", err)),
}
}
}
#[async_trait]
impl Executable for SetHotkeyKeyCommand {
async fn execute(&self) -> Response {
let Some(slot) = &self.slot else {
return Response::new(false, "Missing slot name");
};
let Some(key_chord) = &self.key_chord else {
return Response::new(false, "Missing key chord");
};
let mut config = match HotkeyConfig::load() {
Ok(c) => c,
Err(err) => return Response::new(false, format!("Failed to load hotkeys: {}", err)),
};
if !config.set_key_chord(slot, Some(key_chord.clone())) {
return Response::new(false, format!("Slot '{}' not found", slot));
}
match config.save() {
Ok(_) => Response::new(
true,
format!("Key chord for slot '{}' set to '{}'", slot, key_chord),
),
Err(err) => Response::new(false, format!("Failed to save hotkeys: {}", err)),
}
}
}
#[async_trait]
impl Executable for SetHotkeyActionAndKeyCommand {
async fn execute(&self) -> Response {
let Some(slot) = &self.slot else {
return Response::new(false, "Missing slot name");
};
let Some(action) = &self.action else {
return Response::new(false, "Missing or invalid action");
};
let Some(key_chord) = &self.key_chord else {
return Response::new(false, "Missing key chord");
};
let mut config = match HotkeyConfig::load() {
Ok(c) => c,
Err(err) => return Response::new(false, format!("Failed to load hotkeys: {}", err)),
};
// Set the action and then the key chord
config.set_slot(slot.clone(), action.clone());
if !config.set_key_chord(slot, Some(key_chord.clone())) {
return Response::new(
false,
format!("Slot '{}' not found after setting action", slot),
);
}
match config.save() {
Ok(_) => Response::new(
true,
format!(
"Hotkey slot '{}' set with action and key chord '{}'",
slot, key_chord
),
),
Err(err) => Response::new(false, format!("Failed to save hotkeys: {}", err)),
}
}
}
#[async_trait]
impl Executable for PlayHotkeyCommand {
async fn execute(&self) -> Response {
let Some(slot) = &self.slot else {
return Response::new(false, "Missing slot name");
};
let config = match HotkeyConfig::load() {
Ok(c) => c,
Err(err) => return Response::new(false, format!("Failed to load hotkeys: {}", err)),
};
let Some(hotkey_slot) = config.find_slot(slot) else {
return Response::new(false, format!("Slot '{}' not found", slot));
};
let action = hotkey_slot.action.clone();
if let Some(cmd) = parse_command(&action) {
cmd.execute().await
} else {
Response::new(false, "Unknown command in hotkey slot")
}
}
}
#[async_trait]
impl Executable for ClearHotkeyCommand {
async fn execute(&self) -> Response {
let Some(slot) = &self.slot else {
return Response::new(false, "Missing slot name");
};
let mut config = match HotkeyConfig::load() {
Ok(c) => c,
Err(err) => return Response::new(false, format!("Failed to load hotkeys: {}", err)),
};
if config.remove_slot(slot) {
match config.save() {
Ok(_) => Response::new(true, format!("Hotkey slot '{}' cleared", slot)),
Err(err) => Response::new(false, format!("Failed to save hotkeys: {}", err)),
}
} else {
Response::new(false, format!("Slot '{}' not found", slot))
}
}
}
#[async_trait]
impl Executable for ClearHotkeyKeyCommand {
async fn execute(&self) -> Response {
let Some(slot) = &self.slot else {
return Response::new(false, "Missing slot name");
};
let mut config = match HotkeyConfig::load() {
Ok(c) => c,
Err(err) => return Response::new(false, format!("Failed to load hotkeys: {}", err)),
};
if !config.set_key_chord(slot, None) {
return Response::new(false, format!("Slot '{}' not found", slot));
}
match config.save() {
Ok(_) => Response::new(true, format!("Key chord for slot '{}' cleared", slot)),
Err(err) => Response::new(false, format!("Failed to save hotkeys: {}", err)),
}
}
}
#[async_trait]
impl Executable for GetDaemonConfigCommand {
async fn execute(&self) -> Response {
let serialized = with_daemon_config(|c| serde_json::to_string(&c));
match serialized {
Ok(s) => Response::new(true, s),
Err(err) => Response::new(false, format!("Failed to serialize daemon config: {}", err)),
}
}
}
#[async_trait]
impl Executable for SaveDaemonConfigCommand {
async fn execute(&self) -> Response {
match with_daemon_config(|c| c.save_to_file()) {
Ok(_) => Response::new(true, "Daemon config saved successfully"),
Err(err) => Response::new(false, format!("Failed to save daemon config: {}", err)),
}
}
}
#[async_trait]
impl Executable for UpdateDaemonConfigCommand {
async fn execute(&self) -> Response {
with_daemon_config(|c| {
c.clone_from(&self.new_config);
});
Response::new(true, "Daemon config updated successfully")
}
}
-368
View File
@@ -1,368 +0,0 @@
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_output_name: Option<String>,
pub default_monitoring_volume: Option<f32>,
pub default_mic_volume: Option<f32>,
pub default_volume_multiplier: 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_volume_multiplier: 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,
/// UI language code, or `None` to follow the system locale.
pub forced_lang: Option<String>,
}
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_volume_multiplier: false,
save_input: false,
save_scale_factor: false,
pause_on_exit: false,
dirs: vec![ensure_pwsp_audio_dir().unwrap()],
preferred_theme: PreferredTheme::System,
dirs_settings: HashMap::new(),
forced_lang: None,
}
}
}
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")));
}
}
-174
View File
@@ -1,174 +0,0 @@
use crate::types::{
audio_player::{PlayerState, TrackInfo},
config::HotkeyConfig,
};
use egui::Id;
use std::{
collections::{HashMap, HashSet},
path::PathBuf,
sync::{Arc, Mutex},
time::{Duration, Instant},
};
pub type ScanResult = (PathBuf, Vec<PathBuf>, HashMap<PathBuf, Vec<PathBuf>>);
/// How long the daemon's value is ignored after a slider commits, so the knob does not
/// snap back before the daemon has caught up.
const SETTLE_TIME: Duration = Duration::from_millis(300);
/// A slider whose local value wins over the daemon's while the user is interacting.
///
/// The daemon is polled at 60 Hz, so without this the value would fight the user mid-drag
/// and jump back right after release.
#[derive(Default, Debug)]
pub struct SliderLatch {
pub value: f32,
pub dragged: bool,
pub ignore_update_until: Option<Instant>,
}
impl SliderLatch {
/// Whether the daemon's value may overwrite the local one this frame.
fn should_sync(&self) -> bool {
!self.dragged
&& self
.ignore_update_until
.is_none_or(|until| Instant::now() > until)
}
/// Adopts the daemon's value unless the user is currently driving the slider.
pub fn sync(&mut self, value: f32) {
if self.should_sync() {
self.value = value;
}
}
/// Called once the pending change has been sent to the daemon.
fn commit(&mut self) {
self.dragged = false;
self.ignore_update_until = Some(Instant::now() + SETTLE_TIME);
}
/// Returns the value to send, if the user finished a change that has not been sent yet.
pub fn take_pending(&mut self) -> Option<f32> {
if self.dragged {
let value = self.value;
self.commit();
Some(value)
} else {
None
}
}
}
#[derive(Default, Debug)]
pub struct TrackUiState {
pub position: SliderLatch,
pub volume: SliderLatch,
}
#[derive(Default, Debug)]
pub struct AppState {
pub search_query: String,
pub track_ui_states: HashMap<u32, TrackUiState>,
pub show_settings: bool,
pub force_focus_search: bool,
pub monitoring_volume: SliderLatch,
pub mic_volume: SliderLatch,
pub volume_multiplier: SliderLatch,
pub search_field_id: Option<Id>,
pub current_dir: Option<PathBuf>,
pub dirs: Vec<PathBuf>,
pub dirs_to_remove: HashSet<PathBuf>,
pub listed_files: HashSet<PathBuf>,
pub listed_dirs: HashSet<PathBuf>,
pub dir_cache: HashMap<PathBuf, Vec<PathBuf>>,
pub scanning_dirs: Arc<Mutex<HashSet<PathBuf>>>,
pub scanned_this_session: HashSet<PathBuf>,
pub finished_scans: Arc<Mutex<Vec<ScanResult>>>,
pub recursive_files_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>,
}
#[derive(Default, Debug, Clone)]
pub struct AudioPlayerState {
pub state: PlayerState,
pub new_state: Option<PlayerState>,
pub tracks: Vec<TrackInfo>,
/// What the user hears locally.
pub monitoring_volume: f32,
/// What is sent to the virtual microphone.
pub mic_volume: f32,
pub volume_multiplier: f32,
pub current_input: String,
pub all_inputs: HashMap<String, String>,
pub all_inputs_sorted: Vec<(String, String)>,
/// Empty means "follow the system default sink".
pub current_output: String,
pub all_outputs: HashMap<String, String>,
pub all_outputs_sorted: Vec<(String, String)>,
pub is_daemon_running: bool,
pub hotkey_config: Option<HotkeyConfig>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_slider_latch_syncs_when_idle() {
let mut latch = SliderLatch::default();
latch.sync(0.7);
assert_eq!(latch.value, 0.7);
// While the user drags, the daemon must not move the knob out from under them.
latch.dragged = true;
latch.sync(0.2);
assert_eq!(latch.value, 0.7);
}
#[test]
fn test_slider_latch_take_pending() {
let mut latch = SliderLatch::default();
// Nothing to send until the user actually changes something.
assert_eq!(latch.take_pending(), None);
latch.value = 1.5;
latch.dragged = true;
assert_eq!(latch.take_pending(), Some(1.5));
// The change is sent exactly once.
assert_eq!(latch.take_pending(), None);
// The daemon is then ignored briefly, so a stale value already in flight cannot
// snap the slider back.
assert!(!latch.should_sync());
latch.sync(0.1);
assert_eq!(latch.value, 1.5);
}
}
-189
View File
@@ -1,189 +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,
Sink,
}
/// A link between two nodes in the PipeWire graph.
#[derive(Debug, Clone, Copy, Ord, PartialOrd, Eq, PartialEq)]
pub struct LinkInfo {
pub id: u32,
pub output_node: u32,
pub input_node: u32,
}
#[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) {
// `playback_*` are the input ports of an `Audio/Sink` node. `monitor_*` are its
// output ports and are deliberately left unmapped — we never record from a sink.
match port.name.as_str() {
"input_FL" | "playback_FL" => self.input_fl = Some(port),
"input_FR" | "playback_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" | "playback_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_sink_ports() {
let mut sink = AudioDevice::new(1, None, None, Some("speakers"), DeviceType::Sink);
let port = |id: u32, name: &str| Port {
node_id: 1,
port_id: id,
name: name.to_string(),
};
// playback_* carries audio into the sink, so those are its inputs.
sink.add_port(port(10, "playback_FL"));
sink.add_port(port(11, "playback_FR"));
assert_eq!(sink.input_fl, Some(port(10, "playback_FL")));
assert_eq!(sink.input_fr, Some(port(11, "playback_FR")));
// monitor_* is what the sink plays out; linking a stream there would be a loop.
sink.add_port(port(12, "monitor_FL"));
sink.add_port(port(13, "monitor_FR"));
assert_eq!(sink.output_fl, None);
assert_eq!(sink.output_fr, None);
}
#[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));
}
}
-421
View File
@@ -1,421 +0,0 @@
use crate::types::config::DaemonConfig;
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,
pub args: HashMap<String, String>,
}
impl Request {
pub fn new<T: AsRef<str>>(function_name: T, data: Vec<(T, T)>) -> Self {
let hashmap_data: HashMap<String, String> = data
.into_iter()
.map(|(key, value)| (key.as_ref().to_string(), value.as_ref().to_string()))
.collect();
Request {
name: function_name.as_ref().to_string(),
args: hashmap_data,
}
}
pub fn ping() -> Self {
Request::new("ping", vec![])
}
pub fn kill() -> Self {
Request::new("kill", vec![])
}
pub fn pause(id: Option<u32>) -> Self {
let mut args = vec![];
let id_str;
if let Some(id) = id {
id_str = id.to_string();
args.push(("id", id_str.as_str()));
}
Request::new("pause", args)
}
pub fn resume(id: Option<u32>) -> Self {
let mut args = vec![];
let id_str;
if let Some(id) = id {
id_str = id.to_string();
args.push(("id", id_str.as_str()));
}
Request::new("resume", args)
}
pub fn toggle_pause(id: Option<u32>) -> Self {
let mut args = vec![];
let id_str;
if let Some(id) = id {
id_str = id.to_string();
args.push(("id", id_str.as_str()));
}
Request::new("toggle_pause", args)
}
pub fn stop(id: Option<u32>) -> Self {
let mut args = vec![];
let id_str;
if let Some(id) = id {
id_str = id.to_string();
args.push(("id", id_str.as_str()));
}
Request::new("stop", args)
}
pub fn play(file_path: &str, concurrent: bool) -> Self {
Request::new(
"play",
vec![
("file_path", file_path),
("concurrent", &concurrent.to_string()),
],
)
}
pub fn get_is_paused() -> Self {
Request::new("is_paused", vec![])
}
pub fn get_volume(id: Option<u32>) -> Self {
let mut args = vec![];
let id_str;
if let Some(id) = id {
id_str = id.to_string();
args.push(("id", id_str.as_str()));
}
Request::new("get_volume", args)
}
pub fn get_volume_multiplier() -> Self {
Request::new("get_volume_multiplier", vec![])
}
pub fn get_position(id: Option<u32>) -> Self {
let mut args = vec![];
let id_str;
if let Some(id) = id {
id_str = id.to_string();
args.push(("id", id_str.as_str()));
}
Request::new("get_position", args)
}
pub fn get_duration(id: Option<u32>) -> Self {
let mut args = vec![];
let id_str;
if let Some(id) = id {
id_str = id.to_string();
args.push(("id", id_str.as_str()));
}
Request::new("get_duration", args)
}
pub fn get_state() -> Self {
Request::new("get_state", vec![])
}
pub fn get_tracks() -> Self {
Request::new("get_tracks", vec![])
}
pub fn get_input() -> Self {
Request::new("get_input", vec![])
}
pub fn get_inputs() -> Self {
Request::new("get_inputs", vec![])
}
pub fn set_volume(volume: f32, id: Option<u32>) -> Self {
let mut args = vec![("volume".to_string(), volume.to_string())];
if let Some(id) = id {
args.push(("id".to_string(), id.to_string()));
}
Request::new("set_volume".to_string(), args)
}
pub fn get_monitoring_volume() -> Self {
Request::new("get_monitoring_volume", vec![])
}
pub fn set_monitoring_volume(volume: f32) -> Self {
Request::new(
"set_monitoring_volume",
vec![("volume", &volume.to_string())],
)
}
pub fn get_mic_volume() -> Self {
Request::new("get_mic_volume", vec![])
}
pub fn set_mic_volume(volume: f32) -> Self {
Request::new("set_mic_volume", vec![("volume", &volume.to_string())])
}
pub fn set_volume_multiplier(volume: f32) -> Self {
Request::new(
"set_volume_multiplier",
vec![("volume_multiplier", &volume.to_string())],
)
}
pub fn seek(position: f32, id: Option<u32>) -> Self {
let mut args = vec![("position".to_string(), position.to_string())];
if let Some(id) = id {
args.push(("id".to_string(), id.to_string()));
}
Request::new("seek".to_string(), args)
}
pub fn set_input(name: &str) -> Self {
Request::new("set_input", vec![("input_name", name)])
}
pub fn get_output() -> Self {
Request::new("get_output", vec![])
}
pub fn get_outputs() -> Self {
Request::new("get_outputs", vec![])
}
pub fn set_output(name: &str) -> Self {
Request::new("set_output", vec![("output_name", name)])
}
pub fn set_loop(enabled: &str, id: Option<u32>) -> Self {
let mut args = vec![("enabled".to_string(), enabled.to_string())];
if let Some(id) = id {
args.push(("id".to_string(), id.to_string()));
}
Request::new("set_loop".to_string(), args)
}
pub fn toggle_loop(id: Option<u32>) -> Self {
let mut args = vec![];
let id_str;
if let Some(id) = id {
id_str = id.to_string();
args.push(("id", id_str.as_str()));
}
Request::new("toggle_loop", args)
}
pub fn get_daemon_version() -> Self {
Request::new("get_daemon_version", vec![])
}
pub fn get_daemon_config() -> Self {
Request::new("get_daemon_config", vec![])
}
pub fn save_daemon_config() -> Self {
Request::new("save_daemon_config", vec![])
}
pub fn update_daemon_config(new_config: &DaemonConfig) -> Self {
Request::new(
"update_daemon_config",
vec![(
"new_config",
&serde_json::to_string(&new_config).unwrap_or_default(),
)],
)
}
pub fn get_full_state() -> Self {
Request::new("get_full_state", vec![])
}
pub fn get_hotkeys() -> Self {
Request::new("get_hotkeys", vec![])
}
pub fn set_hotkey(slot: &str, file_path: &str) -> Self {
Request::new("set_hotkey", vec![("slot", slot), ("file_path", file_path)])
}
pub fn set_hotkey_key(slot: &str, key_chord: &str) -> Self {
Request::new(
"set_hotkey_key",
vec![("slot", slot), ("key_chord", key_chord)],
)
}
pub fn clear_hotkey(slot: &str) -> Self {
Request::new("clear_hotkey", vec![("slot", slot)])
}
pub fn play_hotkey(slot: &str) -> Self {
Request::new("play_hotkey", vec![("slot", slot)])
}
pub fn set_hotkey_action(slot: &str, action: &Request) -> Self {
let action_json = serde_json::to_string(action).unwrap_or_default();
Request::new(
"set_hotkey_action",
vec![("slot", slot), ("action", &action_json)],
)
}
pub fn clear_hotkey_key(slot: &str) -> Self {
Request::new("clear_hotkey_key", vec![("slot", slot)])
}
pub fn set_hotkey_action_and_key(slot: &str, action: &Request, key_chord: &str) -> Self {
let action_json = serde_json::to_string(action).unwrap_or_default();
Request::new(
"set_hotkey_action_and_key",
vec![
("slot", slot),
("action", &action_json),
("key_chord", key_chord),
],
)
}
}
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
pub struct Response {
pub status: bool,
pub message: String,
}
impl Response {
pub fn new<T: AsRef<str>>(status: bool, message: T) -> Self {
Response {
status,
message: message.as_ref().to_string(),
}
}
}
#[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_volume_path_request_constructors() {
let monitoring = Request::set_monitoring_volume(1.5);
assert_eq!(monitoring.name, "set_monitoring_volume");
assert_eq!(
monitoring.args.get("volume").map(|s| s.as_str()),
Some("1.5")
);
let mic = Request::set_mic_volume(2.0);
assert_eq!(mic.name, "set_mic_volume");
assert_eq!(mic.args.get("volume").map(|s| s.as_str()), Some("2"));
assert_eq!(
Request::get_monitoring_volume().name,
"get_monitoring_volume"
);
assert!(Request::get_monitoring_volume().args.is_empty());
assert_eq!(Request::get_mic_volume().name, "get_mic_volume");
}
#[test]
fn test_output_request_constructors() {
let set_output = Request::set_output("some-sink");
assert_eq!(set_output.name, "set_output");
assert_eq!(
set_output.args.get("output_name").map(|s| s.as_str()),
Some("some-sink")
);
assert_eq!(Request::get_output().name, "get_output");
assert_eq!(Request::get_outputs().name, "get_outputs");
assert!(Request::get_outputs().args.is_empty());
}
#[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())
);
}
}
-315
View File
@@ -1,315 +0,0 @@
use crate::types::{
audio_player::VolumeTarget, commands::*, config::DaemonConfig, socket::Request,
};
use std::path::PathBuf;
/// Which of the two output paths a `*_monitoring_volume` / `*_mic_volume` command means.
fn volume_target(command_name: &str) -> Option<VolumeTarget> {
match command_name {
"get_monitoring_volume" | "set_monitoring_volume" => Some(VolumeTarget::Monitoring),
"get_mic_volume" | "set_mic_volume" => Some(VolumeTarget::Mic),
_ => None,
}
}
pub fn parse_command(request: &Request) -> Option<Box<dyn Executable + Send>> {
let id = request.args.get("id").and_then(|s| s.parse::<u32>().ok());
match request.name.as_str() {
"ping" => Some(Box::new(PingCommand {})),
"kill" => Some(Box::new(KillCommand {})),
"pause" => Some(Box::new(PauseCommand { id })),
"resume" => Some(Box::new(ResumeCommand { id })),
"toggle_pause" => Some(Box::new(TogglePauseCommand { id })),
"stop" => Some(Box::new(StopCommand { id })),
"is_paused" => Some(Box::new(IsPausedCommand {})),
"get_state" => Some(Box::new(GetStateCommand {})),
"get_volume" => Some(Box::new(GetVolumeCommand { id })),
"get_volume_multiplier" => Some(Box::new(GetVolumeMultiplierCommand {})),
"set_volume" => {
let volume = request
.args
.get("volume")
.unwrap_or(&String::new())
.parse::<f32>()
.ok();
Some(Box::new(SetVolumeCommand { volume, id }))
}
"get_monitoring_volume" | "get_mic_volume" => Some(Box::new(GetMasterVolumeCommand {
target: volume_target(&request.name)?,
})),
"set_monitoring_volume" | "set_mic_volume" => {
let volume = request
.args
.get("volume")
.unwrap_or(&String::new())
.parse::<f32>()
.ok();
Some(Box::new(SetMasterVolumeCommand {
volume,
target: volume_target(&request.name)?,
}))
}
"set_volume_multiplier" => {
let volume_multiplier = request
.args
.get("volume_multiplier")
.unwrap_or(&String::new())
.parse::<f32>()
.ok();
Some(Box::new(SetVolumeMultiplierCommand { volume_multiplier }))
}
"get_position" => Some(Box::new(GetPositionCommand { id })),
"seek" => {
let position = request
.args
.get("position")
.unwrap_or(&String::new())
.parse::<f32>()
.ok();
Some(Box::new(SeekCommand { position, id }))
}
"get_duration" => Some(Box::new(GetDurationCommand { id })),
"play" => {
let file_path = request
.args
.get("file_path")
.unwrap_or(&String::new())
.parse::<PathBuf>()
.ok();
let concurrent = request
.args
.get("concurrent")
.unwrap_or(&String::new())
.parse::<bool>()
.ok();
Some(Box::new(PlayCommand {
file_path,
concurrent,
}))
}
"get_tracks" => Some(Box::new(GetTracksCommand {})),
"get_input" => Some(Box::new(GetCurrentInputCommand {})),
"get_inputs" => Some(Box::new(GetAllInputsCommand {})),
"set_input" => {
let name = Some(request.args.get("input_name").unwrap_or(&String::new())).cloned();
Some(Box::new(SetCurrentInputCommand { name }))
}
"get_output" => Some(Box::new(GetCurrentOutputCommand {})),
"get_outputs" => Some(Box::new(GetAllOutputsCommand {})),
"set_output" => {
let name = Some(request.args.get("output_name").unwrap_or(&String::new())).cloned();
Some(Box::new(SetCurrentOutputCommand { name }))
}
"set_loop" => {
let enabled = request
.args
.get("enabled")
.unwrap_or(&String::new())
.parse::<bool>()
.ok();
Some(Box::new(SetLoopCommand { enabled, id }))
}
"toggle_loop" => Some(Box::new(ToggleLoopCommand { id })),
"get_daemon_version" => Some(Box::new(GetDaemonVersionCommand {})),
"get_full_state" => Some(Box::new(GetFullStateCommand {})),
"get_hotkeys" => Some(Box::new(GetHotkeysCommand {})),
"set_hotkey" => {
let slot = request.args.get("slot").cloned();
let file_path = request
.args
.get("file_path")
.and_then(|s| s.parse::<PathBuf>().ok());
Some(Box::new(SetHotkeyCommand { slot, file_path }))
}
"set_hotkey_key" => {
let slot = request.args.get("slot").cloned();
let key_chord = request.args.get("key_chord").cloned();
Some(Box::new(SetHotkeyKeyCommand { slot, key_chord }))
}
"clear_hotkey" => {
let slot = request.args.get("slot").cloned();
Some(Box::new(ClearHotkeyCommand { slot }))
}
"play_hotkey" => {
let slot = request.args.get("slot").cloned();
Some(Box::new(PlayHotkeyCommand { slot }))
}
"set_hotkey_action" => {
let slot = request.args.get("slot").cloned();
let action = request
.args
.get("action")
.and_then(|s| serde_json::from_str::<Request>(s).ok());
Some(Box::new(SetHotkeyActionCommand { slot, action }))
}
"clear_hotkey_key" => {
let slot = request.args.get("slot").cloned();
Some(Box::new(ClearHotkeyKeyCommand { slot }))
}
"set_hotkey_action_and_key" => {
let slot = request.args.get("slot").cloned();
let action = request
.args
.get("action")
.and_then(|s| serde_json::from_str::<Request>(s).ok());
let key_chord = request.args.get("key_chord").cloned();
Some(Box::new(SetHotkeyActionAndKeyCommand {
slot,
action,
key_chord,
}))
}
"get_daemon_config" => Some(Box::new(GetDaemonConfigCommand {})),
"save_daemon_config" => Some(Box::new(SaveDaemonConfigCommand {})),
"update_daemon_config" => {
let new_config = request
.args
.get("new_config")
.and_then(|nc| serde_json::from_str::<DaemonConfig>(nc).ok())
.unwrap_or_default();
Some(Box::new(UpdateDaemonConfigCommand { new_config }))
}
_ => 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());
}
#[test]
fn test_volume_target_mapping() {
assert_eq!(
volume_target("set_monitoring_volume"),
Some(VolumeTarget::Monitoring)
);
assert_eq!(
volume_target("get_monitoring_volume"),
Some(VolumeTarget::Monitoring)
);
assert_eq!(volume_target("set_mic_volume"), Some(VolumeTarget::Mic));
assert_eq!(volume_target("get_mic_volume"), Some(VolumeTarget::Mic));
// The two paths share a dispatch arm, so a name that is neither must not
// silently fall through to one of them.
assert_eq!(volume_target("set_volume"), None);
assert_eq!(volume_target("mic"), None);
}
#[test]
fn test_parse_new_commands() {
let with_volume = |name: &str| {
let mut args = HashMap::new();
args.insert("volume".to_string(), "2.0".to_string());
Request {
name: name.to_string(),
args,
}
};
assert!(parse_command(&with_volume("set_monitoring_volume")).is_some());
assert!(parse_command(&with_volume("set_mic_volume")).is_some());
for name in [
"get_monitoring_volume",
"get_mic_volume",
"get_output",
"get_outputs",
] {
let request = Request::new(name, vec![]);
assert!(
parse_command(&request).is_some(),
"{} did not dispatch",
name
);
}
let set_output = Request::set_output("some-sink");
assert!(parse_command(&set_output).is_some());
assert!(parse_command(&Request::new("set_speaker_volume", vec![])).is_none());
}
}
-7
View File
@@ -1,7 +0,0 @@
use anyhow::{Context, Result};
use std::path::PathBuf;
pub fn get_config_path() -> Result<PathBuf> {
let config_path = dirs::config_dir().context("Failed to obtain config dir")?;
Ok(config_path.join("pwsp"))
}
-160
View File
@@ -1,160 +0,0 @@
use crate::types::{
audio_player::AudioPlayer,
config::DaemonConfig,
socket::{MAX_MESSAGE_SIZE, Request, Response},
};
use anyhow::Result;
use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt};
use std::path::PathBuf;
use std::{
env,
error::Error,
fs,
sync::{Arc, Mutex, OnceLock},
};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::UnixStream,
sync::{Mutex as AsyncMutex, OnceCell},
time::{Duration, sleep},
};
static AUDIO_PLAYER: OnceCell<AsyncMutex<AudioPlayer>> = OnceCell::const_new();
static DAEMON_CONFIG: OnceLock<Arc<Mutex<DaemonConfig>>> = OnceLock::new();
pub async fn get_audio_player() -> Result<&'static AsyncMutex<AudioPlayer>, String> {
AUDIO_PLAYER
.get_or_try_init(|| async {
println!("Initializing audio player");
match AudioPlayer::new().await {
Ok(player) => Ok(AsyncMutex::new(player)),
Err(err) => Err(err.to_string()),
}
})
.await
}
pub fn get_daemon_config() -> &'static Arc<Mutex<DaemonConfig>> {
DAEMON_CONFIG.get_or_init(|| {
Arc::new(Mutex::new(DaemonConfig::load_from_file().unwrap_or_else(
|_| {
let config = DaemonConfig::default();
config.save_to_file().ok();
config
},
)))
})
}
pub fn with_daemon_config<R>(f: impl FnOnce(&mut DaemonConfig) -> R) -> R {
let config = get_daemon_config().clone();
let mut guard = config.lock().unwrap();
f(&mut guard)
}
fn get_current_uid() -> u32 {
rustix::process::geteuid().as_raw()
}
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() {
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)?;
}
Ok(())
}
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<()> {
if is_daemon_running()? {
return Ok(());
}
println!("Daemon not found, waiting for it...");
while !is_daemon_running()? {
sleep(Duration::from_millis(100)).await;
}
println!("Found running daemon");
Ok(())
}
pub async fn make_request(request: Request) -> Result<Response, Box<dyn Error + Send + Sync>> {
let socket_path = get_runtime_dir().join("daemon.sock");
let mut stream = UnixStream::connect(socket_path).await?;
// ---------- Send request (start) ----------
let request_data = serde_json::to_vec(&request)?;
let request_len = request_data.len() as u32;
if stream.write_all(&request_len.to_le_bytes()).await.is_err() {
return Err("Failed to send request length".into());
};
if stream.write_all(&request_data).await.is_err() {
return Err("Failed to send request".into());
}
// ---------- Send request (end) ----------
// ---------- Read response (start) ----------
let mut len_bytes = [0u8; 4];
if stream.read_exact(&mut len_bytes).await.is_err() {
return Err("Failed to read response length".into());
}
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());
};
// ---------- Read response (end) ----------
Ok(serde_json::from_slice(&buffer)?)
}
-277
View File
@@ -1,277 +0,0 @@
use crate::{types::config::HotkeyConfig, utils::commands::parse_command};
use evdev::{Device, EventStream, EventSummary, KeyCode};
struct ModifierState {
ctrl: bool,
alt: bool,
shift: bool,
meta: bool,
}
impl ModifierState {
fn new() -> Self {
Self {
ctrl: false,
alt: false,
shift: false,
meta: false,
}
}
fn update(&mut self, key: KeyCode, pressed: bool) {
match key {
KeyCode::KEY_LEFTCTRL | KeyCode::KEY_RIGHTCTRL => self.ctrl = pressed,
KeyCode::KEY_LEFTALT | KeyCode::KEY_RIGHTALT => self.alt = pressed,
KeyCode::KEY_LEFTSHIFT | KeyCode::KEY_RIGHTSHIFT => self.shift = pressed,
KeyCode::KEY_LEFTMETA | KeyCode::KEY_RIGHTMETA => self.meta = pressed,
_ => {}
}
}
fn any_active(&self) -> bool {
self.ctrl || self.alt || self.shift || self.meta
}
fn is_modifier(key: KeyCode) -> bool {
matches!(
key,
KeyCode::KEY_LEFTCTRL
| KeyCode::KEY_RIGHTCTRL
| KeyCode::KEY_LEFTALT
| KeyCode::KEY_RIGHTALT
| KeyCode::KEY_LEFTSHIFT
| KeyCode::KEY_RIGHTSHIFT
| KeyCode::KEY_LEFTMETA
| KeyCode::KEY_RIGHTMETA
)
}
}
fn evdev_key_name(key: KeyCode) -> Option<&'static str> {
match key {
KeyCode::KEY_A => Some("A"),
KeyCode::KEY_B => Some("B"),
KeyCode::KEY_C => Some("C"),
KeyCode::KEY_D => Some("D"),
KeyCode::KEY_E => Some("E"),
KeyCode::KEY_F => Some("F"),
KeyCode::KEY_G => Some("G"),
KeyCode::KEY_H => Some("H"),
KeyCode::KEY_I => Some("I"),
KeyCode::KEY_J => Some("J"),
KeyCode::KEY_K => Some("K"),
KeyCode::KEY_L => Some("L"),
KeyCode::KEY_M => Some("M"),
KeyCode::KEY_N => Some("N"),
KeyCode::KEY_O => Some("O"),
KeyCode::KEY_P => Some("P"),
KeyCode::KEY_Q => Some("Q"),
KeyCode::KEY_R => Some("R"),
KeyCode::KEY_S => Some("S"),
KeyCode::KEY_T => Some("T"),
KeyCode::KEY_U => Some("U"),
KeyCode::KEY_V => Some("V"),
KeyCode::KEY_W => Some("W"),
KeyCode::KEY_X => Some("X"),
KeyCode::KEY_Y => Some("Y"),
KeyCode::KEY_Z => Some("Z"),
KeyCode::KEY_1 => Some("1"),
KeyCode::KEY_2 => Some("2"),
KeyCode::KEY_3 => Some("3"),
KeyCode::KEY_4 => Some("4"),
KeyCode::KEY_5 => Some("5"),
KeyCode::KEY_6 => Some("6"),
KeyCode::KEY_7 => Some("7"),
KeyCode::KEY_8 => Some("8"),
KeyCode::KEY_9 => Some("9"),
KeyCode::KEY_0 => Some("0"),
KeyCode::KEY_F1 => Some("F1"),
KeyCode::KEY_F2 => Some("F2"),
KeyCode::KEY_F3 => Some("F3"),
KeyCode::KEY_F4 => Some("F4"),
KeyCode::KEY_F5 => Some("F5"),
KeyCode::KEY_F6 => Some("F6"),
KeyCode::KEY_F7 => Some("F7"),
KeyCode::KEY_F8 => Some("F8"),
KeyCode::KEY_F9 => Some("F9"),
KeyCode::KEY_F10 => Some("F10"),
KeyCode::KEY_F11 => Some("F11"),
KeyCode::KEY_F12 => Some("F12"),
_ => None,
}
}
fn build_chord(modifiers: &ModifierState, key_name: &str) -> String {
let mut parts = Vec::with_capacity(5);
if modifiers.ctrl {
parts.push("Ctrl");
}
if modifiers.alt {
parts.push("Alt");
}
if modifiers.shift {
parts.push("Shift");
}
if modifiers.meta {
parts.push("Super");
}
parts.push(key_name);
parts.join("+")
}
fn is_keyboard(device: &Device) -> bool {
device
.supported_keys()
.is_some_and(|keys| keys.contains(KeyCode::KEY_A) && keys.contains(KeyCode::KEY_Z))
}
async fn handle_device_events(mut stream: EventStream) {
let mut modifiers = ModifierState::new();
loop {
match stream.next_event().await {
Ok(event) => {
if let EventSummary::Key(_, key, value) = event.destructure() {
// 0 = released, 1 = pressed, 2 = repeat
if value == 0 || value == 1 {
modifiers.update(key, value == 1);
}
// Only trigger on press, skip modifiers and bare keys
if value != 1 || ModifierState::is_modifier(key) || !modifiers.any_active() {
continue;
}
let Some(key_name) = evdev_key_name(key) else {
continue;
};
let chord = build_chord(&modifiers, key_name);
let config = match HotkeyConfig::load() {
Ok(c) => c,
Err(_) => continue,
};
let slots = config.slots_for_chord(&chord);
for slot in slots {
if let Some(cmd) = parse_command(&slot.action) {
cmd.execute().await;
}
}
}
}
Err(e) => {
eprintln!("Global hotkeys: device read error: {e}");
break;
}
}
}
}
pub async fn start_global_hotkey_listener() {
let keyboards: Vec<_> = evdev::enumerate()
.filter(|(_, dev)| is_keyboard(dev))
.collect();
if keyboards.is_empty() {
eprintln!(
"Global hotkeys: no keyboard devices found. \
Make sure your user is in the 'input' group."
);
return;
}
println!(
"Global hotkeys: found {} keyboard device(s)",
keyboards.len()
);
for (path, device) in keyboards {
match device.into_event_stream() {
Ok(stream) => {
println!("Global hotkeys: listening on {}", path.display());
tokio::spawn(handle_device_events(stream));
}
Err(e) => {
eprintln!("Global hotkeys: failed to open {}: {}", path.display(), e);
}
}
}
}
#[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");
}
}
-185
View File
@@ -1,185 +0,0 @@
use crate::{
types::{
audio_player::FullState,
config::{DaemonConfig, GuiConfig, HotkeyConfig},
gui::AudioPlayerState,
socket::{Request, Response},
},
utils::daemon::{is_daemon_running, make_request},
};
use anyhow::{Result, anyhow};
use std::{
collections::HashMap,
fs,
path::PathBuf,
sync::{Arc, Mutex},
time::Instant,
};
use tokio::time::{Duration, sleep};
pub fn get_gui_config() -> GuiConfig {
GuiConfig::load_from_file().unwrap_or_else(|_| {
let mut config = GuiConfig::default();
config.save_to_file().ok();
config
})
}
pub fn update_daemon_config(new_config: &DaemonConfig) -> Result<()> {
make_request_sync(Request::update_daemon_config(new_config))?;
make_request_async(Request::save_daemon_config());
Ok(())
}
pub fn get_daemon_config() -> Result<DaemonConfig> {
let response = make_request_sync(Request::get_daemon_config())?;
let config: DaemonConfig = serde_json::from_str(&response.message)?;
Ok(config)
}
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| anyhow!(e))
})
}
pub fn make_request_async(request: Request) {
tokio::spawn(async move {
make_request(request).await.ok();
});
}
pub fn ensure_pwsp_audio_dir() -> Result<PathBuf> {
let audio_dir = dirs::audio_dir().unwrap_or(
dirs::home_dir()
.map(|p| p.join("Music"))
.ok_or_else(|| anyhow!("Failed to get home directory. Is your system ok?"))?,
);
let pwsp_audio_dir = audio_dir.join("PWSP");
if !pwsp_audio_dir.exists() {
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;
let minutes = total_seconds / 60;
let secs = total_seconds % 60;
format!("{:02}:{:02}", minutes, secs)
}
format!("{}/{}", format_time(position), format_time(duration))
}
/// Turns a name→nickname map into the stable order the combo boxes render in.
fn sorted_devices(devices: &HashMap<String, String>) -> Vec<(String, String)> {
let mut sorted: Vec<(String, String)> = devices
.iter()
.map(|(name, nick)| (name.clone(), nick.clone()))
.collect();
sorted.sort_by(|a, b| a.0.cmp(&b.0));
sorted
}
pub fn start_app_state_thread(audio_player_state_shared: Arc<Mutex<AudioPlayerState>>) {
tokio::spawn(async move {
let sleep_duration = Duration::from_secs_f32(1.0 / 60.0);
let mut last_hotkey_poll = Instant::now();
loop {
let is_running = is_daemon_running().unwrap_or(false);
if !is_running {
{
let mut guard = audio_player_state_shared
.lock()
.unwrap_or_else(|e| e.into_inner());
guard.is_daemon_running = false;
}
sleep(Duration::from_millis(500)).await;
continue;
}
let full_state_req = Request::get_full_state();
let full_state_res = make_request(full_state_req).await.unwrap_or_default();
if full_state_res.status {
let full_state: FullState =
serde_json::from_str(&full_state_res.message).unwrap_or_default();
let mut guard = audio_player_state_shared
.lock()
.unwrap_or_else(|e| e.into_inner());
guard.state = match guard.new_state.clone() {
Some(new_state) => {
guard.new_state = None;
new_state
}
None => full_state.state,
};
guard.tracks = full_state.tracks;
guard.monitoring_volume = full_state.monitoring_volume;
guard.mic_volume = full_state.mic_volume;
guard.volume_multiplier = full_state.volume_multiplier;
guard.current_input = full_state
.current_input
.split(" - ")
.next()
.unwrap_or_default()
.to_string();
guard.current_output = full_state.current_output;
if guard.all_inputs != full_state.all_inputs {
guard.all_inputs = full_state.all_inputs;
guard.all_inputs_sorted = sorted_devices(&guard.all_inputs);
}
if guard.all_outputs != full_state.all_outputs {
guard.all_outputs = full_state.all_outputs;
guard.all_outputs_sorted = sorted_devices(&guard.all_outputs);
}
guard.is_daemon_running = true;
}
// Poll hotkey config at a lower frequency (~every 2 seconds)
if last_hotkey_poll.elapsed() >= Duration::from_secs(2) {
let hotkey_res = make_request(Request::get_hotkeys())
.await
.unwrap_or_default();
if hotkey_res.status
&& let Ok(config) = serde_json::from_str::<HotkeyConfig>(&hotkey_res.message)
{
let mut guard = audio_player_state_shared
.lock()
.unwrap_or_else(|e| e.into_inner());
guard.hotkey_config = Some(config);
}
last_hotkey_poll = Instant::now();
}
sleep(sleep_duration).await;
}
});
}
#[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");
}
}
-540
View File
@@ -1,540 +0,0 @@
use crate::types::pipewire::{AudioDevice, DeviceType, LinkInfo, Port};
use anyhow::{Result, anyhow};
use pipewire::{
context::ContextRc, link::Link, main_loop::MainLoopRc, properties::properties,
registry::GlobalObject, spa::utils::dict::DictRef,
};
use std::{cell::RefCell, collections::HashMap, rc::Rc, sync::OnceLock, thread};
use tokio::sync::oneshot;
/// Every audio node PWSP knows about, split by role.
pub struct AllDevices {
/// `Audio/Source*` nodes — real and virtual microphones.
pub inputs: Vec<AudioDevice>,
/// `Stream/Output/Audio` nodes — application playback streams, including our own.
pub outputs: Vec<AudioDevice>,
/// `Audio/Sink` nodes — speakers and headphones.
pub sinks: Vec<AudioDevice>,
}
impl AllDevices {
pub fn iter(&self) -> impl Iterator<Item = &AudioDevice> {
self.inputs
.iter()
.chain(self.outputs.iter())
.chain(self.sinks.iter())
}
}
pub enum PwCommand {
GetDevices {
resp: oneshot::Sender<AllDevices>,
},
GetLinks {
resp: oneshot::Sender<Vec<LinkInfo>>,
},
CreateVirtualMic {
resp: oneshot::Sender<Result<u32, String>>,
},
CreateLink {
output_fl: Port,
output_fr: Port,
input_fl: Port,
input_fr: Port,
resp: oneshot::Sender<Result<(u32, u32), String>>,
},
/// Drops a proxy we created ourselves, which destroys the underlying object.
DestroyObject {
id: u32,
},
/// Destroys a global object owned by somebody else, such as an auto-created link.
DestroyGlobal {
id: u32,
},
}
struct AppState {
input_devices: HashMap<u32, AudioDevice>,
output_devices: HashMap<u32, AudioDevice>,
sink_devices: HashMap<u32, AudioDevice>,
links: HashMap<u32, LinkInfo>,
ports: HashMap<u32, Port>,
proxies: HashMap<u32, Box<dyn std::any::Any>>,
proxy_id_counter: u32,
ready_tx: Option<std::sync::mpsc::Sender<()>>,
}
pub struct PipewireManager {
pub sender: pipewire::channel::Sender<PwCommand>,
}
static MANAGER: OnceLock<PipewireManager> = OnceLock::new();
pub fn get_manager() -> &'static PipewireManager {
MANAGER.get_or_init(|| {
let (pw_sender, pw_receiver) = pipewire::channel::channel::<PwCommand>();
let (ready_tx, ready_rx) = std::sync::mpsc::channel();
thread::spawn(move || {
let (main_loop, context) = setup_pipewire_context().expect("Failed to setup pipewire");
// Leak main_loop and context so their borrows can be 'static
let main_loop = Box::leak(Box::new(main_loop));
let context = Box::leak(Box::new(context));
// Leak to fix lifetime issues since this thread lives forever. Shared rather
// than mutable so both `core` and the `registry` borrowed from it can be
// captured by the command closure below.
let core: &'static _ = Box::leak(Box::new(
context
.connect(None)
.expect("Failed to connect to pipewire"),
));
let registry: &'static _ = Box::leak(Box::new(
core.get_registry().expect("Failed to get registry"),
));
let state = Rc::new(RefCell::new(AppState {
input_devices: HashMap::new(),
output_devices: HashMap::new(),
sink_devices: HashMap::new(),
links: HashMap::new(),
ports: HashMap::new(),
proxies: HashMap::new(),
proxy_id_counter: 10000,
ready_tx: Some(ready_tx),
}));
let state_for_registry_add = state.clone();
let state_for_registry_remove = state.clone();
let _listener = registry
.add_listener_local()
.global(move |global| {
let mut s = state_for_registry_add.borrow_mut();
match parse_global_object(global) {
ParsedGlobal::Device(device) => match device.device_type {
DeviceType::Input => {
s.input_devices.insert(device.id, device);
}
DeviceType::Output => {
s.output_devices.insert(device.id, device);
}
DeviceType::Sink => {
s.sink_devices.insert(device.id, device);
}
},
ParsedGlobal::Port(port) => {
let node_id = port.node_id;
s.ports.insert(port.port_id, port.clone());
if let Some(d) = s.input_devices.get_mut(&node_id) {
d.add_port(port);
} else if let Some(d) = s.output_devices.get_mut(&node_id) {
d.add_port(port);
} else if let Some(d) = s.sink_devices.get_mut(&node_id) {
d.add_port(port);
}
}
ParsedGlobal::Link(link) => {
s.links.insert(link.id, link);
}
ParsedGlobal::Unknown => {}
}
})
.global_remove(move |id| {
let mut s = state_for_registry_remove.borrow_mut();
s.input_devices.remove(&id);
s.output_devices.remove(&id);
s.sink_devices.remove(&id);
s.links.remove(&id);
s.links
.retain(|_, link| link.output_node != id && link.input_node != id);
s.ports.retain(|_, port| port.node_id != id);
s.ports.remove(&id);
})
.register();
// sync to signal ready
let state_for_sync = state.clone();
let _core_listener = core
.add_listener_local()
.done(move |id, _seq| {
if id == 0 {
let mut s = state_for_sync.borrow_mut();
if let Some(tx) = s.ready_tx.take() {
let _ = tx.send(());
}
}
})
.register();
let _pending = core.sync(0).expect("sync failed");
let state_for_cmd = state.clone();
let _receiver = pw_receiver.attach(main_loop.loop_(), move |cmd| {
let mut s = state_for_cmd.borrow_mut();
match cmd {
PwCommand::GetDevices { resp } => {
let mut inputs: Vec<AudioDevice> =
s.input_devices.values().cloned().collect();
let mut outputs: Vec<AudioDevice> =
s.output_devices.values().cloned().collect();
let mut sinks: Vec<AudioDevice> =
s.sink_devices.values().cloned().collect();
inputs.sort_by_key(|a| a.id);
outputs.sort_by_key(|a| a.id);
sinks.sort_by_key(|a| a.id);
let _ = resp.send(AllDevices {
inputs,
outputs,
sinks,
});
}
PwCommand::GetLinks { resp } => {
let mut links: Vec<LinkInfo> = s.links.values().copied().collect();
links.sort_by_key(|l| l.id);
let _ = resp.send(links);
}
PwCommand::CreateVirtualMic { resp } => {
let props = properties!(
"factory.name" => "support.null-audio-sink",
"node.name" => "pwsp-virtual-mic",
"node.description" => "PWSP Virtual Mic",
"media.class" => "Audio/Source/Virtual",
"audio.position" => "[ FL FR ]",
"audio.channels" => "2",
"object.linger" => "false",
);
match core.create_object::<pipewire::node::Node>("adapter", &props) {
Ok(node) => {
s.proxy_id_counter += 1;
let id = s.proxy_id_counter;
s.proxies.insert(id, Box::new(node));
let _ = resp.send(Ok(id));
}
Err(e) => {
let _ = resp.send(Err(e.to_string()));
}
}
}
PwCommand::CreateLink {
output_fl,
output_fr,
input_fl,
input_fr,
resp,
} => {
let props_fl = properties! {
"link.output.node" => format!("{}", output_fl.node_id).as_str(),
"link.output.port" => format!("{}", output_fl.port_id).as_str(),
"link.input.node" => format!("{}", input_fl.node_id).as_str(),
"link.input.port" => format!("{}", input_fl.port_id).as_str(),
};
let props_fr = properties! {
"link.output.node" => format!("{}", output_fr.node_id).as_str(),
"link.output.port" => format!("{}", output_fr.port_id).as_str(),
"link.input.node" => format!("{}", input_fr.node_id).as_str(),
"link.input.port" => format!("{}", input_fr.port_id).as_str(),
};
let link_fl = match core.create_object::<Link>("link-factory", &props_fl) {
Ok(link) => link,
Err(e) => {
let _ = resp.send(Err(e.to_string()));
return;
}
};
let link_fr = match core.create_object::<Link>("link-factory", &props_fr) {
Ok(link) => link,
Err(e) => {
let _ = resp.send(Err(e.to_string()));
return;
}
};
s.proxy_id_counter += 1;
let id_fl = s.proxy_id_counter;
s.proxies.insert(id_fl, Box::new(link_fl));
s.proxy_id_counter += 1;
let id_fr = s.proxy_id_counter;
s.proxies.insert(id_fr, Box::new(link_fr));
let _ = resp.send(Ok((id_fl, id_fr)));
}
PwCommand::DestroyObject { id } => {
s.proxies.remove(&id);
}
PwCommand::DestroyGlobal { id } => {
s.links.remove(&id);
registry.destroy_global(id);
}
}
});
main_loop.run();
});
// Wait for the pipewire thread to be fully up and processed initial events
let _ = ready_rx.recv();
PipewireManager { sender: pw_sender }
})
}
pub fn setup_pipewire_context() -> Result<(MainLoopRc, ContextRc), String> {
pipewire::init();
let main_loop = MainLoopRc::new(None).map_err(|e| e.to_string())?;
let context = ContextRc::new(&main_loop, None).map_err(|e| e.to_string())?;
Ok((main_loop, context))
}
enum ParsedGlobal {
Device(AudioDevice),
Port(Port),
Link(LinkInfo),
Unknown,
}
fn parse_global_object(global_object: &GlobalObject<&DictRef>) -> ParsedGlobal {
let props = match global_object.props {
Some(p) => p,
None => return ParsedGlobal::Unknown,
};
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");
// `Audio/Source/Virtual` (our own virtual mic) also matches "Audio/Source",
// which is intended — it is a microphone as far as the rest of PWSP cares.
let device_type = if media_class.starts_with("Audio/Source") {
DeviceType::Input
} else if media_class.starts_with("Stream/Output/Audio") {
DeviceType::Output
} else if media_class.starts_with("Audio/Sink") {
DeviceType::Sink
} else {
return ParsedGlobal::Unknown;
};
return ParsedGlobal::Device(AudioDevice::new(
node_id,
node_nick,
node_description,
node_name,
device_type,
));
}
if let (Some(output_node), Some(input_node)) = (
props
.get("link.output.node")
.and_then(|id| id.parse::<u32>().ok()),
props
.get("link.input.node")
.and_then(|id| id.parse::<u32>().ok()),
) {
return ParsedGlobal::Link(LinkInfo {
id: global_object.id,
output_node,
input_node,
});
}
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"),
)
{
return ParsedGlobal::Port(Port {
node_id,
port_id,
name: port_name.to_string(),
});
}
ParsedGlobal::Unknown
}
pub async fn get_all_devices() -> Result<AllDevices> {
let (tx, rx) = oneshot::channel();
let manager = get_manager();
manager
.sender
.send(PwCommand::GetDevices { resp: tx })
.map_err(|_| anyhow!("Failed to send GetDevices to manager"))?;
let res = rx
.await
.map_err(|e| anyhow!("Failed to receive response: {}", e))?;
Ok(res)
}
pub async fn get_all_links() -> Result<Vec<LinkInfo>> {
let (tx, rx) = oneshot::channel();
let manager = get_manager();
manager
.sender
.send(PwCommand::GetLinks { resp: tx })
.map_err(|_| anyhow!("Failed to send GetLinks to manager"))?;
rx.await
.map_err(|e| anyhow!("Failed to receive response: {}", e))
}
fn matches_device_name(device: &AudioDevice, device_name: &str) -> bool {
device.name == device_name
|| device.nick == device_name
|| device.name.contains(device_name)
|| device.nick.contains(device_name)
}
pub async fn get_device(device_name: &str) -> Result<AudioDevice> {
let devices = get_all_devices().await?;
devices
.iter()
.find(|device| matches_device_name(device, device_name))
.cloned()
.ok_or_else(|| anyhow!("Device not found: {}", device_name))
}
/// Re-reads a node by its PipeWire id, so callers always link against fresh ports.
pub async fn get_device_by_id(id: u32) -> Result<AudioDevice> {
get_all_devices()
.await?
.iter()
.find(|device| device.id == id)
.cloned()
.ok_or_else(|| anyhow!("Node {} is gone", id))
}
/// Looks up an `Audio/Sink` node by name.
pub async fn get_sink(name: &str) -> Result<AudioDevice> {
get_all_devices()
.await?
.sinks
.into_iter()
.find(|d| matches_device_name(d, name))
.ok_or_else(|| anyhow!("Output device not found: {}", name))
}
pub struct PwTerminator {
ids: Vec<u32>,
}
impl Drop for PwTerminator {
fn drop(&mut self) {
let manager = get_manager();
for id in &self.ids {
let _ = manager.sender.send(PwCommand::DestroyObject { id: *id });
}
}
}
pub async fn create_virtual_mic() -> Result<PwTerminator> {
let (tx, rx) = oneshot::channel();
let manager = get_manager();
manager
.sender
.send(PwCommand::CreateVirtualMic { resp: tx })
.map_err(|_| anyhow!("Failed to send CreateVirtualMic to manager"))?;
let res = rx
.await
.map_err(|e| anyhow!("Failed to receive response: {}", e))?;
let id = res.map_err(|e| anyhow!(e))?;
Ok(PwTerminator { ids: vec![id] })
}
/// Makes `source_node` feed `target` and nothing else.
///
/// Idempotent: safe to call on every device-check tick. Returns `Some` only when a new link
/// was created, so an already-correct route is left in place and the caller never tears
/// down a link it does not own.
///
/// The link is created *before* stale ones are pruned. That order matters: the node is
/// never left unlinked, which is the state that would invite the session manager to
/// re-attach it somewhere else.
pub async fn ensure_route(
source_node: &AudioDevice,
target: &AudioDevice,
) -> Result<Option<PwTerminator>> {
let existing = get_all_links().await?;
let already_routed = existing
.iter()
.any(|link| link.output_node == source_node.id && link.input_node == target.id);
let terminator = if already_routed {
None
} else {
let output_fl = source_node
.output_fl
.clone()
.ok_or_else(|| anyhow!("Node {} has no output_FL port", source_node.name))?;
let output_fr = source_node
.output_fr
.clone()
.ok_or_else(|| anyhow!("Node {} has no output_FR port", source_node.name))?;
let input_fl = target
.input_fl
.clone()
.ok_or_else(|| anyhow!("Node {} has no input_FL port", target.name))?;
let input_fr = target
.input_fr
.clone()
.ok_or_else(|| anyhow!("Node {} has no input_FR port", target.name))?;
Some(create_link(output_fl, output_fr, input_fl, input_fr).await?)
};
prune_links_from(source_node.id, target.id).await?;
Ok(terminator)
}
/// Destroys every link leaving `source_node` that does not end at `keep_target`.
async fn prune_links_from(source_node: u32, keep_target: u32) -> Result<()> {
let manager = get_manager();
for link in get_all_links().await? {
if link.output_node == source_node && link.input_node != keep_target {
let _ = manager
.sender
.send(PwCommand::DestroyGlobal { id: link.id });
}
}
Ok(())
}
pub async fn create_link(
output_fl: Port,
output_fr: Port,
input_fl: Port,
input_fr: Port,
) -> Result<PwTerminator> {
let (tx, rx) = oneshot::channel();
let manager = get_manager();
manager
.sender
.send(PwCommand::CreateLink {
output_fl,
output_fr,
input_fl,
input_fr,
resp: tx,
})
.map_err(|_| anyhow!("Failed to send CreateLink to manager"))?;
let res = rx
.await
.map_err(|e| anyhow!("Failed to receive response: {}", e))?;
let (id_fl, id_fr) = res.map_err(|e| anyhow!(e))?;
Ok(PwTerminator {
ids: vec![id_fl, id_fr],
})
}
+55
View File
@@ -0,0 +1,55 @@
%bcond check 1
# prevent library files from being installed
%global cargo_install_lib 0
Name: pwsp
Version: 1.1.3
Release: %autorelease
Summary: Lets you play audio files through your microphone
License: MIT
URL: https://github.com/arabianq/pipewire-soundpad
Source: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v%{version}.tar.gz
BuildRequires: rust
BuildRequires: cargo
BuildRequires: pipewire-devel
BuildRequires: alsa-lib-devel
BuildRequires: clang-devel
%global _description %{expand:
PWSP lets you play audio files through your microphone. Has both CLI and
GUI clients.}
%description %{_description}
%prep
%autosetup -n pipewire-soundpad-%{version} -p1
%build
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 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
%autochangelog
-19
View File
@@ -1,19 +0,0 @@
#!/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"
-169
View File
@@ -1,169 +0,0 @@
#!/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.")
if isinstance(workspace_package_match, re.Match):
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.")
if isinstance(version_match, re.Match):
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}!")
+113
View File
@@ -0,0 +1,113 @@
use clap::{Parser, Subcommand};
use pwsp::{
types::socket::Request,
utils::daemon::{make_request, wait_for_daemon},
};
use std::{error::Error, path::PathBuf};
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Cli {
#[clap(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// Perform an action (ping, pause, resume, stop, play)
Action {
#[clap(subcommand)]
action: Actions,
},
/// Get information from the player (is paused, volume, position, state)
Get {
#[clap(subcommand)]
parameter: GetCommands,
},
/// Set information in the player (volume, position)
Set {
#[clap(subcommand)]
parameter: SetCommands,
},
}
#[derive(Subcommand, Debug)]
enum Actions {
/// Ping the daemon
Ping,
/// Pause audio playback
Pause,
/// Resume audio playback
Resume,
/// Stop audio playback and clear the queue
Stop,
/// Play a file
Play { file_path: PathBuf },
}
#[derive(Subcommand, Debug)]
enum GetCommands {
/// Check if the player is paused
IsPaused,
/// Playback volume
Volume,
/// Playback position
Position,
/// Duration of the current file
Duration,
/// Player state
State,
/// Current playing file path
CurrentFilePath,
/// Current audio input
Input,
/// All audio inputs
Inputs,
}
#[derive(Subcommand, Debug)]
enum SetCommands {
/// Playback volume
Volume { volume: f32 },
/// Playback position
Position { position: f32 },
/// Input
Input { name: String },
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let cli = Cli::parse();
wait_for_daemon().await?;
let request = match cli.command {
Commands::Action { action } => match action {
Actions::Ping => Request::ping(),
Actions::Pause => Request::pause(),
Actions::Resume => Request::resume(),
Actions::Stop => Request::stop(),
Actions::Play { file_path } => Request::play(file_path.to_str().unwrap()),
},
Commands::Get { parameter } => match parameter {
GetCommands::IsPaused => Request::get_is_paused(),
GetCommands::Volume => Request::get_volume(),
GetCommands::Position => Request::get_position(),
GetCommands::Duration => Request::get_duration(),
GetCommands::State => Request::get_state(),
GetCommands::CurrentFilePath => Request::get_current_file_path(),
GetCommands::Input => Request::get_input(),
GetCommands::Inputs => Request::get_inputs(),
},
Commands::Set { parameter } => match parameter {
SetCommands::Volume { volume } => Request::set_volume(volume),
SetCommands::Position { position } => Request::seek(position),
SetCommands::Input { name } => Request::set_input(&name),
},
};
let response = make_request(request).await?;
println!("{} : {}", response.status, response.message);
Ok(())
}
+96
View File
@@ -0,0 +1,96 @@
use pwsp::{
types::socket::{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,
},
pipewire::create_virtual_mic,
},
};
use std::{error::Error, fs};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::UnixListener,
};
#[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()?;
get_audio_player().await; // Initialize audio player
link_player_to_virtual_mic().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 fs::metadata(&socket_path).is_ok() {
fs::remove_file(&socket_path)?;
}
let listener = UnixListener::bind(&socket_path)?;
println!(
"Daemon started. Listening on {}",
socket_path.to_str().unwrap_or_default()
);
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;
let mut buffer = vec![0u8; request_len];
if stream.read_exact(&mut buffer).await.is_err() {
eprintln!("Failed to read message from client!");
return;
}
let request: Request = serde_json::from_slice(&buffer).unwrap();
println!("Received request: {:?}", request);
// ---------- 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 = serde_json::to_vec(&response).unwrap();
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;
}
println!("Sent response: {:?}", response);
// ---------- Send response (end) ----------
});
}
}
+340
View File
@@ -0,0 +1,340 @@
use crate::gui::SoundpadGui;
use egui::{
AtomExt, Button, Color32, ComboBox, FontFamily, Label, RichText, ScrollArea, Slider, TextEdit,
Ui, Vec2,
};
use egui_material_icons::icons;
use pwsp::types::audio_player::PlayerState;
use pwsp::utils::gui::format_time_pair;
use std::{error::Error, path::PathBuf};
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(),
);
});
}
pub fn draw_settings(&mut self, ui: &mut Ui) {
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 5.0;
// --------- Back Button and Title ----------
ui.horizontal_top(|ui| {
let back_button = Button::new(icons::ICON_ARROW_BACK).frame(false);
let back_button_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",
);
if save_volume_response.changed()
|| save_input_response.changed()
|| save_scale_response.changed()
{
self.config.save_to_file().ok();
}
// --------------------------------
});
}
pub fn draw(&mut self, ui: &mut Ui) -> Result<(), Box<dyn Error>> {
self.draw_header(ui);
self.draw_body(ui);
ui.separator();
self.draw_footer(ui);
Ok(())
}
fn draw_header(&mut self, ui: &mut Ui) {
ui.vertical_centered_justified(|ui| {
// Current file name
ui.label(
RichText::new(
self.audio_player_state
.current_file_path
.file_stem()
.unwrap_or_default()
.to_str()
.unwrap_or_default(),
)
.color(Color32::WHITE)
.family(FontFamily::Monospace),
);
// Media controls
self.draw_controls(ui);
ui.separator();
});
}
fn draw_controls(&mut self, ui: &mut Ui) {
ui.horizontal_top(|ui| {
// ---------- Play Button ----------
let play_button = Button::new(match self.audio_player_state.state {
PlayerState::Playing => icons::ICON_PAUSE,
PlayerState::Paused | PlayerState::Stopped => icons::ICON_PLAY_ARROW,
})
.corner_radius(15.0);
let play_button_response = ui.add_sized([30.0, 30.0], play_button);
if play_button_response.clicked() {
self.play_toggle();
}
// --------------------------------
// ---------- Position Slider ----------
let position_slider = Slider::new(
&mut self.app_state.position_slider_value,
0.0..=self.audio_player_state.duration,
)
.show_value(false)
.step_by(1.0);
let 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() {
self.app_state.position_dragged = true;
}
// --------------------------------
// ---------- Time Label ----------
let time_label = Label::new(
RichText::new(format_time_pair(
self.audio_player_state.position,
self.audio_player_state.duration,
))
.monospace(),
);
ui.add_sized([30.0, 30.0], time_label);
// --------------------------------
// ---------- Volume Icon ----------
let volume_icon = if self.audio_player_state.volume > 0.7 {
icons::ICON_VOLUME_UP
} else if self.audio_player_state.volume == 0.0 {
icons::ICON_VOLUME_OFF
} else if self.audio_player_state.volume < 0.3 {
icons::ICON_VOLUME_MUTE
} else {
icons::ICON_VOLUME_DOWN
};
let volume_icon = Label::new(RichText::new(volume_icon).size(18.0));
ui.add_sized([30.0, 25.0], volume_icon);
// --------------------------------
// ---------- Volume Slider ----------
let volume_slider = Slider::new(&mut self.app_state.volume_slider_value, 0.0..=1.0)
.show_value(false)
.step_by(0.01);
ui.spacing_mut().slider_width = default_slider_width;
ui.spacing_mut().item_spacing.x = 0.0;
let volume_slider_response = ui.add_sized([30.0, 30.0], volume_slider);
if volume_slider_response.drag_stopped() {
self.app_state.volume_dragged = true;
}
// --------------------------------
});
}
fn draw_body(&mut self, ui: &mut Ui) {
let dirs_size = Vec2::new(ui.available_width() / 4.0, ui.available_height() - 40.0);
ui.horizontal(|ui| {
self.draw_dirs(ui, dirs_size);
ui.separator();
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| {
let mut dirs: Vec<PathBuf> = self.app_state.dirs.iter().cloned().collect();
dirs.sort();
for path in dirs.iter() {
ui.horizontal(|ui| {
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 {
if 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(icons::ICON_DELETE).frame(false);
let delete_dir_button_response =
ui.add_sized([18.0, 18.0], delete_dir_button);
if delete_dir_button_response.clicked() {
self.remove_dir(&path.clone());
}
});
}
ui.horizontal(|ui| {
let add_dir_button = egui::Button::new(icons::ICON_ADD).frame(false);
let add_dir_button_response = ui.add_sized([18.0, 18.0], add_dir_button);
if add_dir_button_response.clicked() {
self.add_dir();
}
});
});
});
}
fn draw_files(&mut self, ui: &mut Ui, area_size: Vec2) {
let extensions = [
"mp3", "wav", "ogg", "flac", "mp4", "m4a", "aac", "mov", "mkv", "webm", "avi",
];
ui.vertical(|ui| {
ui.horizontal(|ui| {
let search_field = ui.add_sized(
[ui.available_width(), 22.0],
TextEdit::singleline(&mut self.app_state.search_query).hint_text("Search..."),
);
self.app_state.search_field_id = Some(search_field.id);
});
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 mut files: Vec<PathBuf> = self.app_state.files.iter().cloned().collect();
files.sort();
for entry_path in files {
if entry_path.is_dir() {
continue;
}
if !extensions
.contains(&entry_path.extension().unwrap_or_default().to_str().unwrap())
{
continue;
}
let file_name = entry_path
.file_name()
.unwrap()
.to_string_lossy()
.to_string();
let search_query = self
.app_state
.search_query
.to_lowercase()
.trim()
.to_string();
if !file_name.to_lowercase().contains(search_query.as_str()) {
continue;
}
let mut file_button_text = RichText::new(file_name);
if let Some(current_file) = &self.app_state.selected_file {
if current_file.eq(&entry_path) {
file_button_text = file_button_text.color(Color32::WHITE);
}
}
let file_button = Button::new(file_button_text).frame(false);
let file_button_response = ui.add(file_button);
if file_button_response.clicked() {
self.play_file(&entry_path);
self.app_state.selected_file = Some(entry_path);
}
}
});
});
});
}
fn draw_footer(&mut self, ui: &mut Ui) {
ui.add_space(5.0);
ui.horizontal_top(|ui| {
// ---------- Microphone selection ----------
let mut mics: Vec<(&String, &String)> =
self.audio_player_state.all_inputs.iter().collect();
mics.sort_by_key(|(k, _)| *k);
let mut selected_input = self.audio_player_state.current_input.to_owned();
let prev_input = selected_input.to_owned();
ComboBox::from_label("Choose microphone")
.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.to_owned(), nick);
}
});
if selected_input != prev_input {
self.set_input(selected_input);
}
// --------------------------------
ui.add_space(ui.available_width() - 18.0 - ui.spacing().item_spacing.x);
// ---------- Settings button ----------
let settings_button = Button::new(icons::ICON_SETTINGS).frame(false);
let settings_button_response = ui.add_sized([18.0, 18.0], settings_button);
if settings_button_response.clicked() {
self.app_state.show_settings = true;
}
// --------------------------------
});
}
}
+107
View File
@@ -0,0 +1,107 @@
use crate::gui::SoundpadGui;
use egui::{Context, Key};
use std::path::PathBuf;
impl SoundpadGui {
pub fn handle_input(&mut self, ctx: &Context) {
if ctx.memory(|reader| { reader.focused() }.is_some()) {
return;
}
ctx.input(|i| {
// Close app on espace
if i.key_pressed(Key::Escape) {
std::process::exit(0);
}
// Open/close settings
if i.key_pressed(Key::I) {
self.app_state.show_settings = !self.app_state.show_settings;
}
if i.key_pressed(Key::Enter) && self.app_state.selected_file.is_some() {
self.play_file(&self.app_state.selected_file.clone().unwrap());
}
if !self.app_state.show_settings {
// Pause / resume audio on space
if i.key_pressed(Key::Space) {
self.play_toggle();
}
// Focus search field
if i.key_pressed(Key::Slash) && self.app_state.search_field_id.is_some() {
self.app_state.force_focus_id = self.app_state.search_field_id;
}
// Iterate through dirs if there are some
if i.modifiers.ctrl {
let arrow_up_pressed = i.key_pressed(Key::ArrowUp);
let arrow_down_pressed = i.key_pressed(Key::ArrowDown);
if arrow_up_pressed || arrow_down_pressed {
if i.modifiers.shift && !self.app_state.dirs.is_empty() {
let mut dirs: Vec<PathBuf> =
self.app_state.dirs.iter().cloned().collect();
dirs.sort();
let current_dir_index: i8;
if let Some(current_dir) = &self.app_state.current_dir {
if let Some(index) = dirs.iter().position(|x| x == current_dir) {
current_dir_index = index as i8;
} else {
current_dir_index = -1;
}
} else {
current_dir_index = -1;
}
let mut new_dir_index: i8;
new_dir_index = current_dir_index - arrow_up_pressed as i8
+ arrow_down_pressed as i8;
if new_dir_index < 0 {
new_dir_index = (dirs.len() - 1) as i8;
} else if new_dir_index >= dirs.len() as i8 {
new_dir_index = 0;
}
self.open_dir(&dirs[new_dir_index as usize]);
} else if self.app_state.current_dir.is_some() {
let mut files: Vec<PathBuf> =
self.app_state.files.iter().cloned().collect();
files.sort();
let current_files_index: i64;
if let Some(selected_file) = &self.app_state.selected_file {
if let Some(index) = files.iter().position(|x| x == selected_file) {
current_files_index = index as i64;
} else {
current_files_index = -1;
}
} else {
current_files_index = -1;
}
let mut new_files_index: i64;
new_files_index = current_files_index - arrow_up_pressed as i64
+ arrow_down_pressed as i64;
if new_files_index < 0 {
new_files_index = (files.len() - 1) as i64;
} else if new_files_index >= files.len() as i64 {
new_files_index = 0;
}
self.app_state.selected_file =
Some(files[new_files_index as usize].clone());
}
}
}
}
});
}
}
+144
View File
@@ -0,0 +1,144 @@
mod draw;
mod input;
mod update;
use eframe::{HardwareAcceleration, NativeOptions, icon_data::from_png_bytes, run_native};
use egui::{Context, Vec2, ViewportBuilder};
use pwsp::{
types::{
audio_player::PlayerState,
config::GuiConfig,
gui::{AppState, AudioPlayerState},
socket::Request,
},
utils::{
daemon::get_daemon_config,
gui::{get_gui_config, make_request_sync, start_app_state_thread},
},
};
use rfd::FileDialog;
use std::path::PathBuf;
use std::{
error::Error,
sync::{Arc, Mutex},
};
struct SoundpadGui {
pub app_state: AppState,
pub config: GuiConfig,
pub audio_player_state: AudioPlayerState,
pub audio_player_state_shared: Arc<Mutex<AudioPlayerState>>,
}
impl SoundpadGui {
fn new(ctx: &Context) -> Self {
let audio_player_state = Arc::new(Mutex::new(AudioPlayerState::default()));
start_app_state_thread(audio_player_state.clone());
let config = get_gui_config();
ctx.set_zoom_factor(config.scale_factor);
let mut soundpad_gui = SoundpadGui {
app_state: AppState::default(),
config: config.clone(),
audio_player_state: AudioPlayerState::default(),
audio_player_state_shared: audio_player_state.clone(),
};
soundpad_gui.app_state.dirs = config.dirs;
soundpad_gui
}
pub fn play_toggle(&mut self) {
let mut guard = self.audio_player_state_shared.lock().unwrap();
guard.state = match guard.state {
PlayerState::Playing => {
make_request_sync(Request::pause()).ok();
guard.new_state = Some(PlayerState::Paused);
PlayerState::Paused
}
PlayerState::Paused => {
make_request_sync(Request::resume()).ok();
guard.new_state = Some(PlayerState::Playing);
PlayerState::Playing
}
PlayerState::Stopped => PlayerState::Stopped,
};
}
pub fn add_dir(&mut self) {
let file_dialog = FileDialog::new();
if let Some(path) = file_dialog.pick_folder() {
self.app_state.dirs.insert(path);
self.config.dirs = self.app_state.dirs.clone();
self.config.save_to_file().ok();
}
}
pub fn remove_dir(&mut self, path: &PathBuf) {
self.app_state.dirs.remove(path);
if let Some(current_dir) = &self.app_state.current_dir
&& current_dir == path
{
self.app_state.current_dir = None;
}
self.config.dirs = self.app_state.dirs.clone();
self.config.save_to_file().ok();
}
pub fn open_dir(&mut self, path: &PathBuf) {
self.app_state.current_dir = Some(path.clone());
self.app_state.files = path
.read_dir()
.unwrap()
.filter_map(|res| res.ok())
.map(|entry| entry.path())
.collect();
}
pub fn play_file(&mut self, path: &PathBuf) {
make_request_sync(Request::play(path.to_str().unwrap())).ok();
}
pub fn set_input(&mut self, name: String) {
make_request_sync(Request::set_input(&name)).ok();
if self.config.save_input {
let mut daemon_config = get_daemon_config();
daemon_config.default_input_name = Some(name);
daemon_config.save_to_file().ok();
}
}
}
pub async fn run() -> Result<(), Box<dyn Error>> {
const ICON: &[u8] = include_bytes!("../../assets/icon.png");
let options = NativeOptions {
vsync: true,
centered: true,
hardware_acceleration: HardwareAcceleration::Preferred,
viewport: ViewportBuilder::default()
.with_app_id("ru.arabianq.pwsp")
.with_inner_size(Vec2::new(1200.0, 800.0))
.with_min_inner_size(Vec2::new(800.0, 600.0))
.with_icon(from_png_bytes(ICON)?),
..Default::default()
};
match run_native(
"Pipewire Soundpad",
options,
Box::new(|cc| {
egui_material_icons::initialize(&cc.egui_ctx);
Ok(Box::new(SoundpadGui::new(&cc.egui_ctx)))
}),
) {
Ok(_) => Ok(()),
Err(e) => Err(e.into()),
}
}
+84
View File
@@ -0,0 +1,84 @@
use crate::gui::SoundpadGui;
use eframe::{App, Frame as EFrame};
use egui::{CentralPanel, Context};
use pwsp::{
types::socket::Request,
utils::{
daemon::{get_daemon_config, is_daemon_running},
gui::make_request_sync,
},
};
impl App for SoundpadGui {
fn update(&mut self, ctx: &Context, _frame: &mut EFrame) {
{
let guard = self.audio_player_state_shared.lock().unwrap();
self.audio_player_state = guard.clone();
}
let old_scale_factor = self.config.scale_factor;
let new_scale_factor = ctx.zoom_factor().clamp(0.5, 2.0);
ctx.set_zoom_factor(new_scale_factor);
self.config.scale_factor = new_scale_factor;
if new_scale_factor != old_scale_factor && self.config.save_scale_factor {
self.config.save_to_file().ok();
}
self.handle_input(ctx);
CentralPanel::default().show(ctx, |ui| {
if !is_daemon_running().unwrap() {
self.draw_waiting_for_daemon(ui);
return;
}
if self.app_state.show_settings {
self.draw_settings(ui);
return;
}
self.draw(ui).ok();
if let Some(force_focus_id) = self.app_state.force_focus_id {
ui.memory_mut(|reder| {
reder.request_focus(force_focus_id);
});
self.app_state.force_focus_id = None;
}
});
if self.app_state.position_dragged {
make_request_sync(Request::seek(self.app_state.position_slider_value)).ok();
let mut guard = self.audio_player_state_shared.lock().unwrap();
guard.new_position = Some(self.app_state.position_slider_value);
guard.position = self.app_state.position_slider_value;
self.app_state.position_dragged = false;
} else {
self.app_state.position_slider_value = self.audio_player_state.position;
}
if self.app_state.volume_dragged {
let new_volume = self.app_state.volume_slider_value;
make_request_sync(Request::set_volume(new_volume)).ok();
let mut guard = self.audio_player_state_shared.lock().unwrap();
guard.new_volume = Some(self.app_state.volume_slider_value);
guard.volume = self.app_state.volume_slider_value;
self.app_state.volume_dragged = false;
if self.config.save_volume {
let mut daemon_config = get_daemon_config();
daemon_config.default_volume = Some(new_volume);
daemon_config.save_to_file().ok();
}
} else {
self.app_state.volume_slider_value = self.audio_player_state.volume;
}
ctx.request_repaint_after_secs(1.0 / 60.0);
}
}
View File
+8
View File
@@ -0,0 +1,8 @@
mod gui;
use std::error::Error;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
gui::run().await
}
+244
View File
@@ -0,0 +1,244 @@
use crate::{
types::pipewire::{AudioDevice, DeviceType, Terminate},
utils::{
daemon::get_daemon_config,
pipewire::{create_link, get_all_devices, get_device},
},
};
use rodio::{Decoder, OutputStream, OutputStreamBuilder, Sink, Source};
use serde::{Deserialize, Serialize};
use std::{
error::Error,
fs,
path::{Path, PathBuf},
time::Duration,
};
#[derive(Debug, Eq, PartialEq, Default, Clone, Serialize, Deserialize)]
pub enum PlayerState {
#[default]
Stopped,
Paused,
Playing,
}
pub struct AudioPlayer {
_stream_handle: OutputStream,
sink: Sink,
input_link_sender: Option<pipewire::channel::Sender<Terminate>>,
pub current_input_device: Option<AudioDevice>,
pub volume: f32,
pub duration: Option<f32>,
pub current_file_path: Option<PathBuf>,
}
impl AudioPlayer {
pub async fn new() -> Result<Self, Box<dyn Error>> {
let daemon_config = get_daemon_config();
let default_volume = daemon_config.default_volume.unwrap_or(1.0);
let mut default_input_device: Option<AudioDevice> = None;
if let Some(name) = daemon_config.default_input_name
&& let Ok(device) = get_device(&name).await
&& device.device_type == DeviceType::Input
{
default_input_device = Some(device);
}
let stream_handle = OutputStreamBuilder::open_default_stream()?;
let sink = Sink::connect_new(stream_handle.mixer());
sink.set_volume(default_volume);
let mut audio_player = AudioPlayer {
_stream_handle: stream_handle,
sink,
input_link_sender: None,
current_input_device: default_input_device.clone(),
volume: default_volume,
duration: None,
current_file_path: None,
};
if default_input_device.is_some() {
audio_player.link_devices().await?;
}
Ok(audio_player)
}
fn abort_link_thread(&mut self) {
if let Some(sender) = &self.input_link_sender {
match sender.send(Terminate {}) {
Ok(_) => println!("Sent terminate signal to link thread"),
Err(_) => println!("Failed to send terminate signal to link thread"),
}
}
}
async fn link_devices(&mut self) -> Result<(), Box<dyn Error>> {
self.abort_link_thread();
if self.current_input_device.is_none() {
println!("No input device selected, skipping device linking");
return Ok(());
}
let (input_devices, _) = get_all_devices().await?;
let mut pwsp_daemon_input: Option<AudioDevice> = None;
for input_device in input_devices {
if input_device.name == "pwsp-virtual-mic" {
pwsp_daemon_input = Some(input_device);
break;
}
}
if pwsp_daemon_input.is_none() {
println!("Could not find pwsp-daemon input device, skipping device linking");
return Ok(());
}
let pwsp_daemon_input = pwsp_daemon_input.unwrap();
let current_input_device = self.current_input_device.clone().unwrap();
let output_fl = current_input_device
.clone()
.output_fl
.expect("Failed to get pwsp-daemon output_fl");
let output_fr = current_input_device
.clone()
.output_fr
.expect("Failed to get pwsp-daemon output_fl");
let input_fl = pwsp_daemon_input
.clone()
.input_fl
.expect("Failed to get pwsp-daemon input_fl");
let input_fr = pwsp_daemon_input
.clone()
.input_fr
.expect("Failed to get pwsp-daemon input_fr");
self.input_link_sender = Some(create_link(output_fl, output_fr, input_fl, input_fr)?);
Ok(())
}
pub fn pause(&mut self) {
if self.get_state() == PlayerState::Playing {
self.sink.pause();
}
}
pub fn resume(&mut self) {
if self.get_state() == PlayerState::Paused {
self.sink.play();
}
}
pub fn stop(&mut self) {
self.sink.stop();
}
pub fn is_paused(&self) -> bool {
self.sink.is_paused()
}
pub fn get_state(&mut self) -> PlayerState {
if self.sink.len() == 0 {
return PlayerState::Stopped;
}
if self.sink.is_paused() {
return PlayerState::Paused;
}
PlayerState::Playing
}
pub fn set_volume(&mut self, volume: f32) {
self.volume = volume;
self.sink.set_volume(volume);
}
pub fn get_position(&mut self) -> f32 {
if self.get_state() == PlayerState::Stopped {
return 0.0;
}
self.sink.get_pos().as_secs_f32()
}
pub fn seek(&mut self, mut position: f32) -> Result<(), Box<dyn Error>> {
if position < 0.0 {
position = 0.0;
}
match self.sink.try_seek(Duration::from_secs_f32(position)) {
Ok(_) => Ok(()),
Err(err) => Err(err.into()),
}
}
pub fn get_duration(&mut self) -> Result<f32, Box<dyn Error>> {
if self.get_state() == PlayerState::Stopped {
Err("Nothing is playing right now".into())
} else {
match self.duration {
Some(duration) => Ok(duration),
None => Err("Couldn't determine duration for current file".into()),
}
}
}
pub async fn play(&mut self, file_path: &Path) -> Result<(), Box<dyn Error>> {
if !file_path.exists() {
return Err(format!("File does not exist: {}", file_path.display()).into());
}
let file = fs::File::open(file_path)?;
match Decoder::try_from(file) {
Ok(source) => {
self.current_file_path = Some(file_path.to_path_buf());
if let Some(duration) = source.total_duration() {
self.duration = Some(duration.as_secs_f32());
} else {
self.duration = None;
}
self.sink.stop();
self.sink.append(source);
self.sink.play();
self.link_devices().await?;
Ok(())
}
Err(err) => Err(err.into()),
}
}
pub fn get_current_file_path(&mut self) -> &Option<PathBuf> {
if self.get_state() == PlayerState::Stopped {
self.current_file_path = None;
}
&self.current_file_path
}
pub async fn set_current_input_device(&mut self, name: &str) -> Result<(), Box<dyn Error>> {
let input_device = get_device(name).await?;
if input_device.device_type != DeviceType::Input {
return Err("Selected device is not an input device".into());
}
self.current_input_device = Some(input_device);
self.link_devices().await?;
Ok(())
}
}
+237
View File
@@ -0,0 +1,237 @@
use crate::{
types::socket::Response,
utils::{daemon::get_audio_player, pipewire::get_all_devices},
};
use async_trait::async_trait;
use std::path::PathBuf;
#[async_trait]
pub trait Executable {
async fn execute(&self) -> Response;
}
pub struct PingCommand {}
pub struct PauseCommand {}
pub struct ResumeCommand {}
pub struct StopCommand {}
pub struct IsPausedCommand {}
pub struct GetStateCommand {}
pub struct GetVolumeCommand {}
pub struct SetVolumeCommand {
pub volume: Option<f32>,
}
pub struct GetPositionCommand {}
pub struct SeekCommand {
pub position: Option<f32>,
}
pub struct GetDurationCommand {}
pub struct PlayCommand {
pub file_path: Option<PathBuf>,
}
pub struct GetCurrentFilePathCommand {}
pub struct GetCurrentInputCommand {}
pub struct GetAllInputsCommand {}
pub struct SetCurrentInputCommand {
pub name: Option<String>,
}
#[async_trait]
impl Executable for PingCommand {
async fn execute(&self) -> Response {
Response::new(true, "pong")
}
}
#[async_trait]
impl Executable for PauseCommand {
async fn execute(&self) -> Response {
let mut audio_player = get_audio_player().await.lock().await;
audio_player.pause();
Response::new(true, "Audio was paused")
}
}
#[async_trait]
impl Executable for ResumeCommand {
async fn execute(&self) -> Response {
let mut audio_player = get_audio_player().await.lock().await;
audio_player.resume();
Response::new(true, "Audio was resumed")
}
}
#[async_trait]
impl Executable for StopCommand {
async fn execute(&self) -> Response {
let mut audio_player = get_audio_player().await.lock().await;
audio_player.stop();
Response::new(true, "Audio was stopped")
}
}
#[async_trait]
impl Executable for IsPausedCommand {
async fn execute(&self) -> Response {
let audio_player = get_audio_player().await.lock().await;
let is_paused = audio_player.is_paused().to_string();
Response::new(true, is_paused)
}
}
#[async_trait]
impl Executable for GetStateCommand {
async fn execute(&self) -> Response {
let mut audio_player = get_audio_player().await.lock().await;
let state = audio_player.get_state();
Response::new(true, serde_json::to_string(&state).unwrap())
}
}
#[async_trait]
impl Executable for GetVolumeCommand {
async fn execute(&self) -> Response {
let audio_player = get_audio_player().await.lock().await;
let volume = audio_player.volume;
Response::new(true, volume.to_string())
}
}
#[async_trait]
impl Executable for SetVolumeCommand {
async fn execute(&self) -> Response {
if let Some(volume) = self.volume {
let mut audio_player = get_audio_player().await.lock().await;
audio_player.set_volume(volume);
Response::new(true, format!("Audio volume was set to {}", volume))
} else {
Response::new(false, "Invalid volume value")
}
}
}
#[async_trait]
impl Executable for GetPositionCommand {
async fn execute(&self) -> Response {
let mut audio_player = get_audio_player().await.lock().await;
let position = audio_player.get_position();
Response::new(true, position.to_string())
}
}
#[async_trait]
impl Executable for SeekCommand {
async fn execute(&self) -> Response {
if let Some(position) = self.position {
let mut audio_player = get_audio_player().await.lock().await;
match audio_player.seek(position) {
Ok(_) => Response::new(true, format!("Audio position was set to {}", position)),
Err(err) => Response::new(false, err.to_string()),
}
} else {
Response::new(false, "Invalid position value")
}
}
}
#[async_trait]
impl Executable for GetDurationCommand {
async fn execute(&self) -> Response {
let mut audio_player = get_audio_player().await.lock().await;
match audio_player.get_duration() {
Ok(duration) => Response::new(true, duration.to_string()),
Err(err) => Response::new(false, err.to_string()),
}
}
}
#[async_trait]
impl Executable for PlayCommand {
async fn execute(&self) -> Response {
if let Some(file_path) = &self.file_path {
let mut audio_player = get_audio_player().await.lock().await;
match audio_player.play(file_path).await {
Ok(_) => Response::new(true, format!("Now playing {}", file_path.display())),
Err(err) => Response::new(false, err.to_string()),
}
} else {
Response::new(false, "Invalid file path")
}
}
}
#[async_trait]
impl Executable for GetCurrentFilePathCommand {
async fn execute(&self) -> Response {
let mut audio_player = get_audio_player().await.lock().await;
let current_file_path = audio_player.get_current_file_path();
if let Some(current_file_path) = current_file_path {
Response::new(true, current_file_path.to_str().unwrap())
} else {
Response::new(false, "No file is playing")
}
}
}
#[async_trait]
impl Executable for GetCurrentInputCommand {
async fn execute(&self) -> Response {
let audio_player = get_audio_player().await.lock().await;
if let Some(input_device) = &audio_player.current_input_device {
Response::new(
true,
format!("{} - {}", input_device.name, input_device.nick),
)
} else {
Response::new(false, "No input device selected")
}
}
}
#[async_trait]
impl Executable for GetAllInputsCommand {
async fn execute(&self) -> Response {
let (input_devices, _output_devices) = get_all_devices().await.unwrap();
let mut input_devices_strings = vec![];
for device in input_devices {
if device.name == "pwsp-virtual-mic" {
continue;
}
let string = format!("{} - {}", device.name, device.nick);
input_devices_strings.push(string);
}
let response_message = input_devices_strings.join("; ");
Response::new(true, response_message)
}
}
#[async_trait]
impl Executable for SetCurrentInputCommand {
async fn execute(&self) -> Response {
if let Some(name) = &self.name {
let mut audio_player = get_audio_player().await.lock().await;
match audio_player.set_current_input_device(name).await {
Ok(_) => Response::new(true, "Input device was set"),
Err(err) => Response::new(false, err.to_string()),
}
} else {
Response::new(false, "Invalid index value")
}
}
}
+81
View File
@@ -0,0 +1,81 @@
use crate::utils::config::get_config_path;
use serde::{Deserialize, Serialize};
use std::{collections::HashSet, error::Error, fs, path::PathBuf};
#[derive(Default, Clone, Serialize, Deserialize)]
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");
let config_dir = config_path.parent().unwrap();
if !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)?;
Ok(serde_json::from_slice::<DaemonConfig>(&bytes)?)
}
}
#[derive(Clone, Serialize, Deserialize)]
pub struct GuiConfig {
pub scale_factor: f32,
pub save_volume: bool,
pub save_input: bool,
pub save_scale_factor: bool,
pub dirs: HashSet<PathBuf>,
}
impl Default for GuiConfig {
fn default() -> Self {
GuiConfig {
scale_factor: 1.0,
save_volume: false,
save_input: false,
save_scale_factor: false,
dirs: HashSet::default(),
}
}
}
impl GuiConfig {
pub fn save_to_file(&mut self) -> Result<(), Box<dyn Error>> {
let config_path = get_config_path()?.join("gui.json");
let config_dir = config_path.parent().unwrap();
if !config_path.exists() {
fs::create_dir_all(config_dir)?;
}
// 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)?;
Ok(serde_json::from_slice::<GuiConfig>(&bytes)?)
}
}
+48
View File
@@ -0,0 +1,48 @@
use crate::types::audio_player::PlayerState;
use egui::Id;
use std::{
collections::{HashMap, HashSet},
path::PathBuf,
};
#[derive(Default, Debug)]
pub struct AppState {
pub search_query: String,
pub position_slider_value: f32,
pub volume_slider_value: f32,
pub position_dragged: bool,
pub volume_dragged: bool,
pub show_settings: bool,
pub current_dir: Option<PathBuf>,
pub dirs: HashSet<PathBuf>,
pub selected_file: Option<PathBuf>,
pub files: HashSet<PathBuf>,
pub search_field_id: Option<Id>,
pub force_focus_id: Option<Id>,
}
#[derive(Default, Debug, Clone)]
pub struct AudioPlayerState {
pub state: PlayerState,
pub new_state: Option<PlayerState>,
pub current_file_path: PathBuf,
pub is_paused: bool,
pub volume: f32,
pub new_volume: Option<f32>,
pub position: f32,
pub new_position: Option<f32>,
pub duration: f32,
pub current_input: String,
pub all_inputs: HashMap<String, String>,
}
+31
View File
@@ -0,0 +1,31 @@
#[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>,
}
+101
View File
@@ -0,0 +1,101 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
pub struct Request {
pub name: String,
pub args: HashMap<String, String>,
}
impl Request {
pub fn new<T: AsRef<str>>(function_name: T, data: Vec<(T, T)>) -> Self {
let hashmap_data: HashMap<String, String> = data
.into_iter()
.map(|(key, value)| (key.as_ref().to_string(), value.as_ref().to_string()))
.collect();
Request {
name: function_name.as_ref().to_string(),
args: hashmap_data,
}
}
pub fn ping() -> Self {
Request::new("ping", vec![])
}
pub fn pause() -> Self {
Request::new("pause", vec![])
}
pub fn resume() -> Self {
Request::new("resume", vec![])
}
pub fn stop() -> Self {
Request::new("stop", vec![])
}
pub fn play(file_path: &str) -> Self {
Request::new("play", vec![("file_path", file_path)])
}
pub fn get_is_paused() -> Self {
Request::new("is_paused", vec![])
}
pub fn get_volume() -> Self {
Request::new("get_volume", vec![])
}
pub fn get_position() -> Self {
Request::new("get_position", vec![])
}
pub fn get_duration() -> Self {
Request::new("get_duration", vec![])
}
pub fn get_state() -> Self {
Request::new("get_state", vec![])
}
pub fn get_current_file_path() -> Self {
Request::new("get_current_file_path", vec![])
}
pub fn get_input() -> Self {
Request::new("get_input", vec![])
}
pub fn get_inputs() -> Self {
Request::new("get_inputs", vec![])
}
pub fn set_volume(volume: f32) -> Self {
Request::new("set_volume", vec![("volume", &volume.to_string())])
}
pub fn seek(position: f32) -> Self {
Request::new("seek", vec![("position", &position.to_string())])
}
pub fn set_input(name: &str) -> Self {
Request::new("set_input", vec![("input_name", name)])
}
}
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
pub struct Response {
pub status: bool,
pub message: String,
}
impl Response {
pub fn new<T: AsRef<str>>(status: bool, message: T) -> Self {
Response {
status,
message: message.as_ref().to_string(),
}
}
}
+52
View File
@@ -0,0 +1,52 @@
use crate::types::{commands::*, socket::Request};
use std::path::PathBuf;
pub fn parse_command(request: &Request) -> Option<Box<dyn Executable + Send>> {
match request.name.as_str() {
"ping" => Some(Box::new(PingCommand {})),
"pause" => Some(Box::new(PauseCommand {})),
"resume" => Some(Box::new(ResumeCommand {})),
"stop" => Some(Box::new(StopCommand {})),
"is_paused" => Some(Box::new(IsPausedCommand {})),
"get_state" => Some(Box::new(GetStateCommand {})),
"get_volume" => Some(Box::new(GetVolumeCommand {})),
"set_volume" => {
let volume = request
.args
.get("volume")
.unwrap_or(&String::new())
.parse::<f32>()
.ok();
Some(Box::new(SetVolumeCommand { volume }))
}
"get_position" => Some(Box::new(GetPositionCommand {})),
"seek" => {
let position = request
.args
.get("position")
.unwrap_or(&String::new())
.parse::<f32>()
.ok();
Some(Box::new(SeekCommand { position }))
}
"get_duration" => Some(Box::new(GetDurationCommand {})),
"play" => {
let file_path = request
.args
.get("file_path")
.unwrap_or(&String::new())
.parse::<PathBuf>()
.ok();
Some(Box::new(PlayCommand { file_path }))
}
"get_current_file_path" => Some(Box::new(GetCurrentFilePathCommand {})),
"get_input" => Some(Box::new(GetCurrentInputCommand {})),
"get_inputs" => Some(Box::new(GetAllInputsCommand {})),
"set_input" => {
let name = Some(request.args.get("input_name").unwrap_or(&String::new())).cloned();
Some(Box::new(SetCurrentInputCommand { name }))
}
_ => None,
}
}
+6
View File
@@ -0,0 +1,6 @@
use std::{error::Error, path::PathBuf};
pub fn get_config_path() -> Result<PathBuf, Box<dyn Error>> {
let config_path = dirs::config_dir().expect("Failed to obtain config dir");
Ok(config_path.join("pwsp"))
}
+156
View File
@@ -0,0 +1,156 @@
use crate::{
types::{
audio_player::AudioPlayer,
config::DaemonConfig,
pipewire::AudioDevice,
socket::{Request, Response},
},
utils::pipewire::{create_link, get_all_devices},
};
use std::path::PathBuf;
use std::{error::Error, fs};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::UnixStream,
sync::{Mutex, OnceCell},
time::{Duration, sleep},
};
static AUDIO_PLAYER: OnceCell<Mutex<AudioPlayer>> = OnceCell::const_new();
pub async fn get_audio_player() -> &'static Mutex<AudioPlayer> {
AUDIO_PLAYER
.get_or_init(|| async {
println!("Initializing audio player");
Mutex::new(AudioPlayer::new().await.unwrap())
})
.await
}
pub fn get_daemon_config() -> DaemonConfig {
DaemonConfig::load_from_file().unwrap_or_else(|_| {
let config = DaemonConfig::default();
config.save_to_file().ok();
config
})
}
pub async fn link_player_to_virtual_mic() -> Result<(), Box<dyn Error>> {
let (input_devices, output_devices) = get_all_devices().await?;
let mut pwsp_daemon_output: Option<AudioDevice> = None;
for output_device in output_devices {
if output_device.name == "alsa_playback.pwsp-daemon" {
pwsp_daemon_output = Some(output_device);
break;
}
}
if pwsp_daemon_output.is_none() {
println!("Could not find pwsp-daemon output device, skipping device linking");
return Ok(());
}
let mut pwsp_daemon_input: Option<AudioDevice> = None;
for input_device in input_devices {
if input_device.name == "pwsp-virtual-mic" {
pwsp_daemon_input = Some(input_device);
break;
}
}
if pwsp_daemon_input.is_none() {
println!("Could not find pwsp-daemon input device, skipping device linking");
return Ok(());
}
let pwsp_daemon_output = pwsp_daemon_output.unwrap();
let pwsp_daemon_input = pwsp_daemon_input.unwrap();
let output_fl = pwsp_daemon_output
.clone()
.output_fl
.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"))
}
pub fn create_runtime_dir() -> Result<(), Box<dyn Error>> {
let runtime_dir = get_runtime_dir();
if !runtime_dir.exists() {
fs::create_dir_all(&runtime_dir)?;
}
Ok(())
}
pub fn is_daemon_running() -> Result<bool, Box<dyn Error>> {
let lock_file = fs::File::create(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>> {
if is_daemon_running()? {
return Ok(());
}
println!("Daemon not found, waiting for it...");
while !is_daemon_running()? {
sleep(Duration::from_millis(100)).await;
}
println!("Found running daemon");
Ok(())
}
pub async fn make_request(request: Request) -> Result<Response, Box<dyn Error>> {
let socket_path = get_runtime_dir().join("daemon.sock");
let mut stream = UnixStream::connect(socket_path).await?;
// ---------- Send request (start) ----------
let request_data = serde_json::to_vec(&request)?;
let request_len = request_data.len() as u32;
if stream.write_all(&request_len.to_le_bytes()).await.is_err() {
return Err("Failed to send request length".into());
};
if stream.write_all(&request_data).await.is_err() {
return Err("Failed to send request".into());
}
// ---------- Send request (end) ----------
// ---------- Read response (start) ----------
let mut len_bytes = [0u8; 4];
if stream.read_exact(&mut len_bytes).await.is_err() {
return Err("Failed to read response length".into());
}
let response_len = u32::from_le_bytes(len_bytes) as usize;
let mut buffer = vec![0u8; response_len];
if stream.read_exact(&mut buffer).await.is_err() {
return Err("Failed to read response".into());
};
// ---------- Read response (end) ----------
Ok(serde_json::from_slice(&buffer)?)
}
+154
View File
@@ -0,0 +1,154 @@
use crate::{
types::{
audio_player::PlayerState,
config::GuiConfig,
gui::AudioPlayerState,
socket::{Request, Response},
},
utils::daemon::{make_request, wait_for_daemon},
};
use std::{
collections::HashMap,
error::Error,
path::PathBuf,
sync::{Arc, Mutex},
};
use tokio::time::{Duration, sleep};
pub fn get_gui_config() -> GuiConfig {
GuiConfig::load_from_file().unwrap_or_else(|_| {
let mut config = GuiConfig::default();
config.save_to_file().ok();
config
})
}
pub fn make_request_sync(request: Request) -> Result<Response, Box<dyn Error>> {
futures::executor::block_on(make_request(request))
}
pub fn format_time_pair(position: f32, duration: f32) -> String {
fn format_time(seconds: f32) -> String {
let total_seconds = seconds.round() as u32;
let minutes = total_seconds / 60;
let secs = total_seconds % 60;
format!("{:02}:{:02}", minutes, secs)
}
format!("{}/{}", format_time(position), format_time(duration))
}
pub fn start_app_state_thread(audio_player_state_shared: Arc<Mutex<AudioPlayerState>>) {
tokio::spawn(async move {
let sleep_duration = Duration::from_millis(100);
loop {
wait_for_daemon().await.ok();
let state_req = Request::get_state();
let file_path_req = Request::get_current_file_path();
let is_paused_req = Request::get_is_paused();
let volume_req = Request::get_volume();
let position_req = Request::get_position();
let duration_req = Request::get_duration();
let current_input_req = Request::get_input();
let all_inputs_req = Request::get_inputs();
let state_res = make_request(state_req).await.unwrap_or_default();
let file_path_res = make_request(file_path_req).await.unwrap_or_default();
let is_paused_res = make_request(is_paused_req).await.unwrap_or_default();
let volume_res = make_request(volume_req).await.unwrap_or_default();
let position_res = make_request(position_req).await.unwrap_or_default();
let duration_res = make_request(duration_req).await.unwrap_or_default();
let current_input_res = make_request(current_input_req).await.unwrap_or_default();
let all_inputs_res = make_request(all_inputs_req).await.unwrap_or_default();
let state = match state_res.status {
true => serde_json::from_str::<PlayerState>(&state_res.message).unwrap(),
false => PlayerState::default(),
};
let file_path = match file_path_res.status {
true => PathBuf::from(file_path_res.message),
false => PathBuf::new(),
};
let is_paused = match is_paused_res.status {
true => is_paused_res.message == "true",
false => false,
};
let volume = match volume_res.status {
true => volume_res.message.parse::<f32>().unwrap(),
false => 0.0,
};
let position = match position_res.status {
true => position_res.message.parse::<f32>().unwrap(),
false => 0.0,
};
let duration = match duration_res.status {
true => duration_res.message.parse::<f32>().unwrap(),
false => 0.0,
};
let current_input = match current_input_res.status {
true => current_input_res
.message
.as_str()
.split(" - ")
.collect::<Vec<&str>>()
.first()
.unwrap()
.to_string(),
false => String::new(),
};
let all_inputs = match all_inputs_res.status {
true => all_inputs_res
.message
.as_str()
.split(';')
.filter_map(|entry| {
let entry = entry.trim();
if entry.is_empty() {
return None;
}
entry
.split_once(" - ")
.map(|(k, v)| (k.trim().to_string(), v.trim().to_string()))
})
.collect::<HashMap<String, String>>(),
false => HashMap::new(),
};
{
let mut guard = audio_player_state_shared.lock().unwrap();
guard.state = match guard.new_state.clone() {
Some(new_state) => {
guard.new_state = None;
new_state
}
None => state,
};
guard.current_file_path = file_path;
guard.is_paused = is_paused;
guard.volume = match guard.new_volume {
Some(new_volume) => {
guard.new_volume = None;
new_volume
}
None => volume,
};
guard.position = match guard.new_position {
Some(new_position) => {
guard.new_position = None;
new_position
}
None => position,
};
guard.duration = if duration > 0.0 { duration } else { 1.0 };
guard.current_input = current_input;
guard.all_inputs = all_inputs;
}
sleep(sleep_duration).await;
}
});
}
@@ -1,6 +1,5 @@
pub mod commands; pub mod commands;
pub mod config; pub mod config;
pub mod daemon; pub mod daemon;
pub mod global_hotkeys;
pub mod gui; pub mod gui;
pub mod pipewire; pub mod pipewire;
+309
View File
@@ -0,0 +1,309 @@
use crate::types::pipewire::{AudioDevice, DeviceType, Port, Terminate};
use pipewire::{
context::ContextRc, link::Link, main_loop::MainLoopRc, properties::properties,
registry::GlobalObject, spa::utils::dict::DictRef,
};
use std::{collections::HashMap, error::Error, thread};
use tokio::{
sync::mpsc,
time::{Duration, timeout},
};
fn parse_global_object(
global_object: &GlobalObject<&DictRef>,
) -> (Option<AudioDevice>, Option<Port>) {
// Only objects with props can be devices/ports
if let Some(props) = global_object.props {
// Only objects with media.class can be devices
if let Some(media_class) = props.get("media.class") {
let node_id = global_object.id;
let node_nick = props.get("node.nick");
let node_name = props.get("node.name");
let node_description = props.get("node.description");
// Check if the device is an input or output
return if media_class.starts_with("Audio/Source") {
let input_device = AudioDevice {
id: node_id,
nick: node_nick
.unwrap_or(node_description.unwrap_or(node_name.unwrap_or_default()))
.to_string(),
name: node_name.unwrap_or_default().to_string(),
device_type: DeviceType::Input,
input_fl: None,
input_fr: None,
output_fl: None,
output_fr: None,
};
(Some(input_device), None)
} else if media_class.starts_with("Stream/Output/Audio") {
let output_device = AudioDevice {
id: node_id,
nick: node_nick
.unwrap_or(node_description.unwrap_or(node_name.unwrap_or_default()))
.to_string(),
name: node_name.unwrap_or_default().to_string(),
device_type: DeviceType::Output,
input_fl: None,
input_fr: None,
output_fl: None,
output_fr: None,
};
(Some(output_device), None)
} else {
(None, None)
};
// Check if the object is a port
} else if props.get("port.direction").is_some() {
let node_id = props.get("node.id").unwrap().parse::<u32>().unwrap();
let port_id = props.get("port.id").unwrap().parse::<u32>().unwrap();
let port_name = props.get("port.name").unwrap();
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>,
) {
pipewire::init();
let main_loop = MainLoopRc::new(None).expect("Failed to initialize pipewire main loop");
// Stop main loop on Terminate message
let _receiver = pw_receiver.attach(main_loop.loop_(), {
let _main_loop = main_loop.clone();
move |_| _main_loop.quit()
});
let 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 _listener = registry
.add_listener_local()
.global(move |global| {
// Try to parse every global object pipewire finds
let (device, port) = parse_global_object(global);
// Send message to the main thread
let sender_clone = main_sender.clone();
tokio::task::spawn(async move {
sender_clone.send((device, port)).await.ok();
});
})
.register();
main_loop.run();
}
pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>), Box<dyn Error>> {
// Channels to communicate with pipewire thread
let (main_sender, mut main_receiver) = mpsc::channel(10);
let (pw_sender, pw_receiver) = pipewire::channel::channel();
// Spawn pipewire thread in background
let _pw_thread =
tokio::spawn(async move { pw_get_global_objects_thread(main_sender, pw_receiver).await });
let mut input_devices: HashMap<u32, AudioDevice> = HashMap::new();
let mut output_devices: HashMap<u32, AudioDevice> = HashMap::new();
let mut ports: Vec<Port> = vec![];
loop {
// If we don't receive a message in 100ms, we can assume that pipewire thread is finished
match timeout(Duration::from_millis(100), main_receiver.recv()).await {
Ok(Some((device, port))) => {
if let Some(device) = device {
match device.device_type {
DeviceType::Input => {
input_devices.insert(device.id, device);
}
DeviceType::Output => {
output_devices.insert(device.id, device);
}
}
} else if let Some(port) = port {
ports.push(port);
}
}
Ok(None) | Err(_) => {
// Pipewire thread is finished and we can collect our devices
pw_sender
.send(Terminate {})
.expect("Failed to terminate pipewire thread");
for port in ports {
let node_id = port.node_id;
if input_devices.contains_key(&node_id) {
let input_device = input_devices.get_mut(&node_id).unwrap();
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.input_fl = Some(port.clone());
input_device.input_fr = Some(port);
}
_ => {}
}
} else if output_devices.contains_key(&node_id) {
let output_device = output_devices.get_mut(&node_id).unwrap();
match port.name.as_str() {
"input_FL" => output_device.input_fl = Some(port),
"input_FR" => output_device.input_fr = Some(port),
"output_FL" => output_device.output_fl = Some(port),
"output_FR" => output_device.output_fr = Some(port),
"capture_FL" => output_device.output_fl = Some(port),
"capture_FR" => output_device.output_fr = Some(port),
"output_MONO" => {
output_device.output_fl = Some(port.clone());
output_device.output_fr = Some(port)
}
"capture_MONO" => {
output_device.output_fl = Some(port.clone());
output_device.output_fr = Some(port)
}
_ => {}
}
}
}
let mut input_devices: Vec<AudioDevice> = input_devices.values().cloned().collect();
let mut output_devices: Vec<AudioDevice> =
output_devices.values().cloned().collect();
input_devices.sort_by(|a, b| a.id.cmp(&b.id));
output_devices.sort_by(|a, b| a.id.cmp(&b.id));
return Ok((input_devices, output_devices));
}
}
}
}
pub async fn get_device(device_name: &str) -> Result<AudioDevice, Box<dyn Error>> {
let (mut input_devices, output_devices) = get_all_devices().await?;
input_devices.extend(output_devices);
for device in input_devices {
if device.name == device_name {
return Ok(device);
}
}
Err("Device not found".into())
}
pub fn create_virtual_mic() -> Result<pipewire::channel::Sender<Terminate>, Box<dyn Error>> {
let (pw_sender, pw_receiver) = pipewire::channel::channel::<Terminate>();
let _pw_thread = thread::spawn(move || {
pipewire::init();
let main_loop = MainLoopRc::new(None).expect("Failed to initialize pipewire main loop");
let context = ContextRc::new(&main_loop, None).expect("Failed to create pipewire context");
let core = context
.connect(None)
.expect("Failed to connect to pipewire context");
let props = properties!(
"factory.name" => "support.null-audio-sink",
"node.name" => "pwsp-virtual-mic",
"node.description" => "PWSP Virtual Mic",
"media.class" => "Audio/Source/Virtual",
"audio.position" => "[ FL FR ]",
"audio.channels" => "2",
"object.linger" => "false", // Destroy the node on app exit
);
let _node = core
.create_object::<pipewire::node::Node>("adapter", &props)
.expect("Failed to create virtual mic");
let _receiver = pw_receiver.attach(main_loop.loop_(), {
let _main_loop = main_loop.clone();
move |_| _main_loop.quit()
});
println!("Virtual mic created");
main_loop.run();
});
Ok(pw_sender)
}
pub fn create_link(
output_fl: Port,
output_fr: Port,
input_fl: Port,
input_fr: Port,
) -> Result<pipewire::channel::Sender<Terminate>, Box<dyn Error>> {
let (pw_sender, pw_receiver) = pipewire::channel::channel::<Terminate>();
let _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 props_fl = properties! {
"link.output.node" => format!("{}", output_fl.node_id).as_str(),
"link.output.port" => format!("{}", output_fl.port_id).as_str(),
"link.input.node" => format!("{}", input_fl.node_id).as_str(),
"link.input.port" => format!("{}", input_fl.port_id).as_str(),
};
let props_fr = properties! {
"link.output.node" => format!("{}", output_fr.node_id).as_str(),
"link.output.port" => format!("{}", output_fr.port_id).as_str(),
"link.input.node" => format!("{}", input_fr.node_id).as_str(),
"link.input.port" => format!("{}", input_fr.port_id).as_str(),
};
let _link_fl = 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 _receiver = pw_receiver.attach(main_loop.loop_(), {
let _main_loop = main_loop.clone();
move |_| _main_loop.quit()
});
println!(
"Link created: FL: {}-{} FR: {}-{}",
output_fl.node_id, input_fl.node_id, output_fr.node_id, input_fr.node_id
);
main_loop.run();
});
Ok(pw_sender)
}