Compare commits

...

109 Commits

Author SHA1 Message Date
arabianq 87bd6db446 change version to 1.1.4 2025-12-14 01:28:28 +03:00
arabianq 1fc6c8829c cargo update 2025-12-14 01:27:38 +03:00
arabianq a63e44a4a8 bump clap version to 4.5.53 2025-12-14 01:26:19 +03:00
arabianq 5de29f3d14 bump egui and eframe to 0.33.3 2025-12-14 01:24:46 +03:00
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
arabian 1957a5e2fd Delete scripts directory 2025-09-26 01:43:08 +03:00
arabian 5f852343da Update README.md 2025-09-26 01:42:14 +03:00
arabian aee48c8f8d Update release-deb.yml
fix .deb uploading
2025-09-26 01:22:35 +03:00
arabian c63b220d92 Update release-deb.yml
change name
2025-09-26 01:17:48 +03:00
arabian a4708f1812 Create release-deb.yml 2025-09-26 01:17:28 +03:00
arabian 3754121ab5 Update and rename build-release.yml to release-archive.yml 2025-09-26 01:10:35 +03:00
arabian a665939137 Update build-release.yml 2025-09-26 01:01:58 +03:00
arabian 974fdc9411 Update build-release.yml
try to fix zip creation
2025-09-26 00:54:56 +03:00
arabian 9a1107fb41 Update build-release.yml
change workflow_dispatch description
2025-09-26 00:50:13 +03:00
arabian ad2c15f9e3 Update build-release.yml
add build dependencies installation
2025-09-26 00:49:40 +03:00
arabian 6d66b57d1b Create build-release.yml 2025-09-26 00:46:28 +03:00
arabian 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
arabian 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
arabian faa29d8805 Create LICENSE 2025-02-09 13:43:23 +03:00
35 changed files with 7477 additions and 723 deletions
+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
-1
View File
@@ -1,3 +1,2 @@
/target /target
.idea .idea
Cargo.lock
Generated
+4606
View File
File diff suppressed because it is too large Load Diff
+42 -11
View File
@@ -1,24 +1,46 @@
[package] [package]
name = "pwsp" name = "pwsp"
version = "0.1.0" version = "1.1.4"
edition = "2021" edition = "2024"
authors = ["arabian"] authors = ["arabian"]
description = "A simple soundpad application written in Rust using egui for the GUI, pipewire for audio input/output, and rodio for audio decoding." description = "PWSP lets you play audio files through your microphone. Has both CLI and GUI clients."
readme = "README.md" readme = "README.md"
homepage = "https://github.com/arabianq/pipewire-soundpad" homepage = "https://pwsp.arabianq.ru"
repository = "https://github.com/arabianq/pipewire-soundpad" repository = "https://github.com/arabianq/pipewire-soundpad"
license = "MIT" license = "MIT"
keywords = ["soundpad", "pipewire"] keywords = ["soundpad", "pipewire", "linux", "cli", "gui"]
[dependencies] [dependencies]
egui = "0.31.0" tokio = { version = "1.48.0", features = ["full"] }
eframe = "0.31.0" futures = { version = "0.3.31", features = ["thread-pool"] }
egui_material_icons = "0.3.0" async-trait = "0.1.89"
rfd = "0.15.2"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
clap = { version = "4.5.53", default-features = false, features = ["std", "suggestions", "help", "usage", "error-context", "derive"] }
dirs = "6.0.0" dirs = "6.0.0"
rodio = {version = "0.20.1", default-features = false, features = ["symphonia-all"]}
metadata = "0.1.10" rodio = { version = "0.21.1", default-features = false, features = ["symphonia-all", "playback"] }
pipewire = "0.9.2"
rfd = "0.16.0"
egui = { version = "0.33.3", default-features = false, features = ["default_fonts", "rayon"] }
eframe = { version = "0.33.3", default-features = false, features = ["default_fonts", "glow", "x11", "wayland"] }
egui_material_icons = "0.5.0"
[[bin]]
name = "pwsp-daemon"
path = "src/bin/daemon.rs"
[[bin]]
name = "pwsp-cli"
path = "src/bin/cli.rs"
[[bin]]
name = "pwsp-gui"
path = "src/main.rs"
[profile.release] [profile.release]
strip = true strip = true
@@ -27,3 +49,12 @@ 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"],
]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Tarasov Alexander
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+170 -33
View File
@@ -1,48 +1,185 @@
# PipeWire SoundPad # **🎵 Pipewire Soundpad (PWSP)**
This is a simple soundpad application written in Rust using egui for the GUI, pipewire for audio input/output, and rodio for audio decoding. It allows you to play various audio files (mp3, wav, ogg, flac, mp4, aac) through your microphone. **PipeWire Soundpad (PWSP)** is a simple yet powerful **soundboard application** written in **Rust**. It provides a
user-friendly graphical interface for **managing and playing audio files, directing their output directly to the virtual
microphone.** This makes it an ideal tool for gamers, streamers, and anyone looking to inject sound effects into voice
chats on platforms like **Discord, Zoom, or Teamspeak**.
![ScreenShot](screenshot.png) ![screenshot.png](assets/screenshot.png)
## Features # **🌟 Key Features**
* **Audio File Playback:** Supports a wide range of audio formats including mp3, wav, ogg, flac, mp4, and aac. * **Multi-Format Support**: Play audio files in popular formats, including _**mp3**_, _**wav**_, _**ogg**_, _**flac**_,
* **Microphone Output:** Plays selected audio files through the chosen microphone. _**mp4**_, and _**aac**_.
* **PipeWire Integration:** Leverages PipeWire for efficient audio routing and device management. * **Virtual Microphone Output**: The application routes audio through a virtual device created by PipeWire, allowing
* **egui GUI:** Provides a user-friendly interface for file selection, playback control, and microphone selection. other users to hear the sounds as if you were speaking into your microphone.
* **Directory Management:** Allows adding and removing directories for organizing audio files. * **Modern and Clean GUI**: The interface is built with the [egui](https://egui.rs) library, ensuring an intuitive and
* **Search Functionality:** Enables searching for specific audio files within loaded directories. responsive user experience.
* **Playback Control:** Offers play/pause functionality, volume control, and a playback position slider. * **Sound Collection Management**: Easily add and remove directories containing your audio files. The application scans
* **Persistent Configuration:** Saves the list of added directories and the selected microphone for future use. 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.
## Installation # **⚙️ How It Works**
1. **Rust and Cargo:** Ensure you have Rust and Cargo installed. You can install them from [https://www.rust-lang.org/tools/install](https://www.rust-lang.org/tools/install). PWSP is designed with a clear separation of concerns, operating through a client-server architecture. It consists of
2. **Dependencies:** This project requires PipeWire to be installed on your system. The specific installation steps will depend on your distribution. three main components:
3. **Clone the Repository:** Clone this repository to your local machine.
4. **Build:** Navigate to the project directory in your terminal and run `cargo build --release`.
## Usage * **pwsp-daemon**: This is the core of the application. It runs silently in the background, managing all the
heavy-lifting tasks. The daemon is responsible for:
* Creating and managing virtual audio devices.
* Linking these devices within the PipeWire graph.
* Handling all audio playback.
* **pwsp-gui**: This is the graphical user interface. It acts as a client that communicates with pwsp-daemon via a *
*UnixSocket**. This is how you interact with your sound collection, control playback, and configure settings.
* **pwsp-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.
1. **Run:** After building, run the application using `cargo run --release`. # **🚀 Installation**
2. **Add Directories:** Click the "+" button to add directories containing your audio files.
3. **Select Directory:** Click on a directory in the list to view its contents.
4. **Select Audio File:** Click on an audio file in the list to select it.
5. **Choose Microphone:** Select your desired microphone from the dropdown menu.
6. **Play/Pause:** Use the play/pause button to control playback.
7. **Volume:** Adjust the volume using the volume slider.
8. **Seek:** Use the playback position slider to navigate through the audio file.
9. **Search:** Use the search bar to filter audio files by name.
## Configuration ## **Pre-built Packages**
The application saves the list of added directories and the selected microphone in the application's configuration directory. This directory is typically located at `~/.config/pwsp`. You can download pre-built binaries, .deb and .rpm packages from
the [releases page](https://github.com/arabianq/pipewire-soundpad/releases).
## **Fedora Linux**
## Contributing If you're using Fedora, you can install PWSP from a dedicated repository using DNF.
Contributions are welcome! Please open an issue or submit a pull request. Add the repository:
## License ```bash
sudo dnf copr enable arabianq/pwsp
```
[MIT](LICENSE) Update cache:
```bash
sudo dnf makecache
```
Install PWSP:
```bash
sudo dnf install pwsp
```
## **Arch Linux**
There is pwsp package in AUR.
You can install it using yay, paru or any other AUR helper.
```bash
paru pwsp
```
## **Installing using cargo**
```bash
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
git clone https://github.com/arabianq/pipewire-soundpad.git
cd pipewire-soundpad
```
Build the project:
```bash
cargo build --release
```
Now you have three binary files inside ./target/release/:
- **pwsp-gui**
- **pwsp-cli**
- **pwsp-daemon**
# **🎮 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
/path/to/your/pwsp-daemon &
```
* **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
```
### **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
pwsp-cli --help
```
* **Example Commands**:
* **Play a file**:
```bash
pwsp-cli action play <file_path>
```
* **Get the current volume**:
```bash
pwsp-cli get volume
```
* **Set playback position to 20 seconds**:
```bash
pwsp-cli set position 20
```
# **🤝 Contributing**
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**
This project is licensed under
the [MIT License](https://github.com/arabianq/pipewire-soundpad/blob/main/LICENSE).
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
+8
View File
@@ -0,0 +1,8 @@
[Desktop Entry]
Name=PWSP (Soundpad)
Comment=Let's you play audio files through you microphone
Exec=pwsp-gui %u
Icon=pwsp
Terminal=false
Type=Application
Categories=Audio
Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

+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.4
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
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

+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);
}
}
+2
View File
@@ -0,0 +1,2 @@
pub mod types;
pub mod utils;
+5 -462
View File
@@ -1,465 +1,8 @@
use eframe::{CreationContext, Frame, NativeOptions}; mod gui;
use egui::{
Button, CentralPanel, ComboBox, Context, Label, ScrollArea, Separator, Slider, TextEdit, Ui,
Vec2,
};
use egui_material_icons::icons;
use metadata::media_file::MediaFileMetadata;
use rfd::FileDialog;
use rodio::{Decoder, OutputStream, OutputStreamHandle, Sink};
use std::io::Write;
use std::{fs, path::PathBuf};
mod pw; use std::error::Error;
enum PlayerState { #[tokio::main]
PLAYING, async fn main() -> Result<(), Box<dyn Error>> {
PAUSED, gui::run().await
}
impl Default for PlayerState {
fn default() -> Self {
PlayerState::PAUSED
}
}
#[derive(Default)]
struct App {
player_position: f32,
prev_player_position: f32,
max_player_position: f32,
volume: f32,
player_state: PlayerState,
directories: Vec<PathBuf>,
deleted_directory: Option<usize>,
current_directory: Option<usize>,
selected_input_device: String,
available_input_devices: Vec<pw::InputDevice>,
current_file: PathBuf,
search_query: String,
_audio_stream: Option<OutputStream>,
_audio_stream_handle: Option<OutputStreamHandle>,
audio_sink: Option<Sink>,
}
impl App {
pub fn new(_cc: &CreationContext<'_>) -> Self {
let saved_dirs_path = dirs::config_dir().unwrap().join("pwsp").join("saved_dirs");
let saved_dirs_content = fs::read_to_string(&saved_dirs_path).unwrap_or_default();
let saved_dirs: Vec<_> = saved_dirs_content.lines().map(PathBuf::from).collect();
let current_directory = match saved_dirs.is_empty() {
false => Some(0),
true => None,
};
let saved_mic_path = dirs::config_dir().unwrap().join("pwsp").join("saved_mic");
let saved_mic_content = fs::read_to_string(&saved_mic_path).unwrap_or_default();
let (_audio_stream, audio_stream_handle) = OutputStream::try_default().unwrap();
let audio_sink = Sink::try_new(&audio_stream_handle).unwrap();
audio_sink.pause();
Self {
max_player_position: 1.0,
directories: saved_dirs,
selected_input_device: saved_mic_content,
_audio_stream: Some(_audio_stream),
_audio_stream_handle: Some(audio_stream_handle),
audio_sink: Some(audio_sink),
volume: 1.0,
current_directory,
..Default::default()
}
}
fn upd(&mut self, ui: &mut Ui, ctx: &Context, _frame: &mut Frame) {
self.available_input_devices = pw::get_input_devices().unwrap();
let saved_mic_path = dirs::config_dir().unwrap().join("pwsp").join("saved_mic");
let saved_mic_content = fs::read_to_string(&saved_mic_path).unwrap_or_default();
if self.selected_input_device != saved_mic_content {
fs::write(saved_mic_path, self.selected_input_device.clone()).ok();
}
if let PlayerState::PLAYING = self.player_state {
ctx.request_repaint();
self.audio_sink.as_ref().unwrap().set_volume(self.volume);
}
self.player_state = match self.audio_sink.as_ref().unwrap().is_paused() {
true => PlayerState::PAUSED,
false => PlayerState::PLAYING,
};
if self.player_position != self.prev_player_position {
let target_pos = core::time::Duration::from_secs_f32(self.player_position);
self.audio_sink
.as_ref()
.unwrap()
.try_seek(target_pos)
.unwrap();
self.prev_player_position = self.player_position;
} else {
self.player_position = self.audio_sink.as_ref().unwrap().get_pos().as_secs_f32();
}
self.prev_player_position = self.player_position;
self.render_ui(ui);
}
fn render_ui(&mut self, ui: &mut Ui) {
self.render_player(ui);
ui.add_space(10.0);
ui.separator();
ui.add_space(10.0);
self.render_content(ui);
}
fn render_player(&mut self, ui: &mut Ui) {
let file_title_label = Label::new(
self.current_file
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(""),
);
let play_button_icon = match self.player_state {
PlayerState::PLAYING => icons::ICON_PAUSE,
PlayerState::PAUSED => icons::ICON_PLAY_ARROW,
};
let play_button = Button::new(play_button_icon).corner_radius(15.0);
let position_minutes = (self.player_position / 60.0) as i32;
let position_seconds = (self.player_position - (position_minutes as f32) * 60.0) as i32;
let position_minutes_str = match position_minutes.to_string().chars().count() {
1 => "0".to_string() + &position_minutes.to_string(),
2 => position_minutes.to_string(),
_ => "00".to_string(),
};
let position_seconds_str = match position_seconds.to_string().chars().count() {
1 => "0".to_string() + &position_seconds.to_string(),
2 => position_seconds.to_string(),
_ => "00".to_string(),
};
let player_position_label =
Label::new(format!("{}:{}", position_minutes_str, position_seconds_str));
let player_position_slider =
Slider::new(&mut self.player_position, 0.0..=self.max_player_position)
.show_value(false);
let volume_slider = Slider::new(&mut self.volume, 0.0..=1.0).show_value(false);
ui.add_space(10.0);
ui.horizontal_top(|ui| {
let play_button_response = ui.add_sized([30.0, 30.0], play_button);
if !self.current_file.display().to_string().is_empty() && play_button_response.clicked()
{
match self.player_state {
PlayerState::PLAYING => {
self.audio_sink.as_ref().unwrap().pause();
}
PlayerState::PAUSED => {
self.audio_sink.as_ref().unwrap().play();
}
}
}
ui.vertical(|ui| {
ui.spacing_mut().slider_width = ui.available_width() - 150.0;
ui.add_sized([ui.available_width() - 150.0, 15.0], file_title_label);
ui.add_sized([ui.available_width() - 150.0, 15.0], player_position_slider);
});
ui.add_sized([30.0, 30.0], player_position_label);
let volume_slider_response = ui.add_sized([15.0, 30.0], volume_slider);
if volume_slider_response.changed() {
// println!("{}", self.volume.);
}
});
}
fn render_content(&mut self, ui: &mut Ui) {
let dirs_area_size = Vec2::new(120.0, ui.available_height());
ui.horizontal(|ui| {
ui.vertical(|ui| {
self.render_directories_list(ui, dirs_area_size);
self.render_mic_selection(ui);
});
ui.allocate_ui(dirs_area_size, |ui| {
if !self.directories.is_empty() {
ui.add(Separator::default().vertical());
}
});
let music_area_size = Vec2::new(ui.available_width(), ui.available_height());
self.render_music_list(ui, music_area_size);
});
self.handle_directory_deletion();
}
fn render_directories_list(&mut self, ui: &mut Ui, scroll_area_size: Vec2) {
let add_dir_button = Button::new(icons::ICON_ADD).frame(false);
ui.vertical(|ui| {
let add_dir_button_response = ui.add_sized([20.0, 20.0], add_dir_button);
if add_dir_button_response.clicked() {
self.handle_directory_adding();
}
ui.add_space(10.0);
ui.allocate_ui(scroll_area_size, |ui| {
ScrollArea::vertical().id_salt(0).show(ui, |ui| {
for (index, dir) in self.directories.iter().enumerate() {
let dir_name = dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("Invalid Directory");
let dir_button = Button::new(dir_name).frame(false);
let dir_delete_button = Button::new(icons::ICON_DELETE).frame(false);
ui.horizontal(|ui| {
let dir_button_response = ui.add(dir_button);
if dir_button_response.clicked() {
self.current_directory = Some(index);
}
let directory_delete_button_response = ui.add(dir_delete_button);
if directory_delete_button_response.clicked() {
self.deleted_directory = Some(index);
}
});
ui.separator();
}
});
});
});
}
fn handle_directory_adding(&mut self) {
if let Some(dirs) = FileDialog::pick_folders(Default::default()) {
let saved_dirs_path = dirs::config_dir().unwrap().join("pwsp").join("saved_dirs");
if let Ok(mut file) = fs::OpenOptions::new().append(true).open(saved_dirs_path) {
for path in dirs {
if !self.directories.contains(&path) {
self.directories.push(path.clone());
writeln!(file, "{}", path.display()).ok();
}
}
}
}
}
fn handle_directory_deletion(&mut self) {
if let Some(index) = self.deleted_directory {
if let Some(current_index) = self.current_directory {
if current_index > index {
self.current_directory = Some(current_index - 1);
} else if current_index == index {
self.current_directory = None;
}
}
self.directories.remove(index);
self.deleted_directory = None;
let saved_dirs_path = dirs::config_dir().unwrap().join("pwsp").join("saved_dirs");
let content = self
.directories
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join("\n");
fs::write(saved_dirs_path, content).ok();
}
}
fn render_music_list(&mut self, ui: &mut Ui, scroll_area_size: Vec2) {
if self.current_directory.is_none() {
return;
}
let current_path = self
.directories
.get(self.current_directory.unwrap())
.unwrap();
if !fs::exists(current_path).ok().unwrap_or(false) {
self.deleted_directory = self.current_directory;
return;
}
let files = fs::read_dir(current_path).ok().unwrap();
let mut music_files = Vec::new();
let music_extensions = ["mp3", "wav", "ogg", "flac", "mp4", "aac"].to_vec();
for file in files {
let path = file.unwrap().path();
if !path.is_file() {
continue;
}
if let Some(extension) = path.extension().and_then(|n| n.to_str()) {
if music_extensions.contains(&extension.to_lowercase().as_str()) {
if path
.to_str()
.unwrap()
.to_string()
.to_lowercase()
.contains(self.search_query.as_str())
{
music_files.push(path);
}
}
}
}
ui.vertical(|ui| {
let search_entry = TextEdit::singleline(&mut self.search_query);
ui.add_sized([ui.available_width(), 20.0], search_entry);
ui.separator();
ui.allocate_ui(scroll_area_size, |ui| {
ScrollArea::vertical().id_salt(1).show(ui, |ui| {
for file in music_files.iter() {
let file_name = file.file_name().and_then(|n| n.to_str()).unwrap();
let file_button = Button::new(file_name).frame(false);
let file_button_response = ui.add(file_button);
if file_button_response.clicked() {
self.current_file = file.to_path_buf();
self.play_current_file();
}
ui.separator();
}
});
});
});
}
fn render_mic_selection(&mut self, ui: &mut Ui) {
ComboBox::from_label("Choose MIC")
.selected_text(format!("{:?}", self.selected_input_device))
.show_ui(ui, |ui| {
for device in self.available_input_devices.iter() {
ui.selectable_value(
&mut self.selected_input_device,
device.audio_device.name.clone(),
device.audio_device.nick.clone(),
);
}
});
}
fn play_current_file(&mut self) {
if self.current_file.to_str().unwrap().is_empty() {
return;
}
if !self.current_file.exists() {
return;
}
if !self.selected_input_device.is_empty() {
self.link_devices();
}
let file = fs::File::open(self.current_file.display().to_string()).unwrap();
let source = Decoder::new(file).unwrap();
let metadata = MediaFileMetadata::new(&self.current_file.as_path()).unwrap();
self.max_player_position = metadata._duration.unwrap() as f32;
self.audio_sink.as_ref().unwrap().stop();
self.audio_sink.as_ref().unwrap().play();
self.audio_sink.as_ref().unwrap().append(source);
}
fn link_devices(&self) {
let output_devices = pw::get_output_devices().unwrap();
let mut pwsp_output: Option<&pw::OutputDevice> = None;
for device in output_devices.iter() {
if device.audio_device.name == "alsa_playback.pwsp" {
pwsp_output = Some(device);
break;
}
}
if pwsp_output.is_none() {
return;
}
let input_devices = pw::get_input_devices().unwrap();
let mut mic: Option<&pw::InputDevice> = None;
for device in input_devices.iter() {
if device.audio_device.name == self.selected_input_device {
mic = Some(device);
break;
}
}
if mic.is_none() {
return;
}
pwsp_output.unwrap().link(mic.unwrap());
}
}
impl eframe::App for App {
fn update(&mut self, ctx: &Context, frame: &mut Frame) {
CentralPanel::default().show(ctx, |ui| self.upd(ui, ctx, frame));
}
}
fn main() -> Result<(), eframe::Error> {
let config_dir_path = dirs::config_dir().unwrap().join("pwsp");
fs::create_dir_all(&config_dir_path).ok();
if !fs::exists(config_dir_path.join("saved_dirs"))
.ok()
.unwrap_or(false)
{
fs::File::create(config_dir_path.join("saved_dirs")).ok();
}
if !fs::exists(config_dir_path.join("saved_mic"))
.ok()
.unwrap_or(false)
{
fs::File::create(config_dir_path.join("saved_mic")).ok();
}
let mut options = NativeOptions {
..Default::default()
};
options.viewport.min_inner_size = Some(Vec2::new(400.0, 400.0));
options.vsync = true;
options.hardware_acceleration = eframe::HardwareAcceleration::Preferred;
eframe::run_native(
"PipeWire SoundPad",
options,
Box::new(|cc| {
egui_material_icons::initialize(&cc.egui_ctx);
Ok(Box::new(App::new(cc)))
}),
)
} }
-216
View File
@@ -1,216 +0,0 @@
use std::error::Error;
use std::process::Command;
use std::collections::HashMap;
pub struct AudioDevice {
pub nick: String,
pub name: String
}
pub struct InputDevice {
pub audio_device: AudioDevice,
pub input_fl: String,
pub input_fr: String
}
pub struct OutputDevice {
pub audio_device: AudioDevice,
pub output_fl: String,
pub output_fr: String
}
impl OutputDevice {
pub fn link(&self, input_device: &InputDevice) {
let _ = Command::new("pw-link")
.arg(&self.output_fl)
.arg(&input_device.input_fl)
.status();
let _ = Command::new("pw-link")
.arg(&self.output_fr)
.arg(&input_device.input_fr)
.status();
}
}
fn get_pw_entries() -> Result<Vec<HashMap<String, String>>, Box<dyn Error>> {
let output = Command::new("pw-cli")
.args(&["ls", "Node"])
.output()
.expect("Failed to execute pw-cli ls Node");
let output_str = String::from_utf8_lossy(&output.stdout);
let mut entries = Vec::new();
let mut current_entry = HashMap::new();
for line in output_str.lines() {
let line = line.trim();
if line.is_empty() {
continue
}
if line.starts_with("id ") {
if !current_entry.is_empty() {
entries.push(current_entry);
current_entry = HashMap::new();
}
} else {
let mut parts = line.splitn(2, " = ");
let key = match parts.next() {
Some(k) => k.trim().to_string(),
None => continue
};
let value = match parts.next() {
Some(k) => k.trim().strip_prefix("\"").unwrap().strip_suffix("\"").unwrap().to_string(),
None => continue
};
current_entry.insert(key, value);
}
}
if !current_entry.is_empty() {
entries.push(current_entry);
}
Ok(entries)
}
pub fn get_input_devices() -> Result<Vec<InputDevice>, Box<dyn Error>> {
let entries = get_pw_entries()?;
let mut input_devices = Vec::new();
for entry in entries.iter() {
let media_class = entry.get("media.class").map(String::as_str).unwrap_or("");
let nick = entry.get("node.nick").map(String::as_str)
.unwrap_or(entry.get("node.description").map(String::as_str)
.unwrap_or(entry.get("node.name").map(String::as_str).unwrap_or("")));
let name = entry.get("node.name").map(String::as_str).unwrap_or("");
if media_class.is_empty() {
continue
}
if !media_class.starts_with(&"Audio/Source") {
continue
}
if nick.is_empty() || name.is_empty() {
continue
}
let audio_device = AudioDevice {
nick: nick.to_string(),
name: name.to_string(),
};
let device = InputDevice {
audio_device,
input_fl: String::new(),
input_fr: String::new()
};
input_devices.push(device);
}
let output = Command::new("pw-link")
.arg("-i")
.output()
.expect("Failed to execute pw-link -i");
let output_str = String::from_utf8_lossy(&output.stdout);
for line in output_str.lines() {
let line = line.trim();
for device in input_devices.iter_mut() {
if line.starts_with(device.audio_device.name.as_str()) {
if line.ends_with("input_MONO") {
device.input_fl = line.to_string();
device.input_fr = line.to_string();
} else if line.ends_with("input_FL") {
device.input_fl = line.to_string();
} else if line.ends_with("input_FR") {
device.input_fr = line.to_string();
}
}
}
}
Ok(input_devices)
}
pub fn get_output_devices() -> Result<Vec<OutputDevice>, Box<dyn Error>> {
let entries = get_pw_entries()?;
let mut output_devices = Vec::new();
for entry in entries.iter() {
let media_class = entry.get("media.class").map(String::as_str).unwrap_or("");
let nick = entry.get("node.nick").map(String::as_str)
.unwrap_or(entry.get("node.description").map(String::as_str)
.unwrap_or(entry.get("node.name").map(String::as_str).unwrap_or("")));
let name = entry.get("node.name").map(String::as_str).unwrap_or("");
if media_class.is_empty() {
continue
}
if !media_class.starts_with(&"Stream/Output/Audio") {
continue
}
if nick.is_empty() || name.is_empty() {
continue
}
let audio_device = AudioDevice {
nick: nick.to_string(),
name: name.to_string(),
};
let device = OutputDevice {
audio_device,
output_fl: String::new(),
output_fr: String::new()
};
output_devices.push(device);
}
let output = Command::new("pw-link")
.arg("-o")
.output()
.expect("Failed to execute pw-link -o");
let output_str = String::from_utf8_lossy(&output.stdout);
for line in output_str.lines() {
let line = line.trim();
for device in output_devices.iter_mut() {
if line.starts_with(device.audio_device.name.as_str()) {
if line.ends_with("capture_MONO") {
device.output_fl = line.to_string();
device.output_fr = line.to_string();
} else if line.ends_with("output_FL") {
device.output_fl = line.to_string();
} else if line.ends_with("output_FR") {
device.output_fr = line.to_string();
}
}
}
}
Ok(output_devices)
}
+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>,
}
+6
View File
@@ -0,0 +1,6 @@
pub mod audio_player;
pub mod commands;
pub mod config;
pub mod gui;
pub mod pipewire;
pub mod socket;
+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;
}
});
}
+5
View File
@@ -0,0 +1,5 @@
pub mod commands;
pub mod config;
pub mod daemon;
pub mod gui;
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)
}