Compare commits

..

85 Commits

Author SHA1 Message Date
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
41 changed files with 1764 additions and 12200 deletions
-1
View File
@@ -1 +0,0 @@
custom: ['https://boosty.to/arabian']
-119
View File
@@ -1,119 +0,0 @@
name: Build
permissions:
contents: write
packages: write
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
workflow_dispatch:
jobs:
linux-build:
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: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Rust toolchain
uses: actions-rs/toolchain@v1
with:
toolchain: 1.94.1
- 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
COMMIT_SHA="${{ github.sha }}"
ARCHIVE_NAME="pwsp-${COMMIT_SHA}-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
zip -j "$ARCHIVE_NAME" "${FILES[@]}"
- name: Upload archive as artifact
uses: actions/upload-artifact@v4
with:
name: archive
path: pwsp-*.zip
retention-days: 7
- 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) as artifacts
uses: actions/upload-artifact@v4
with:
name: deb-packages
path: target/debian/*.deb
retention-days: 7
flatpak-build:
runs-on: ubuntu-latest
container:
image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-25.08
options: --privileged
steps:
- uses: actions/checkout@v4
- name: Build Flatpak
uses: flatpak/flatpak-github-actions/flatpak-builder@v6
with:
bundle: ru.arabianq.pwsp.flatpak
manifest-path: packages/flatpak/ru.arabianq.pwsp.yaml
cache: true
branch: master
build-bundle: true
@@ -1,4 +1,4 @@
name: Release name: Release archive
permissions: permissions:
contents: write contents: write
@@ -10,20 +10,29 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
tag: tag:
description: "Tag to attach assets to (e.g. v1.0.0)" description: 'Tag to attach assets to (e.g. v1.0.0)'
required: false required: false
jobs: jobs:
prepare: build-and-release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs:
tag: ${{ steps.tag.outputs.tag }}
steps: 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 - name: Determine tag to use
id: tag id: tag
run: | run: |
set -euo pipefail set -euo pipefail
# приоритет 1: входной параметр workflow_dispatch
INPUT_TAG="${{ github.event.inputs.tag || '' }}" INPUT_TAG="${{ github.event.inputs.tag || '' }}"
if [ -n "$INPUT_TAG" ]; then if [ -n "$INPUT_TAG" ]; then
echo "Using input tag: $INPUT_TAG" echo "Using input tag: $INPUT_TAG"
@@ -31,6 +40,7 @@ jobs:
exit 0 exit 0
fi fi
# приоритет 2: если запущено событием release
EVENT_TAG="${{ github.event.release.tag_name || '' }}" EVENT_TAG="${{ github.event.release.tag_name || '' }}"
if [ -n "$EVENT_TAG" ]; then if [ -n "$EVENT_TAG" ]; then
echo "Using event tag: $EVENT_TAG" echo "Using event tag: $EVENT_TAG"
@@ -38,14 +48,16 @@ jobs:
exit 0 exit 0
fi fi
# приоритет 3: если GITHUB_REF — refs/tags/...
if [[ "${GITHUB_REF:-}" =~ ^refs/tags/(.+)$ ]]; then if [[ "${GITHUB_REF:-}" =~ ^refs/tags/(.+)$ ]]; then
echo "Using GITHUB_REF tag: ${BASH_REMATCH[1]}" echo "Using GITHUB_REF tag: ${BASH_REMATCH[1]}"
echo "tag=${BASH_REMATCH[1]}" >> $GITHUB_OUTPUT echo "tag=${BASH_REMATCH[1]}" >> $GITHUB_OUTPUT
exit 0 exit 0
fi fi
# приоритет 4: пробуем получить последний релиз через API
echo "No tag in input/event/GITHUB_REF — querying latest release via API..." echo "No tag in input/event/GITHUB_REF — querying latest release via API..."
LATEST_JSON=$(curl -sSf -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" -H "Accept: application/vnd.github+json" "https://api.github.com/repos/${{ github.repository }}/releases/latest" || true) LATEST_JSON=$(curl -sSf -H "Authorization: Bearer $GITHUB_TOKEN" -H "Accept: application/vnd.github+json" "https://api.github.com/repos/${{ github.repository }}/releases/latest" || true)
TAG_NAME=$(echo "$LATEST_JSON" | jq -r '.tag_name // empty') TAG_NAME=$(echo "$LATEST_JSON" | jq -r '.tag_name // empty')
if [ -n "$TAG_NAME" ]; then if [ -n "$TAG_NAME" ]; then
echo "Found latest release tag: $TAG_NAME" echo "Found latest release tag: $TAG_NAME"
@@ -62,30 +74,16 @@ jobs:
echo "ERROR: No tag determined. Provide a tag when running manually or ensure a release exists." echo "ERROR: No tag determined. Provide a tag when running manually or ensure a release exists."
exit 1 exit 1
linux-release:
needs: prepare
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: Checkout code at tag - name: Checkout code at tag
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
ref: ${{ needs.prepare.outputs.tag }} ref: ${{ steps.tag.outputs.tag || github.ref }}
fetch-depth: 0 fetch-depth: 0
- name: Setup Rust toolchain - name: Setup Rust toolchain
uses: actions-rs/toolchain@v1 uses: actions-rs/toolchain@v1
with: with:
toolchain: 1.94.1 toolchain: stable
- name: Extract all binary names - name: Extract all binary names
id: cargo-meta id: cargo-meta
@@ -93,6 +91,7 @@ jobs:
set -euo pipefail set -euo pipefail
BIN_NAMES=$(cargo metadata --no-deps --format-version 1 \ BIN_NAMES=$(cargo metadata --no-deps --format-version 1 \
| jq -r '.packages[0].targets[] | select(.kind[] | contains("bin")) | .name') | jq -r '.packages[0].targets[] | select(.kind[] | contains("bin")) | .name')
# сохраним построчно в выход
echo "bin_names<<EOF" >> $GITHUB_OUTPUT echo "bin_names<<EOF" >> $GITHUB_OUTPUT
echo "$BIN_NAMES" >> $GITHUB_OUTPUT echo "$BIN_NAMES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT
@@ -104,10 +103,11 @@ jobs:
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
TAG="${{ needs.prepare.outputs.tag }}" TAG="${{ steps.tag.outputs.tag }}"
ARCHIVE_NAME="pwsp-${TAG}-linux-x64.zip" ARCHIVE_NAME="pwsp-${TAG}-linux-x64.zip"
echo "Creating archive: $ARCHIVE_NAME" echo "Creating archive: $ARCHIVE_NAME"
# читаем построчно список бинарников и формируем массив файлов
FILES=() FILES=()
while IFS= read -r BIN; do while IFS= read -r BIN; do
[ -z "$BIN" ] && continue [ -z "$BIN" ] && continue
@@ -119,6 +119,7 @@ jobs:
exit 1 exit 1
fi fi
# проверим, что все бинарники действительно есть
for f in "${FILES[@]}"; do for f in "${FILES[@]}"; do
if [ ! -f "$f" ]; then if [ ! -f "$f" ]; then
echo "Error: expected binary not found: $f" >&2 echo "Error: expected binary not found: $f" >&2
@@ -127,57 +128,13 @@ jobs:
echo "Will add: $f" echo "Will add: $f"
done done
# создаём архив с бинарниками внутри как просто pwsp-gui, pwsp-daemon, pwsp-cli
zip -j "$ARCHIVE_NAME" "${FILES[@]}" zip -j "$ARCHIVE_NAME" "${FILES[@]}"
- name: Upload release archive - name: Upload release archive
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
tag_name: ${{ needs.prepare.outputs.tag }} tag_name: ${{ steps.tag.outputs.tag }}
files: | files: |
pwsp-*.zip pwsp-*.zip
- 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: ${{ needs.prepare.outputs.tag }}
files: |
target/debian/*.deb
flatpak-release:
needs: prepare
runs-on: ubuntu-latest
container:
image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-25.08
options: --privileged
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.prepare.outputs.tag }}
- name: Build Flatpak
uses: flatpak/flatpak-github-actions/flatpak-builder@v6
with:
bundle: ru.arabianq.pwsp.flatpak
manifest-path: packages/flatpak/ru.arabianq.pwsp.yaml
cache: true
branch: master
build-bundle: true
- name: Upload Flatpak to release
uses: softprops/action-gh-release@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
tag_name: ${{ needs.prepare.outputs.tag }}
files: ru.arabianq.pwsp.flatpak
+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
-2
View File
@@ -1,4 +1,2 @@
/target /target
.idea .idea
packages/aur/bin/.git
packages/aur/standart/.git
View File
Generated
+956 -1560
View File
File diff suppressed because it is too large Load Diff
+16 -63
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "pwsp" name = "pwsp"
version = "1.7.3" version = "1.0.2"
edition = "2024" edition = "2024"
authors = ["arabian"] authors = ["arabian"]
description = "PWSP lets you play audio files through your microphone. Has both CLI and GUI clients." description = "PWSP lets you play audio files through your microphone. Has both CLI and GUI clients."
@@ -12,46 +12,23 @@ keywords = ["soundpad", "pipewire", "linux", "cli", "gui"]
[dependencies] [dependencies]
tokio = { version = "1.52.1", features = ["full"] } tokio = { version = "1.47.1", features = ["full"] }
futures = { version = "0.3.31", features = ["thread-pool"] }
async-trait = "0.1.89" async-trait = "0.1.89"
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149" serde_json = "1.0.145"
clap = { version = "4.6.1", default-features = false, features = [ clap = { version = "4.5.48", default-features = false, features = ["std", "suggestions", "help", "usage", "error-context", "derive"] }
"std",
"suggestions",
"help",
"usage",
"error-context",
"derive",
] }
dirs = "6.0.0" dirs = "6.0.0"
itertools = "0.14.0"
rodio = { version = "0.22.2", default-features = false, features = [ rodio = { version = "0.21.1", default-features = false, features = ["symphonia-all", "playback"] }
"symphonia-all",
"playback",
] }
pipewire = "0.9.2" pipewire = "0.9.2"
evdev = { version = "0.13.2", features = ["tokio"] } rfd = "0.15.4"
rfd = { version = "0.17.2", default-features = false, features = [
"xdg-portal",
] }
opener = { version = "0.8.4", features = ["reveal"] }
egui = { version = "0.34.1", default-features = false, features = [ egui = { version = "0.32.3", default-features = false, features = ["default_fonts", "rayon"] }
"default_fonts", eframe = { version = "0.32.3", default-features = false, features = ["default_fonts", "glow", "x11", "wayland"] }
"rayon", egui_material_icons = "0.4.0"
] }
eframe = { version = "0.34.1", default-features = false, features = [
"default_fonts",
"glow",
"x11",
"wayland",
] }
egui_material_icons = "0.6.0"
egui_dnd = "0.15.0"
[[bin]] [[bin]]
name = "pwsp-daemon" name = "pwsp-daemon"
@@ -74,34 +51,10 @@ panic = "abort"
[package.metadata.deb] [package.metadata.deb]
assets = [ assets = [
[ ["target/release/pwsp-daemon", "usr/bin/", "755"],
"target/release/pwsp-daemon", ["target/release/pwsp-cli", "usr/bin/", "755"],
"usr/bin/", ["target/release/pwsp-gui", "usr/bin/", "755"],
"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"],
"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",
],
] ]
+8 -38
View File
@@ -24,11 +24,6 @@ chats on platforms like **Discord, Zoom, or Teamspeak**.
* **Position slider** to fast-forward or rewind the audio. * **Position slider** to fast-forward or rewind the audio.
* **Persistent Configuration**: The list of added directories and your selected audio output device are saved * **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. automatically, so you won't need to reconfigure them every time you launch the application.
* **Collapsible Audio Tracks**: You can collapse every audio track to save space.
* **Drag and Drop Directories**: Reorder your sound directories easily using drag and drop.
* **Automatic Device Detection**: PWSP automatically detects when an input device is connected or disconnected and handles linking/unlinking.
* **Global Hotkeys**: Assign custom keyboard shortcuts to any sound file (or action) to trigger playback instantly, even when the application is not in focus.
# **⚙️ How It Works** # **⚙️ How It Works**
@@ -40,8 +35,8 @@ three main components:
* Creating and managing virtual audio devices. * Creating and managing virtual audio devices.
* Linking these devices within the PipeWire graph. * Linking these devices within the PipeWire graph.
* Handling all audio playback. * Handling all audio playback.
* **UnixSocket**. This is how you interact with your sound collection, control playback, and configure settings. * **pwsp-gui**: This is the graphical user interface. It acts as a client that communicates with pwsp-daemon via a *
* **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 * **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. without a GUI, allowing for scripting or quick command-based actions.
@@ -49,10 +44,10 @@ three main components:
## **Pre-built Packages** ## **Pre-built Packages**
You can download pre-built binaries and .deb packages from You can download pre-built binaries, .deb and .rpm packages from
the [releases page](https://github.com/arabianq/pipewire-soundpad/releases). the [releases page](https://github.com/arabianq/pipewire-soundpad/releases).
## **Fedora Linux (and derivatives)** ## **Fedora Linux**
If you're using Fedora, you can install PWSP from a dedicated repository using DNF. If you're using Fedora, you can install PWSP from a dedicated repository using DNF.
@@ -78,7 +73,7 @@ sudo dnf install pwsp
There is pwsp package in AUR. There is pwsp package in AUR.
You can install it using yay, paru or any other AUR helper. You can install it using yay, paru or any other AUR helper.
```bash ```bash
paru pwsp-bin # or paru pwsp to build it locally paru pwsp
``` ```
## **Installing using cargo** ## **Installing using cargo**
@@ -142,12 +137,12 @@ You can start the daemon from the terminal or enable the systemd service for aut
### **Using the GUI** ### **Using the GUI**
1. **Add Sounds**: Click the **"+"** button and select a folder containing your audio files. The application 1. **Add Sounds**: Click the **"Add Directory"** button and select a folder containing your audio files. The application
will automatically list all supported files. will automatically list all supported files.
2. **Select Microphone**: In the main application window, select your microphone. PWSP will automatically 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**. 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 3. **Playback**: Click on a file in the list to load it, then use the **"Play"** and **"Pause"** buttons to control
playback. You can also play single file once using **"Play File"** button. playback.
### **Using the CLI** ### **Using the CLI**
@@ -178,28 +173,6 @@ pwsp-cli --help
pwsp-cli set position 20 pwsp-cli set position 20
``` ```
### **Hotkeys & Controls**
#### **Keyboard Shortcuts**
| Key | Action |
| :----------------------- | :--------------------------------------------------- |
| **Space** | Pause / Resume audio |
| **Backspace** | Stop all audio tracks |
| **Enter** | Play selected file (stops all other tracks) |
| **Ctrl + Enter** | Add selected file to playback (plays simultaneously) |
| **Shift + Enter** | Replace the last added track with the selected one |
| **I** | Open / Close settings |
| **/** | Focus search field |
| **Ctrl + ↑ / ↓** | Navigate through files |
| **Ctrl + Shift + ↑ / ↓** | Navigate through directories |
#### **Mouse Controls**
* **Left Click**: Play track (stops all other tracks).
* **Ctrl + Left Click**: Add track (plays simultaneously with current tracks).
* **Shift + Left Click**: Replace the last added track with the selected one.
# **🤝 Contributing** # **🤝 Contributing**
Contributions are welcome\! If you have ideas for improvements or find a bug, feel free to create Contributions are welcome\! If you have ideas for improvements or find a bug, feel free to create
@@ -210,6 +183,3 @@ a [pull request](https://github.com/arabianq/pipewire-soundpad/pulls).
This project is licensed under This project is licensed under
the [MIT License](https://github.com/arabianq/pipewire-soundpad/blob/main/LICENSE). the [MIT License](https://github.com/arabianq/pipewire-soundpad/blob/main/LICENSE).
# **🤖 AI Wiki**
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/arabianq/pipewire-soundpad)
-1
View File
@@ -1,6 +1,5 @@
[Unit] [Unit]
Description=Pipewire Soundpad Daemon Description=Pipewire Soundpad Daemon
After=pipewire.service
[Service] [Service]
ExecStart=/usr/bin/pwsp-daemon ExecStart=/usr/bin/pwsp-daemon
Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 200 KiB

-17
View File
@@ -1,17 +0,0 @@
pkgbase = pwsp-bin
pkgdesc = Lets you play audio files through your microphone (Pre-built binaries)
pkgver = 1.7.3
pkgrel = 2
url = https://github.com/arabianq/pipewire-soundpad
arch = x86_64
license = MIT
depends = pipewire
depends = alsa-lib
provides = pwsp
conflicts = pwsp
source = pwsp-bin-1.7.3.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.7.3/pwsp-v1.7.3-linux-x64.zip
source = pipewire-soundpad-1.7.3.tar.gz :: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.7.3.tar.gz
sha256sums = SKIP
sha256sums = SKIP
pkgname = pwsp-bin
-163
View File
@@ -1,163 +0,0 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
*.db
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
poetry.lock
.poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cythikaaryhon_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
#.vscode/
-32
View File
@@ -1,32 +0,0 @@
# Maintainer: Alexander Tarasov <a.tevg@ya.ru>
pkgname=pwsp-bin
_pkgname=pipewire-soundpad
pkgver=1.7.3
pkgrel=2
pkgdesc="Lets you play audio files through your microphone (Pre-built binaries)"
arch=('x86_64')
url="https://github.com/arabianq/pipewire-soundpad"
license=('MIT')
depends=('pipewire' 'alsa-lib')
provides=('pwsp')
conflicts=('pwsp')
source=("${pkgname}-${pkgver}.zip::https://github.com/arabianq/$_pkgname/releases/download/v$pkgver/pwsp-v$pkgver-linux-x64.zip"
"${_pkgname}-${pkgver}.tar.gz::https://github.com/arabianq/$_pkgname/archive/refs/tags/v$pkgver.tar.gz")
sha256sums=('SKIP'
'SKIP')
package() {
_srcsrc="${srcdir}/${_pkgname}-${pkgver}"
install -Dm755 "${srcdir}/pwsp-cli" "${pkgdir}/usr/bin/pwsp-cli"
install -Dm755 "${srcdir}/pwsp-daemon" "${pkgdir}/usr/bin/pwsp-daemon"
install -Dm755 "${srcdir}/pwsp-gui" "${pkgdir}/usr/bin/pwsp-gui"
install -Dm644 "$_srcsrc/assets/pwsp-gui.desktop" "${pkgdir}/usr/share/applications/pwsp-gui.desktop"
install -Dm644 "$_srcsrc/assets/icon.png" "${pkgdir}/usr/share/icons/hicolor/256x256/apps/icon.png"
install -Dm644 "$_srcsrc/assets/pwsp-daemon.service" "${pkgdir}/usr/lib/systemd/user/pwsp-daemon.service"
install -Dm644 "$_srcsrc/LICENSE" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
}
-16
View File
@@ -1,16 +0,0 @@
pkgbase = pwsp
pkgdesc = Lets you play audio files through your microphone
pkgver = 1.7.3
pkgrel = 1
url = https://github.com/arabianq/pipewire-soundpad
arch = any
license = MIT
makedepends = clang
makedepends = rust
makedepends = cargo
makedepends = pipewire
makedepends = alsa-lib
source = https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.7.3.tar.gz
sha256sums = SKIP
pkgname = pwsp
-163
View File
@@ -1,163 +0,0 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
*.db
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
poetry.lock
.poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cythikaaryhon_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
#.vscode/
-47
View File
@@ -1,47 +0,0 @@
# Maintainer: Alexander Tarasov <a.tevg@ya.ru>
pkgsubn=pwsp
pkgname=pwsp
pkgver=1.7.3
pkgrel=1
pkgdesc="Lets you play audio files through your microphone"
arch=('any')
url="https://github.com/arabianq/pipewire-soundpad"
license=('MIT')
makedepends=(clang rust cargo pipewire alsa-lib)
source=("$url/archive/refs/tags/v$pkgver.tar.gz")
sha256sums=('SKIP')
prepare() {
cd "${srcdir}/pipewire-soundpad-${pkgver}"
export CARGO_HOME="${srcdir}/${pkgname%}/.cargo" # Download all to src directory, not in ~/.cargo
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
}
build() {
cd "${srcdir}/pipewire-soundpad-${pkgver}"
export CARGO_ENCODED_RUSTFLAGS="--remap-path-prefix=${srcdir}=/" # Prevent warning: 'Package contains reference to $srcdir'
[[ -n "${_sccache}" ]] && export RUSTC_WRAPPER=sccache # If $_sccache not empty, build using binary cache
export CARGO_HOME="${srcdir}/${pkgname%}/.cargo" # Use downloaded earlier from src directory, not from ~/.cargo
export CARGO_TARGET_DIR=target # Place the output in target relative to the current directory
cargo build --frozen --release
}
package() {
cd "${srcdir}/pipewire-soundpad-${pkgver}"
install -Dm755 "target/release/pwsp-cli" "${pkgdir}/usr/bin/pwsp-cli"
install -Dm755 "target/release/pwsp-daemon" "${pkgdir}/usr/bin/pwsp-daemon"
install -Dm755 "target/release/pwsp-gui" "${pkgdir}/usr/bin/pwsp-gui"
install -Dm644 "assets/pwsp-gui.desktop" "${pkgdir}/usr/share/applications/pwsp-gui.desktop"
install -Dm644 "assets/icon.png" "${pkgdir}/usr/share/icons/hicolor/256x256/apps/icon.png"
install -Dm644 "assets/pwsp-daemon.service" "${pkgdir}/usr/lib/systemd/user/pwsp-daemon.service"
}
File diff suppressed because it is too large Load Diff
-19
View File
@@ -1,19 +0,0 @@
#!/bin/bash
set -e
if [ ! -f "Cargo.lock" ]; then
echo "Error: Cargo.lock not found. Please run this script from the project root."
return 1
fi
echo "Downloading flatpak-cargo-generator.py..."
curl -sLO https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/master/cargo/flatpak-cargo-generator.py
chmod +x flatpak-cargo-generator.py
echo "Generating cargo-sources.json..."
python3 flatpak-cargo-generator.py Cargo.lock -o packages/flatpak/cargo-sources.json
echo "Cleaning up..."
rm flatpak-cargo-generator.py
echo "Successfully generated packages/flatpak/cargo-sources.json"
-35
View File
@@ -1,35 +0,0 @@
#!/usr/bin/env python3
import argparse
import subprocess
if __name__ == "__main__":
parser = argparse.ArgumentParser(
prog="PWSP Flatpak",
add_help=True,
exit_on_error=True
)
subparsers = parser.add_subparsers(dest="command")
cli_parser = subparsers.add_parser("cli", add_help=False, prefix_chars=" ")
cli_parser.add_argument("args", nargs=argparse.REMAINDER, help="Arguments for pwsp-cli")
daemon_parser = subparsers.add_parser("daemon", add_help=True)
daemon_group = daemon_parser.add_mutually_exclusive_group(required=True)
daemon_group.add_argument("--start", action="store_true", help="Start pwps-daemon")
daemon_group.add_argument("--kill", action="store_true", help="Kill pwsp-daemon")
args = parser.parse_args()
command = args.command
if not command:
subprocess.Popen("pwsp-daemon")
subprocess.Popen("pwsp-gui")
else:
if command == "cli":
subprocess.Popen(["pwsp-cli"] + args.args)
elif command == "daemon":
if args.start:
subprocess.Popen("pwsp-daemon")
elif args.kill:
subprocess.Popen(["pwsp-cli", "action", "kill"])
@@ -1,9 +0,0 @@
[Desktop Entry]
Name=PWSP (Soundpad)
Comment=Let's you play audio files through you microphone
Exec=pwsp-wrapper.py %u
Icon=ru.arabianq.pwsp
Terminal=false
Type=Application
Categories=AudioVideo;Audio;
Keywords=soundpad;pipewire;audio;
@@ -1,31 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>ru.arabianq.pwsp</id>
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT</project_license>
<name>PWSP</name>
<summary>Play audio files through your microphone using PipeWire</summary>
<description>
<p>
PWSP (PipeWire Soundpad) is a tool that allows you to play audio files through your
microphone.
It features both a graphical user interface and a command-line interface.
</p>
</description>
<launchable type="desktop-id">ru.arabianq.pwsp.desktop</launchable>
<screenshots>
<screenshot type="default">
<image>https://raw.githubusercontent.com/arabianq/pipewire-soundpad/master/assets/screenshot.png</image>
</screenshot>
</screenshots>
<url type="homepage">https://pwsp.arabianq.ru</url>
<url type="bugtracker">https://github.com/arabianq/pipewire-soundpad/issues</url>
<url type="vcs-browser">https://github.com/arabianq/pipewire-soundpad</url>
<developer id="ru.arabianq">
<name>arabian</name>
</developer>
<releases>
<release version="1.7.3" date="2026-04-20" />
</releases>
<content_rating type="oars-1.1" />
</component>
-44
View File
@@ -1,44 +0,0 @@
app-id: ru.arabianq.pwsp
runtime: org.freedesktop.Platform
runtime-version: "25.08"
sdk: org.freedesktop.Sdk
sdk-extensions:
- org.freedesktop.Sdk.Extension.rust-stable
- org.freedesktop.Sdk.Extension.llvm20
command: pwsp-wrapper.py
finish-args:
- --share=ipc
- --socket=fallback-x11
- --socket=wayland
- --socket=pulseaudio
- --filesystem=xdg-run/pipewire-0
- --filesystem=xdg-run/pwsp:create
- --filesystem=xdg-run/app/ru.arabianq.pwsp:create
- --filesystem=home
- --device=all
- --device=dri
- --talk-name=org.freedesktop.portal.Desktop
- --talk-name=org.freedesktop.portal.Documents
build-options:
append-path: /usr/lib/sdk/rust-stable/bin:/usr/lib/sdk/llvm20/bin
env:
CARGO_HOME: /run/build/pwsp/cargo
LIBCLANG_PATH: /usr/lib/sdk/llvm20/lib
modules:
- name: pwsp
buildsystem: simple
build-commands:
- export CARGO_HOME=$PWD/cargo && cargo build --release --offline
- install -Dm755 target/release/pwsp-daemon /app/bin/pwsp-daemon
- install -Dm755 target/release/pwsp-cli /app/bin/pwsp-cli
- install -Dm755 target/release/pwsp-gui /app/bin/pwsp-gui
- install -Dm755 packages/flatpak/pwsp-wrapper.py /app/bin/pwsp-wrapper.py
- install -Dm644 assets/icon.png /app/share/icons/hicolor/256x256/apps/ru.arabianq.pwsp.png
- install -Dm644 packages/flatpak/ru.arabianq.pwsp.desktop /app/share/applications/ru.arabianq.pwsp.desktop
- install -Dm644 packages/flatpak/ru.arabianq.pwsp.metainfo.xml /app/share/metainfo/ru.arabianq.pwsp.metainfo.xml
sources:
- type: dir
path: ../../
- cargo-sources.json
+1 -1
View File
@@ -4,7 +4,7 @@
%global cargo_install_lib 0 %global cargo_install_lib 0
Name: pwsp Name: pwsp
Version: 1.7.3 Version: 1.0.2
Release: %autorelease Release: %autorelease
Summary: Lets you play audio files through your microphone Summary: Lets you play audio files through your microphone
+29 -129
View File
@@ -14,17 +14,17 @@ struct Cli {
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
enum Commands { enum Commands {
/// Perform an action (ping, pause, resume, toggle-pause, stop, play) /// Perform an action (ping, pause, resume, stop, play)
Action { Action {
#[clap(subcommand)] #[clap(subcommand)]
action: Actions, action: Actions,
}, },
/// Get information from the player (is paused, volume, position, duration, state, current-file-path, input, inputs) /// Get information from the player (is paused, volume, position, state)
Get { Get {
#[clap(subcommand)] #[clap(subcommand)]
parameter: GetCommands, parameter: GetCommands,
}, },
/// Set information in the player (volume, position, input) /// Set information in the player (volume, position)
Set { Set {
#[clap(subcommand)] #[clap(subcommand)]
parameter: SetCommands, parameter: SetCommands,
@@ -35,45 +35,14 @@ enum Commands {
enum Actions { enum Actions {
/// Ping the daemon /// Ping the daemon
Ping, Ping,
/// Kill the daemon
Kill,
/// Pause audio playback /// Pause audio playback
Pause { Pause,
#[clap(short, long)]
id: Option<u32>,
},
/// Resume audio playback /// Resume audio playback
Resume { Resume,
#[clap(short, long)]
id: Option<u32>,
},
/// Toggle pause
TogglePause {
#[clap(short, long)]
id: Option<u32>,
},
/// Stop audio playback and clear the queue /// Stop audio playback and clear the queue
Stop { Stop,
#[clap(short, long)]
id: Option<u32>,
},
/// Play a file /// Play a file
Play { Play { file_path: PathBuf },
file_path: PathBuf,
#[clap(short, long)]
concurrent: bool,
},
/// Toggle loop
ToggleLoop {
#[clap(short, long)]
id: Option<u32>,
},
/// Play a sound by hotkey slot name
PlayHotkey { slot: String },
/// Remove the hotkey slot
ClearHotkey { slot: String },
/// Clear the key chord for a hotkey slot
ClearHotkeyKey { slot: String },
} }
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
@@ -81,68 +50,29 @@ enum GetCommands {
/// Check if the player is paused /// Check if the player is paused
IsPaused, IsPaused,
/// Playback volume /// Playback volume
Volume { Volume,
#[clap(short, long)] /// Playback position
id: Option<u32>, Position,
},
/// Playback position (in seconds)
Position {
#[clap(short, long)]
id: Option<u32>,
},
/// Duration of the current file /// Duration of the current file
Duration { Duration,
#[clap(short, long)] /// Player state
id: Option<u32>,
},
/// Player state (Playing, Paused or Stopped)
State, State,
/// Get all playing tracks /// Current playing file path
Tracks, CurrentFilePath,
/// Current audio input /// Current audio input
Input, Input,
/// All audio inputs /// All audio inputs
Inputs, Inputs,
/// Version of the daemon
DaemonVersion,
/// Full player state
FullState,
/// All hotkey slots
Hotkeys,
} }
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
enum SetCommands { enum SetCommands {
/// Playback volume /// Playback volume
Volume { Volume { volume: f32 },
volume: f32, /// Playback position
#[clap(short, long)] Position { position: f32 },
id: Option<u32>, /// Input
},
/// Playback position (in seconds)
Position {
position: f32,
#[clap(short, long)]
id: Option<u32>,
},
/// Audio input id (see pwsp-cli get inputs)
Input { name: String }, Input { name: String },
/// Enable or disable loop (true or false)
Loop {
enabled: String,
#[clap(short, long)]
id: Option<u32>,
},
/// Assign a sound file to a hotkey slot
Hotkey { slot: String, file_path: PathBuf },
/// Set the key chord for a hotkey slot (e.g. "Ctrl+Alt+1")
HotkeyKey { slot: String, key_chord: String },
/// Atomically set the action and key chord for a hotkey slot
HotkeyActionAndKey {
slot: String,
action: String,
key_chord: String,
},
} }
#[tokio::main] #[tokio::main]
@@ -154,59 +84,29 @@ async fn main() -> Result<(), Box<dyn Error>> {
let request = match cli.command { let request = match cli.command {
Commands::Action { action } => match action { Commands::Action { action } => match action {
Actions::Ping => Request::ping(), Actions::Ping => Request::ping(),
Actions::Kill => Request::kill(), Actions::Pause => Request::pause(),
Actions::Pause { id } => Request::pause(id), Actions::Resume => Request::resume(),
Actions::Resume { id } => Request::resume(id), Actions::Stop => Request::stop(),
Actions::TogglePause { id } => Request::toggle_pause(id), Actions::Play { file_path } => Request::play(file_path.to_str().unwrap()),
Actions::Stop { id } => Request::stop(id),
Actions::Play {
file_path,
concurrent,
} => Request::play(&file_path.to_string_lossy(), concurrent),
Actions::ToggleLoop { id } => Request::toggle_loop(id),
Actions::PlayHotkey { slot } => Request::play_hotkey(&slot),
Actions::ClearHotkey { slot } => Request::clear_hotkey(&slot),
Actions::ClearHotkeyKey { slot } => Request::clear_hotkey_key(&slot),
}, },
Commands::Get { parameter } => match parameter { Commands::Get { parameter } => match parameter {
GetCommands::IsPaused => Request::get_is_paused(), GetCommands::IsPaused => Request::get_is_paused(),
GetCommands::Volume { id } => Request::get_volume(id), GetCommands::Volume => Request::get_volume(),
GetCommands::Position { id } => Request::get_position(id), GetCommands::Position => Request::get_position(),
GetCommands::Duration { id } => Request::get_duration(id), GetCommands::Duration => Request::get_duration(),
GetCommands::State => Request::get_state(), GetCommands::State => Request::get_state(),
GetCommands::Tracks => Request::get_tracks(), GetCommands::CurrentFilePath => Request::get_current_file_path(),
GetCommands::Input => Request::get_input(), GetCommands::Input => Request::get_input(),
GetCommands::Inputs => Request::get_inputs(), GetCommands::Inputs => Request::get_inputs(),
GetCommands::DaemonVersion => Request::get_daemon_version(),
GetCommands::FullState => Request::get_full_state(),
GetCommands::Hotkeys => Request::get_hotkeys(),
}, },
Commands::Set { parameter } => match parameter { Commands::Set { parameter } => match parameter {
SetCommands::Volume { volume, id } => Request::set_volume(volume, id), SetCommands::Volume { volume } => Request::set_volume(volume),
SetCommands::Position { position, id } => Request::seek(position, id), SetCommands::Position { position } => Request::seek(position),
SetCommands::Input { name } => Request::set_input(&name), SetCommands::Input { name } => Request::set_input(&name),
SetCommands::Loop { enabled, id } => Request::set_loop(&enabled, id),
SetCommands::Hotkey { slot, file_path } => {
Request::set_hotkey(&slot, &file_path.to_string_lossy())
}
SetCommands::HotkeyKey { slot, key_chord } => {
Request::set_hotkey_key(&slot, &key_chord)
}
SetCommands::HotkeyActionAndKey {
slot,
action,
key_chord,
} => Request::set_hotkey_action_and_key(
&slot,
&serde_json::from_str::<Request>(&action)?,
&key_chord,
),
}, },
}; };
let response = make_request(request) let response = make_request(request).await?;
.await
.map_err(|e| e as Box<dyn Error>)?;
println!("{} : {}", response.status, response.message); println!("{} : {}", response.status, response.message);
Ok(()) Ok(())
+11 -98
View File
@@ -1,21 +1,18 @@
use pwsp::{ use pwsp::{
types::socket::{MAX_MESSAGE_SIZE, Request, Response}, types::socket::{Request, Response},
utils::{ utils::{
commands::parse_command, commands::parse_command,
daemon::{ daemon::{
create_runtime_dir, get_audio_player, get_daemon_config, get_runtime_dir, create_runtime_dir, get_audio_player, get_daemon_config, get_runtime_dir,
is_daemon_running, is_daemon_running, link_player_to_virtual_mic,
}, },
global_hotkeys::start_global_hotkey_listener,
pipewire::create_virtual_mic, pipewire::create_virtual_mic,
}, },
}; };
use std::os::unix::fs::PermissionsExt; use std::{error::Error, fs};
use std::{error::Error, fs, time::Duration};
use tokio::{ use tokio::{
io::{AsyncReadExt, AsyncWriteExt}, io::{AsyncReadExt, AsyncWriteExt},
net::UnixListener, net::UnixListener,
time::sleep,
}; };
#[tokio::main] #[tokio::main]
@@ -28,13 +25,8 @@ async fn main() -> Result<(), Box<dyn Error>> {
get_daemon_config(); // Initialize daemon config get_daemon_config(); // Initialize daemon config
create_virtual_mic()?; create_virtual_mic()?;
if let Err(err) = get_audio_player().await { get_audio_player().await; // Initialize audio player
eprintln!("Failed to initialize audio player: {}", err); link_player_to_virtual_mic().await?;
} // Initialize audio player
tokio::spawn(async {
start_global_hotkey_listener().await;
});
let runtime_dir = get_runtime_dir(); let runtime_dir = get_runtime_dir();
@@ -42,41 +34,16 @@ async fn main() -> Result<(), Box<dyn Error>> {
lock_file.lock()?; lock_file.lock()?;
let socket_path = runtime_dir.join("daemon.sock"); let socket_path = runtime_dir.join("daemon.sock");
if let Err(e) = fs::remove_file(&socket_path) if fs::metadata(&socket_path).is_ok() {
&& e.kind() != std::io::ErrorKind::NotFound fs::remove_file(&socket_path)?;
{
return Err(e.into());
} }
let listener = UnixListener::bind(&socket_path)?; let listener = UnixListener::bind(&socket_path)?;
fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600))?;
println!( println!(
"Daemon started. Listening on {}", "Daemon started. Listening on {}",
socket_path.to_str().unwrap_or_default() socket_path.to_str().unwrap_or_default()
); );
let commands_loop_handle = tokio::spawn(async {
commands_loop(listener).await.ok();
});
let player_loop_handle = tokio::spawn(async {
player_loop().await;
});
tokio::select! {
_ = commands_loop_handle => {
eprint!("Commands loop was finished, stopping program...");
}
_ = player_loop_handle => {
eprint!("Audio Player loop was finished, stopping program...");
}
}
Ok(())
}
async fn commands_loop(listener: UnixListener) -> Result<(), Box<dyn Error>> {
loop { loop {
let (mut stream, _addr) = listener.accept().await?; let (mut stream, _addr) = listener.accept().await?;
@@ -90,35 +57,14 @@ async fn commands_loop(listener: UnixListener) -> Result<(), Box<dyn Error>> {
let request_len = u32::from_le_bytes(len_bytes) as usize; let request_len = u32::from_le_bytes(len_bytes) as usize;
if request_len > MAX_MESSAGE_SIZE {
eprintln!(
"Failed to read message from client: request too large ({} bytes)!",
request_len
);
return;
}
let mut buffer = vec![0u8; request_len]; let mut buffer = vec![0u8; request_len];
if stream.read_exact(&mut buffer).await.is_err() { if stream.read_exact(&mut buffer).await.is_err() {
eprintln!("Failed to read message from client!"); eprintln!("Failed to read message from client!");
return; return;
} }
let request: Request = match serde_json::from_slice(&buffer) { let request: Request = serde_json::from_slice(&buffer).unwrap();
Ok(req) => req, println!("Received request: {:?}", request);
Err(err) => {
let response =
Response::new(false, format!("Failed to parse request: {}", err));
let response_data = match serde_json::to_vec(&response) {
Ok(data) => data,
Err(_) => return, // Should not happen with this simple Response
};
let response_len = response_data.len() as u32;
let _ = stream.write_all(&response_len.to_le_bytes()).await;
let _ = stream.write_all(&response_data).await;
return;
}
};
// ---------- Read request (end) ---------- // ---------- Read request (end) ----------
// ---------- Generate response (start) ---------- // ---------- Generate response (start) ----------
@@ -132,13 +78,7 @@ async fn commands_loop(listener: UnixListener) -> Result<(), Box<dyn Error>> {
// ---------- Generate response (end) ---------- // ---------- Generate response (end) ----------
// ---------- Send response (start) ---------- // ---------- Send response (start) ----------
let response_data = match serde_json::to_vec(&response) { let response_data = serde_json::to_vec(&response).unwrap();
Ok(data) => data,
Err(err) => {
eprintln!("Failed to serialize response: {}", err);
return;
}
};
let response_len = response_data.len() as u32; let response_len = response_data.len() as u32;
if stream.write_all(&response_len.to_le_bytes()).await.is_err() { if stream.write_all(&response_len.to_le_bytes()).await.is_err() {
@@ -149,35 +89,8 @@ async fn commands_loop(listener: UnixListener) -> Result<(), Box<dyn Error>> {
eprintln!("Failed to write response to client!"); eprintln!("Failed to write response to client!");
return; return;
} }
println!("Sent response: {:?}", response);
// ---------- Send response (end) ---------- // ---------- Send response (end) ----------
if response.status && response.message.eq("killed") {
std::process::exit(0);
}
}); });
} }
} }
async fn player_loop() {
let mut device_check_counter: u32 = 0;
loop {
let is_idle = match get_audio_player().await {
Ok(player_mutex) => {
let mut audio_player = player_mutex.lock().await;
let check_devices = device_check_counter == 0;
audio_player.update(check_devices).await;
audio_player.tracks.is_empty()
}
Err(_err) => true,
};
if is_idle {
device_check_counter = 0;
sleep(Duration::from_secs(2)).await;
} else {
// Check devices every ~5 seconds (50 * 100ms) while playing
device_check_counter = (device_check_counter + 1) % 50;
sleep(Duration::from_millis(100)).await;
}
}
}
+105 -693
View File
@@ -1,52 +1,14 @@
use crate::gui::SoundpadGui; use crate::gui::SoundpadGui;
use egui::{ use egui::{
Align, AtomExt, Button, CollapsingHeader, Color32, ComboBox, CursorIcon, FontFamily, Grid, AtomExt, Button, Color32, ComboBox, FontFamily, Label, RichText, ScrollArea, Slider, TextEdit,
Label, Layout, RichText, ScrollArea, Sense, Slider, TextEdit, Ui, Vec2, Ui, Vec2,
}; };
use egui_dnd::dnd; use egui_material_icons::icons;
use egui_material_icons::icons::*; use pwsp::types::audio_player::PlayerState;
use pwsp::types::socket::Request; use pwsp::utils::gui::format_time_pair;
use pwsp::types::{audio_player::TrackInfo, gui::AppState}; use std::{error::Error, path::PathBuf};
use pwsp::utils::gui::{format_time_pair, make_request_async};
use std::{
path::{Path, PathBuf},
time::Instant,
};
enum TrackAction {
Pause(u32),
Resume(u32),
ToggleLoop(u32),
Stop(u32),
}
enum HotkeyAction {
Remove(String),
Capture(String),
ClearChord(String),
Play(String),
}
impl SoundpadGui { impl SoundpadGui {
fn get_volume_icon(volume: f32) -> &'static str {
if volume > 0.7 {
ICON_VOLUME_UP.codepoint
} else if volume <= 0.0 {
ICON_VOLUME_OFF.codepoint
} else if volume < 0.3 {
ICON_VOLUME_MUTE.codepoint
} else {
ICON_VOLUME_DOWN.codepoint
}
}
pub fn draw(&mut self, ui: &mut Ui) {
self.draw_header(ui);
self.draw_body(ui);
ui.separator();
self.draw_footer(ui);
}
pub fn draw_waiting_for_daemon(&mut self, ui: &mut Ui) { pub fn draw_waiting_for_daemon(&mut self, ui: &mut Ui) {
ui.centered_and_justified(|ui| { ui.centered_and_justified(|ui| {
ui.label( ui.label(
@@ -57,38 +19,12 @@ impl SoundpadGui {
}); });
} }
pub fn draw_hotkey_capture(&mut self, ui: &mut Ui) {
ui.vertical_centered(|ui| {
ui.add_space(ui.available_height() / 3.0);
ui.label(
RichText::new("Press a key combination (e.g. Ctrl+Alt+1)")
.size(18.0)
.color(Color32::YELLOW)
.monospace(),
);
ui.add_space(10.0);
let target = if let Some(slot) = &self.app_state.assigning_hotkey_slot {
format!("for slot '{}'", slot)
} else if let Some(path) = &self.app_state.assigning_hotkey_for_file {
format!(
"for '{}'",
path.file_name().unwrap_or_default().to_string_lossy()
)
} else {
String::new()
};
ui.label(RichText::new(target).size(16.0));
ui.add_space(10.0);
ui.label("Press Escape to cancel");
});
}
pub fn draw_settings(&mut self, ui: &mut Ui) { pub fn draw_settings(&mut self, ui: &mut Ui) {
ui.vertical(|ui| { ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 5.0; ui.spacing_mut().item_spacing.y = 5.0;
// --------- Back Button and Title ---------- // --------- Back Button and Title ----------
ui.horizontal_top(|ui| { ui.horizontal_top(|ui| {
let back_button = Button::new(ICON_ARROW_BACK).frame(false); let back_button = Button::new(icons::ICON_ARROW_BACK).frame(false);
let back_button_response = ui.add(back_button); let back_button_response = ui.add(back_button);
if back_button_response.clicked() { if back_button_response.clicked() {
self.app_state.show_settings = false; self.app_state.show_settings = false;
@@ -112,291 +48,32 @@ impl SoundpadGui {
&mut self.config.save_scale_factor, &mut self.config.save_scale_factor,
"Always remember UI scale factor", "Always remember UI scale factor",
); );
let pause_on_exit_response = ui.checkbox(
&mut self.config.pause_on_exit,
"Pause audio playback when the window is closed",
);
if save_volume_response.changed() if save_volume_response.changed()
|| save_input_response.changed() || save_input_response.changed()
|| save_scale_response.changed() || save_scale_response.changed()
|| pause_on_exit_response.changed()
{ {
self.config.save_to_file().ok(); self.config.save_to_file().ok();
} }
// -------------------------------- // --------------------------------
ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
ui.label(format!("GUI version: {}", env!("CARGO_PKG_VERSION")));
});
}); });
} }
pub fn draw_hotkeys(&mut self, ui: &mut Ui) { pub fn draw(&mut self, ui: &mut Ui) -> Result<(), Box<dyn Error>> {
let area_size = ui.available_size(); self.draw_header(ui);
ui.vertical(|ui| { self.draw_body(ui);
ui.set_min_width(area_size.x);
ui.set_min_height(area_size.y);
ui.spacing_mut().item_spacing.y = 5.0;
// Header
ui.horizontal_top(|ui| {
let back_button = Button::new(ICON_ARROW_BACK).frame(false);
if ui.add(back_button).clicked() {
self.app_state.show_hotkeys = false;
}
ui.add_space(ui.available_width() / 2.0 - 40.0);
ui.label(RichText::new("Hotkeys").color(Color32::WHITE).monospace());
});
ui.separator(); ui.separator();
self.draw_footer(ui);
// Search and Add Command Ok(())
ui.horizontal(|ui| {
ui.menu_button(format!("{} Add Command", ICON_ADD.codepoint), |ui| {
let mut selected_cmd = None;
if ui.button("Toggle Pause").clicked() {
selected_cmd = Some(("cmd_toggle_pause", Request::toggle_pause(None)));
}
if ui.button("Stop Playback").clicked() {
selected_cmd = Some(("cmd_stop", Request::stop(None)));
}
if ui.button("Pause Playback").clicked() {
selected_cmd = Some(("cmd_pause", Request::pause(None)));
}
if ui.button("Resume Playback").clicked() {
selected_cmd = Some(("cmd_resume", Request::resume(None)));
}
if ui.button("Toggle Loop").clicked() {
selected_cmd = Some(("cmd_toggle_loop", Request::toggle_loop(None)));
}
if let Some((slot_name, req)) = selected_cmd {
make_request_async(Request::set_hotkey_action(slot_name, &req));
self.app_state
.hotkey_config
.set_slot(slot_name.to_string(), req);
self.app_state.assigning_hotkey_slot = Some(slot_name.to_string());
self.app_state.hotkey_capture_active = true;
ui.close();
}
});
ui.add_space(10.0);
ui.add_sized(
[ui.available_width(), 22.0],
TextEdit::singleline(&mut self.app_state.hotkey_search_query)
.hint_text("Search hotkeys..."),
);
});
ui.separator();
ui.add_space(5.0);
let conflicts = self.app_state.hotkey_config.find_conflicts();
let conflict_slots: std::collections::HashSet<&str> =
conflicts.into_iter().flat_map(|(a, b)| [a, b]).collect();
let search = self.app_state.hotkey_search_query.to_lowercase();
// Slots table
let mut action: Option<HotkeyAction> = None;
let area_size = ui.available_size();
ScrollArea::vertical().show(ui, |ui| {
ui.set_min_width(area_size.x);
Grid::new("hotkeys_grid")
.striped(true)
.num_columns(4)
.max_col_width(area_size.x)
.min_col_width(area_size.x / 4.0)
.spacing([40.0, 10.0])
.show(ui, |ui| {
// Table header
ui.label(
RichText::new("Slot")
.strong()
.monospace()
.color(Color32::LIGHT_GRAY),
);
ui.label(
RichText::new("Sound")
.strong()
.monospace()
.color(Color32::LIGHT_GRAY),
);
ui.label(
RichText::new("Key Chord")
.strong()
.monospace()
.color(Color32::LIGHT_GRAY),
);
ui.label(
RichText::new("Actions")
.strong()
.monospace()
.color(Color32::LIGHT_GRAY),
);
ui.end_row();
let slots: Vec<_> = self
.app_state
.hotkey_config
.slots
.iter()
.filter(|s| {
if search.is_empty() {
return true;
}
s.slot.to_lowercase().contains(&search)
|| format!("{:?}", s.action).to_lowercase().contains(&search)
|| s.key_chord
.as_deref()
.unwrap_or("")
.to_lowercase()
.contains(&search)
})
.cloned()
.collect();
for slot in &slots {
ui.horizontal(|ui| {
// Conflict badge
if conflict_slots.contains(slot.slot.as_str()) {
ui.label(
RichText::new(ICON_WARNING.codepoint)
.color(Color32::from_rgb(255, 165, 0)),
)
.on_hover_text("Key chord conflict");
}
// Slot name
let slot_text = RichText::new(&slot.slot).monospace();
ui.label(slot_text);
});
// Action description
let action_name = match slot.action.name.as_str() {
"play" => {
if let Some(file_path_str) = slot.action.args.get("file_path") {
Path::new(file_path_str)
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string()
} else {
"Play".to_string()
}
}
"toggle_pause" => "Toggle Pause".to_string(),
"pause" => "Pause Playback".to_string(),
"resume" => "Resume Playback".to_string(),
"stop" => "Stop Playback".to_string(),
"toggle_loop" => "Toggle Loop".to_string(),
other => other.to_string(),
};
ui.add(Label::new(RichText::new(action_name).monospace()).truncate());
// Key chord
let chord_text = slot.key_chord.as_deref().unwrap_or("(none)");
ui.label(RichText::new(chord_text).monospace().color(
if slot.key_chord.is_some() {
Color32::from_rgb(100, 200, 100)
} else {
Color32::GRAY
},
));
ui.horizontal(|ui| {
// Delete button
if ui
.add(Button::new(ICON_DELETE).frame(false))
.on_hover_text("Remove slot")
.clicked()
{
action = Some(HotkeyAction::Remove(slot.slot.clone()));
}
// Set key chord button
if ui
.add(Button::new(ICON_KEYBOARD).frame(false))
.on_hover_text("Set key chord")
.clicked()
{
action = Some(HotkeyAction::Capture(slot.slot.clone()));
}
// Clear key chord
if slot.key_chord.is_some()
&& ui
.add(Button::new(ICON_BACKSPACE).frame(false))
.on_hover_text("Clear key chord")
.clicked()
{
action = Some(HotkeyAction::ClearChord(slot.slot.clone()));
}
// Play button
if ui
.add(Button::new(ICON_PLAY_ARROW).frame(false))
.on_hover_text("Play")
.clicked()
{
action = Some(HotkeyAction::Play(slot.slot.clone()));
}
});
ui.end_row();
}
if slots.is_empty() {
ui.label("No hotkey slots configured.");
ui.label("");
ui.label("");
ui.label("");
ui.end_row();
}
});
});
if let Some(action) = action {
match action {
HotkeyAction::Remove(slot) => {
make_request_async(Request::clear_hotkey(&slot));
self.app_state.hotkey_config.remove_slot(&slot);
}
HotkeyAction::Capture(slot) => {
self.app_state.assigning_hotkey_slot = Some(slot);
self.app_state.hotkey_capture_active = true;
}
HotkeyAction::ClearChord(slot) => {
make_request_async(Request::clear_hotkey_key(&slot));
self.app_state.hotkey_config.set_key_chord(&slot, None);
}
HotkeyAction::Play(slot) => {
self.play_hotkey_slot(&slot);
}
}
}
});
} }
fn draw_header(&mut self, ui: &mut Ui) { fn draw_header(&mut self, ui: &mut Ui) {
ui.vertical_centered_justified(|ui| { ui.vertical_centered_justified(|ui| {
if self.audio_player_state.tracks.is_empty() { // Current file name
ui.label("No tracks playing"); ui.label(
return;
}
let tracks = self.audio_player_state.tracks.clone();
let mut action = None;
for track in tracks {
CollapsingHeader::new(
RichText::new( RichText::new(
track self.audio_player_state
.path .current_file_path
.file_stem() .file_stem()
.unwrap_or_default() .unwrap_or_default()
.to_str() .to_str()
@@ -404,181 +81,95 @@ impl SoundpadGui {
) )
.color(Color32::WHITE) .color(Color32::WHITE)
.family(FontFamily::Monospace), .family(FontFamily::Monospace),
) );
.default_open(true) // Media controls
.show(ui, |ui| { self.draw_controls(ui);
if let Some(act) = Self::draw_track_control(ui, &mut self.app_state, &track) {
action = Some(act);
}
});
ui.separator(); ui.separator();
}
if let Some(action) = action {
match action {
TrackAction::Pause(id) => self.pause(Some(id)),
TrackAction::Resume(id) => self.resume(Some(id)),
TrackAction::ToggleLoop(id) => self.toggle_loop(Some(id)),
TrackAction::Stop(id) => self.stop(Some(id)),
}
}
}); });
} }
fn draw_track_control( fn draw_controls(&mut self, ui: &mut Ui) {
ui: &mut Ui,
app_state: &mut AppState,
track: &TrackInfo,
) -> Option<TrackAction> {
let ui_state = app_state.track_ui_states.entry(track.id).or_default();
let should_update_position = !ui_state.position_dragged
&& ui_state
.ignore_position_update_until
.map(|t| Instant::now() > t)
.unwrap_or(true);
if should_update_position {
ui_state.position_slider_value = track.position;
}
let should_update_volume = !ui_state.volume_dragged
&& ui_state
.ignore_volume_update_until
.map(|t| Instant::now() > t)
.unwrap_or(true);
if should_update_volume {
ui_state.volume_slider_value = track.volume;
}
let mut action = None;
ui.horizontal_top(|ui| { ui.horizontal_top(|ui| {
// ---------- Play Button ---------- // ---------- Play Button ----------
let play_button = Button::new(if track.paused { let play_button = Button::new(match self.audio_player_state.state {
ICON_PLAY_ARROW PlayerState::Playing => icons::ICON_PAUSE,
} else { PlayerState::Paused | PlayerState::Stopped => icons::ICON_PLAY_ARROW,
ICON_PAUSE
}) })
.corner_radius(15.0); .corner_radius(15.0);
let play_button_response = ui.add_sized([30.0, 30.0], play_button); let play_button_response = ui.add_sized([30.0, 30.0], play_button);
if play_button_response.clicked() { if play_button_response.clicked() {
if track.paused { self.play_toggle();
action = Some(TrackAction::Resume(track.id));
} else {
action = Some(TrackAction::Pause(track.id));
}
}
// --------------------------------
// ---------- Loop Button ----------
let loop_button = Button::new(
RichText::new(if track.looped {
ICON_REPEAT_ONE
} else {
ICON_REPEAT
})
.size(18.0),
)
.frame(false);
let loop_button_response = ui.add_sized([15.0, 30.0], loop_button);
if loop_button_response.clicked() {
action = Some(TrackAction::ToggleLoop(track.id));
} }
// -------------------------------- // --------------------------------
// ---------- Position Slider ---------- // ---------- Position Slider ----------
let duration = track.duration.unwrap_or(1.0); let position_slider = Slider::new(
let position_slider = Slider::new(&mut ui_state.position_slider_value, 0.0..=duration) &mut self.app_state.position_slider_value,
0.0..=self.audio_player_state.duration,
)
.show_value(false) .show_value(false)
.step_by(0.01); .step_by(1.0);
let default_slider_width = ui.spacing().slider_width; let default_slider_width = ui.spacing().slider_width;
let position_slider_width = ui.available_width() let position_slider_width = ui.available_width()
- (30.0 * 3.0) - (30.0 * 3.0)
- default_slider_width - default_slider_width
- (ui.spacing().item_spacing.x * 6.0); - (ui.spacing().item_spacing.x * 5.0);
ui.spacing_mut().slider_width = position_slider_width; ui.spacing_mut().slider_width = position_slider_width;
let position_slider_response = ui.add_sized([30.0, 30.0], position_slider); let position_slider_response = ui.add_sized([30.0, 30.0], position_slider);
if position_slider_response.drag_stopped() { if position_slider_response.drag_stopped() {
ui_state.position_dragged = true; self.app_state.position_dragged = true;
} }
// -------------------------------- // --------------------------------
// ---------- Time Label ---------- // ---------- Time Label ----------
let time_label = let time_label = Label::new(
Label::new(RichText::new(format_time_pair(track.position, duration)).monospace()); 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); ui.add_sized([30.0, 30.0], time_label);
// -------------------------------- // --------------------------------
// ---------- Volume Icon ---------- // ---------- Volume Icon ----------
let volume_icon = Self::get_volume_icon(track.volume); let volume_icon = if self.audio_player_state.volume > 0.7 {
let volume_label = Label::new(RichText::new(volume_icon).size(18.0)); icons::ICON_VOLUME_UP
ui.add_sized([30.0, 30.0], volume_label) } else if self.audio_player_state.volume == 0.0 {
.on_hover_text(format!("Volume: {:.0}%", track.volume * 100.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 ---------- // ---------- Volume Slider ----------
let volume_slider = Slider::new(&mut ui_state.volume_slider_value, 0.0..=1.0) let volume_slider = Slider::new(&mut self.app_state.volume_slider_value, 0.0..=1.0)
.show_value(false) .show_value(false)
.step_by(0.01); .step_by(0.01);
ui.spacing_mut().slider_width = default_slider_width - 30.0; ui.spacing_mut().slider_width = default_slider_width;
ui.spacing_mut().item_spacing.x = 0.0; ui.spacing_mut().item_spacing.x = 0.0;
let volume_slider_response = ui.add_sized([30.0, 30.0], volume_slider); let volume_slider_response = ui.add_sized([30.0, 30.0], volume_slider);
if volume_slider_response.drag_stopped() { if volume_slider_response.drag_stopped() {
ui_state.volume_dragged = true; self.app_state.volume_dragged = true;
}
// --------------------------------
// ---------- Stop Button ---------
let stop_button = Button::new(ICON_CLOSE).frame(false);
let stop_button_response = ui.add_sized([30.0, 30.0], stop_button);
if stop_button_response.clicked() {
action = Some(TrackAction::Stop(track.id));
} }
// -------------------------------- // --------------------------------
}); });
action
} }
fn draw_body(&mut self, ui: &mut Ui) { fn draw_body(&mut self, ui: &mut Ui) {
let left_panel_width = self let dirs_size = Vec2::new(ui.available_width() / 4.0, ui.available_height() - 40.0);
.config
.left_panel_width
.max(100.0)
.min(ui.available_width() - 100.0);
let dirs_size = Vec2::new(left_panel_width, ui.available_height() - 40.0);
ui.horizontal(|ui| { ui.horizontal(|ui| {
self.draw_dirs(ui, dirs_size); self.draw_dirs(ui, dirs_size);
ui.separator();
let (rect, response) = ui.allocate_at_least(
Vec2::new(ui.spacing().item_spacing.x, ui.available_height()),
Sense::click_and_drag(),
);
if ui.is_rect_visible(rect) {
let stroke = ui.visuals().widgets.noninteractive.bg_stroke;
ui.painter().vline(rect.center().x, rect.y_range(), stroke);
}
let vertical_separator_response =
response.on_hover_and_drag_cursor(CursorIcon::ResizeHorizontal);
if vertical_separator_response.dragged() {
self.config.left_panel_width += vertical_separator_response.drag_delta().x;
self.config.left_panel_width = self.config.left_panel_width.clamp(100.0, 500.0);
}
if vertical_separator_response.drag_stopped() {
self.config.save_to_file().ok();
}
let files_size = Vec2::new(ui.available_width(), ui.available_height() - 40.0); let files_size = Vec2::new(ui.available_width(), ui.available_height() - 40.0);
self.draw_files(ui, files_size); self.draw_files(ui, files_size);
@@ -591,89 +182,37 @@ impl SoundpadGui {
ui.set_min_height(area_size.y); ui.set_min_height(area_size.y);
ScrollArea::vertical().id_salt(0).show(ui, |ui| { ScrollArea::vertical().id_salt(0).show(ui, |ui| {
ui.set_min_width(area_size.x); let mut dirs: Vec<PathBuf> = self.app_state.dirs.iter().cloned().collect();
dirs.sort();
let mut dirs = self.app_state.dirs.clone(); for path in dirs.iter() {
dnd(ui, "dnd_directories").show_vec(&mut dirs, |ui, item, handle, _state| {
let path = item.clone();
ui.horizontal(|ui| { ui.horizontal(|ui| {
handle.ui(ui, |ui| {
ui.label(ICON_DRAG_INDICATOR.codepoint);
});
let name = path let name = path
.file_name() .file_name()
.map(|s| s.to_string_lossy().to_string()) .map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| path.to_string_lossy().to_string()); .unwrap_or_else(|| path.to_string_lossy().to_string());
let mut dir_button_text = RichText::new(name.clone());
if let Some(current_dir) = &self.app_state.current_dir
&& current_dir.eq(&path)
{
dir_button_text = dir_button_text.color(Color32::WHITE);
}
let dir_button = let dir_button =
Button::new(dir_button_text.atom_max_width(area_size.x)).frame(false); Button::new(RichText::new(name).atom_max_width(area_size.x))
.frame(false);
let dir_button_response = ui.add(dir_button); let dir_button_response = ui.add(dir_button);
if dir_button_response.clicked() { if dir_button_response.clicked() {
self.open_dir(&path); self.app_state.current_dir = Some(path.clone());
} }
let delete_dir_button = Button::new(ICON_DELETE).frame(false); let delete_dir_button = Button::new(icons::ICON_DELETE).frame(false);
let delete_dir_button_response = let delete_dir_button_response =
ui.add_sized([18.0, 18.0], delete_dir_button); ui.add_sized([18.0, 18.0], delete_dir_button);
if delete_dir_button_response.clicked() { if delete_dir_button_response.clicked() {
self.app_state.dirs_to_remove.insert(path.clone()); self.remove_dir(path.clone());
}
// Context menu
dir_button_response.context_menu(|ui| {
if ui
.button(format!("{} {}", ICON_OPEN_IN_NEW.codepoint, "Show"))
.clicked()
{
self.open_dir(&path);
}
if ui
.button(format!(
"{} {}",
ICON_OPEN_IN_BROWSER.codepoint, "Open in File Manager"
))
.clicked()
&& let Err(e) = opener::open(&path)
{
eprintln!("Failed to open file manager: {}", e);
}
ui.separator();
if ui
.button(format!("{} {}", ICON_DELETE.codepoint, "Remove"))
.clicked()
{
self.app_state.dirs_to_remove.insert(path.clone());
} }
}); });
}); }
});
self.app_state.dirs = dirs;
ui.horizontal(|ui| { ui.horizontal(|ui| {
let add_dirs_button = Button::new(ICON_ADD).frame(false); let add_dir_button = egui::Button::new(icons::ICON_ADD).frame(false);
let add_dirs_button_response = ui.add_sized([18.0, 18.0], add_dirs_button); let add_dir_button_response = ui.add_sized([18.0, 18.0], add_dir_button);
if add_dirs_button_response.clicked() { if add_dir_button_response.clicked() {
self.add_dirs(); self.add_dir();
}
});
ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
let play_file_button = Button::new("Play file");
let play_file_button_response = ui.add(play_file_button);
if play_file_button_response.clicked() {
self.open_file();
} }
}); });
}); });
@@ -681,19 +220,16 @@ impl SoundpadGui {
} }
fn draw_files(&mut self, ui: &mut Ui, area_size: Vec2) { 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.vertical(|ui| {
ui.horizontal(|ui| { ui.horizontal(|ui| {
let search_field_response = ui.add_sized( ui.add_sized(
[ui.available_width(), 22.0], [ui.available_width(), 22.0],
TextEdit::singleline(&mut self.app_state.search_query).hint_text("Search..."), TextEdit::singleline(&mut self.app_state.search_query).hint_text("Search..."),
); );
if self.app_state.force_focus_search {
search_field_response.request_focus();
self.app_state.force_focus_search = false;
}
self.app_state.search_field_id = Some(search_field_response.id);
}); });
ui.separator(); ui.separator();
@@ -703,145 +239,61 @@ impl SoundpadGui {
ui.set_min_height(area_size.y); ui.set_min_height(area_size.y);
ui.vertical(|ui| { ui.vertical(|ui| {
let files = self.get_filtered_files(); if let Some(path) = self.app_state.current_dir.clone() {
for entry in path.read_dir().unwrap() {
let entry = entry.unwrap();
let entry_path = entry.path();
if entry_path.is_dir() {
continue;
}
if !extensions.contains(
&entry_path.extension().unwrap_or_default().to_str().unwrap(),
) {
continue;
}
for entry_path in files {
let file_name = entry_path let file_name = entry_path
.file_name() .file_name()
.unwrap_or_default() .unwrap()
.to_string_lossy() .to_string_lossy()
.to_string(); .to_string();
ui.horizontal(|ui| { let search_query = self
// Hotkey badge .app_state
let hotkey_badge = self.get_hotkey_badge(&entry_path); .search_query
if let Some(badge) = &hotkey_badge { .to_lowercase()
ui.label( .trim()
RichText::new(badge) .to_string();
.small()
.monospace() if !file_name.to_lowercase().contains(search_query.as_str()) {
.color(Color32::from_rgb(100, 200, 100)), continue;
);
} }
let mut file_button_text = RichText::new(&file_name); let file_button = Button::new(file_name).frame(false);
if let Some(current_file) = &self.app_state.selected_file
&& current_file.eq(&entry_path)
{
file_button_text = file_button_text.color(Color32::WHITE);
}
let file_button = Button::new(file_button_text).frame(false);
let file_button_response = ui.add(file_button); let file_button_response = ui.add(file_button);
if file_button_response.clicked() { if file_button_response.clicked() {
ui.input(|i| { self.play_file(entry_path);
if i.modifiers.ctrl {
self.play_file(&entry_path, true);
} else if i.modifiers.shift
&& let Some(last_track) =
self.audio_player_state.tracks.last()
{
self.stop(Some(last_track.id));
self.play_file(&entry_path, true);
} else {
self.play_file(&entry_path, false);
} }
});
self.app_state.selected_file = Some(entry_path.clone());
} }
// Context menu
file_button_response.context_menu(|ui| {
if ui
.button(format!("{} {}", ICON_BOLT.codepoint, "Play Solo"))
.clicked()
{
self.play_file(&entry_path, false);
self.app_state.selected_file = Some(entry_path.clone());
}
if ui
.button(format!("{} {}", ICON_ADD.codepoint, "Add New"))
.clicked()
{
self.play_file(&entry_path, true);
self.app_state.selected_file = Some(entry_path.clone());
}
if ui
.button(format!(
"{} {}",
ICON_SWAP_HORIZ.codepoint, "Replace Last"
))
.clicked()
&& let Some(last_track) = self.audio_player_state.tracks.last()
{
self.stop(Some(last_track.id));
self.play_file(&entry_path, true);
self.app_state.selected_file = Some(entry_path.clone());
}
ui.separator();
if ui
.button(format!(
"{} {}",
ICON_OPEN_IN_BROWSER.codepoint, "Show in File Manager"
))
.clicked()
&& let Err(e) = opener::reveal(&entry_path)
{
eprintln!("Failed to open file manager: {}", e);
}
ui.separator();
if ui
.button(format!(
"{} {}",
ICON_KEYBOARD.codepoint, "Assign Hotkey"
))
.clicked()
{
self.app_state.assigning_hotkey_for_file =
Some(entry_path.clone());
self.app_state.hotkey_capture_active = true;
ui.close();
}
});
});
} }
}); });
}); });
}); });
} }
fn get_hotkey_badge(&self, path: &PathBuf) -> Option<String> {
for slot in &self.app_state.hotkey_config.slots {
if slot.action.name == "play"
&& let Some(file_path_str) = slot.action.args.get("file_path")
&& Path::new(file_path_str) == path.as_path()
{
if let Some(chord) = &slot.key_chord {
return Some(format!("[{}]", chord));
} else {
return Some(format!("[{}]", slot.slot));
}
}
}
None
}
fn draw_footer(&mut self, ui: &mut Ui) { fn draw_footer(&mut self, ui: &mut Ui) {
ui.add_space(5.0); ui.add_space(5.0);
ui.horizontal(|ui| { ui.horizontal_top(|ui| {
// ---------- Microphone selection ---------- // ---------- Microphone selection ----------
let mics = &self.audio_player_state.all_inputs_sorted; 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 mut selected_input = self.audio_player_state.current_input.to_owned();
let prev_input = selected_input.to_owned(); let prev_input = selected_input.to_owned();
ComboBox::from_label("Choose microphone") ComboBox::from_label("Choose microphone")
.height(30.0)
.selected_text( .selected_text(
self.audio_player_state self.audio_player_state
.all_inputs .all_inputs
@@ -850,7 +302,7 @@ impl SoundpadGui {
) )
.show_ui(ui, |ui| { .show_ui(ui, |ui| {
for (name, nick) in mics { for (name, nick) in mics {
ui.selectable_value(&mut selected_input, name.clone(), nick); ui.selectable_value(&mut selected_input, name.to_owned(), nick);
} }
}); });
@@ -859,50 +311,10 @@ impl SoundpadGui {
} }
// -------------------------------- // --------------------------------
// ---------- Master Volume Slider ---------- ui.add_space(ui.available_width() - 18.0 - ui.spacing().item_spacing.x);
let volume_icon = Self::get_volume_icon(self.audio_player_state.volume);
let volume_label = Label::new(RichText::new(volume_icon).size(18.0));
ui.add_sized([18.0, 18.0], volume_label)
.on_hover_text(format!(
"Master Volume: {:.0}%",
self.audio_player_state.volume * 100.0
));
let should_update_volume = !self.app_state.volume_dragged
&& self
.app_state
.ignore_volume_update_until
.map(|t| Instant::now() > t)
.unwrap_or(true);
if should_update_volume {
self.app_state.volume_slider_value = self.audio_player_state.volume;
}
let volume_slider = Slider::new(&mut self.app_state.volume_slider_value, 0.0..=1.0)
.show_value(false)
.step_by(0.01);
let volume_slider_response = ui.add_sized([150.0, 18.0], volume_slider);
if volume_slider_response.drag_stopped() {
self.app_state.volume_dragged = true;
}
// ------------------------------------------
ui.add_space(ui.available_width() - 18.0 * 2.0 - ui.spacing().item_spacing.x * 2.0);
// ---------- Hotkeys button ----------
let hotkeys_button =
Button::new(ICON_KEYBOARD.atom_size(Vec2::new(18.0, 18.0))).frame(false);
let hotkeys_button_response = ui.add_sized([18.0, 18.0], hotkeys_button);
if hotkeys_button_response.clicked() {
self.app_state.show_hotkeys = true;
}
hotkeys_button_response.on_hover_text("Hotkeys (H)");
// --------------------------------
// ---------- Settings button ---------- // ---------- Settings button ----------
let settings_button = let settings_button = Button::new(icons::ICON_SETTINGS).frame(false);
Button::new(ICON_SETTINGS.atom_size(Vec2::new(18.0, 18.0))).frame(false);
let settings_button_response = ui.add_sized([18.0, 18.0], settings_button); let settings_button_response = ui.add_sized([18.0, 18.0], settings_button);
if settings_button_response.clicked() { if settings_button_response.clicked() {
self.app_state.show_settings = true; self.app_state.show_settings = true;
+10 -269
View File
@@ -1,283 +1,24 @@
use crate::gui::SoundpadGui; use crate::gui::SoundpadGui;
use egui::{Context, Id, Key, Modifiers}; use egui::{Context, Key};
use pwsp::types::socket::Request;
use pwsp::utils::gui::make_request_async;
use std::path::PathBuf;
/// Convert an egui Key + Modifiers to a normalized chord string like "Ctrl+Shift+A".
fn chord_from_event(modifiers: &Modifiers, key: &Key) -> Option<String> {
let key_name = key.name();
let is_valid = (key_name.len() == 1
&& key_name.chars().next().unwrap().is_ascii_alphanumeric())
|| (key_name.starts_with('F')
&& key_name.len() > 1
&& key_name[1..].chars().all(|c| c.is_ascii_digit()));
if !is_valid {
return None;
}
// Require at least one modifier for hotkey chords (ignoring command/Super due to Wayland/Niri bug)
if !modifiers.ctrl && !modifiers.alt && !modifiers.shift {
return None;
}
let mut parts = vec![];
if modifiers.ctrl {
parts.push("Ctrl");
}
if modifiers.alt {
parts.push("Alt");
}
if modifiers.shift {
parts.push("Shift");
}
// We intentionally ignore modifiers.command (Super) here to bypass a Wayland/Niri bug
// where the Super key modifier is constantly active.
parts.push(key_name);
Some(parts.join("+"))
}
/// Parse a chord string back to (Modifiers, Key) for matching.
pub fn parse_chord(chord: &str) -> Option<(Modifiers, Key)> {
let parts: Vec<&str> = chord.split('+').collect();
if parts.is_empty() {
return None;
}
let mut modifiers = Modifiers::NONE;
for &part in &parts[..parts.len() - 1] {
match part {
"Ctrl" => modifiers.ctrl = true,
"Alt" => modifiers.alt = true,
"Shift" => modifiers.shift = true,
"Super" => modifiers.command = true,
_ => return None,
}
}
let key_name = parts[parts.len() - 1];
let is_valid = (key_name.len() == 1
&& key_name.chars().next().unwrap().is_ascii_alphanumeric())
|| (key_name.starts_with('F')
&& key_name.len() > 1
&& key_name[1..].chars().all(|c| c.is_ascii_digit()));
if !is_valid {
return None;
}
let key = Key::from_name(key_name)?;
Some((modifiers, key))
}
impl SoundpadGui { impl SoundpadGui {
fn key_pressed(&self, ctx: &Context, key: Key) -> bool {
ctx.input(|i| i.key_pressed(key))
}
fn modifiers(&self, ctx: &Context) -> Modifiers {
ctx.input(|i| i.modifiers)
}
fn get_focused(&self, ctx: &Context) -> Option<Id> {
ctx.memory(|m| m.focused())
}
pub fn handle_input(&mut self, ctx: &Context) { pub fn handle_input(&mut self, ctx: &Context) {
let modifiers = self.modifiers(ctx); ctx.input(|i| {
let search_focused = { if i.key_pressed(Key::Escape) {
if let Some(focused_id) = self.get_focused(ctx) std::process::exit(0);
&& let Some(search_id) = self.app_state.search_field_id
&& focused_id.eq(&search_id)
{
true
} else {
false
}
};
// Handle hotkey capture mode: listen for a key chord to assign
if self.app_state.hotkey_capture_active {
if self.key_pressed(ctx, Key::Escape) {
self.app_state.hotkey_capture_active = false;
self.app_state.assigning_hotkey_slot = None;
self.app_state.assigning_hotkey_for_file = None;
return;
} }
// Try to capture a chord from any key press if !self.app_state.show_settings && i.key_pressed(Key::Space) {
let captured = ctx.input(|i| {
for event in &i.events {
if let egui::Event::Key {
key,
pressed: true,
modifiers: mods,
..
} = event
&& let Some(chord) = chord_from_event(mods, key)
{
return Some(chord);
}
}
None
});
if let Some(chord) = captured {
if let Some(slot) = self.app_state.assigning_hotkey_slot.take() {
make_request_async(Request::set_hotkey_key(&slot, &chord));
self.app_state
.hotkey_config
.set_key_chord(&slot, Some(chord));
} else if let Some(file_path) = self.app_state.assigning_hotkey_for_file.take() {
// Auto-create a slot from the file name
let slot_name = file_path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let action = Request::play(&file_path.to_string_lossy(), false);
make_request_async(Request::set_hotkey_action_and_key(
&slot_name, &action, &chord,
));
self.app_state
.hotkey_config
.set_slot(slot_name.clone(), action);
self.app_state
.hotkey_config
.set_key_chord(&slot_name, Some(chord.clone()));
}
self.app_state.hotkey_capture_active = false;
self.app_state.assigning_hotkey_slot = None;
self.app_state.assigning_hotkey_for_file = None;
}
return;
}
// Open/close settings
if !search_focused && self.key_pressed(ctx, Key::I) {
self.app_state.show_settings = !self.app_state.show_settings;
}
// Toggle hotkeys view
if !search_focused && self.key_pressed(ctx, Key::H) {
self.app_state.show_hotkeys = !self.app_state.show_hotkeys;
}
if !self.app_state.show_settings && !self.app_state.show_hotkeys {
// Pause / resume audio on space
if !search_focused && self.key_pressed(ctx, Key::Space) {
self.play_toggle(); self.play_toggle();
} }
// Stop all audio tracks on backspace if i.key_pressed(Key::Slash) {
if !search_focused && self.key_pressed(ctx, Key::Backspace) { self.app_state.show_settings = !self.app_state.show_settings;
self.stop(None);
} }
// Focus search field if self.app_state.show_settings && i.key_pressed(Key::Backspace) {
if self.key_pressed(ctx, Key::Slash) { self.app_state.show_settings = false;
if search_focused { }
ctx.memory_mut(|m| {
m.request_focus(Id::NULL);
}); });
} else {
self.app_state.force_focus_search = true;
}
}
// Play selected file on Enter
if self.key_pressed(ctx, Key::Enter)
&& let Some(path) = self.app_state.selected_file.clone()
{
if modifiers.ctrl {
self.play_file(&path, true);
} else if modifiers.shift
&& let Some(last_track) = self.audio_player_state.tracks.last()
{
self.stop(Some(last_track.id));
self.play_file(&path, true);
} else {
self.play_file(&path, false);
}
}
// Iterate through dirs and files with Ctrl + Up/Down
let arrow_up_pressed = self.key_pressed(ctx, Key::ArrowUp);
let arrow_down_pressed = self.key_pressed(ctx, Key::ArrowDown);
if modifiers.ctrl && (arrow_up_pressed || arrow_down_pressed) {
if modifiers.shift && !self.app_state.dirs.is_empty() {
let mut dirs: Vec<PathBuf> = self.app_state.dirs.to_vec();
dirs.sort();
let current_dir_index = self
.app_state
.current_dir
.as_ref()
.and_then(|cd| dirs.iter().position(|x| x == cd));
let new_dir_index =
match (current_dir_index, arrow_up_pressed, arrow_down_pressed) {
(Some(i), true, false) => (i + dirs.len() - 1) % dirs.len(),
(Some(i), false, true) => (i + 1) % dirs.len(),
(Some(i), true, true) => i,
(None, true, _) => dirs.len() - 1,
(None, false, true) => 0,
_ => return,
};
self.open_dir(&dirs[new_dir_index]);
} else if self.app_state.current_dir.is_some() {
let files = self.get_filtered_files();
if files.is_empty() {
return;
}
let current_files_index = self
.app_state
.selected_file
.as_ref()
.and_then(|f| files.iter().position(|x| x == f));
let new_files_index =
match (current_files_index, arrow_up_pressed, arrow_down_pressed) {
(Some(i), true, false) => (i + files.len() - 1) % files.len(),
(Some(i), false, true) => (i + 1) % files.len(),
(Some(i), true, true) => i,
(None, true, _) => files.len() - 1,
(None, false, true) => 0,
_ => return,
};
self.app_state.selected_file = Some(files[new_files_index].clone());
}
}
// Check for hotkey chord triggers
let slots_to_play: Vec<String> = ctx.input(|i| {
let mut result = vec![];
for slot in &self.app_state.hotkey_config.slots {
if let Some(chord) = &slot.key_chord
&& let Some((mods, key)) = parse_chord(chord)
&& i.modifiers == mods
&& i.key_pressed(key)
{
result.push(slot.slot.clone());
}
}
result
});
for slot in slots_to_play {
self.play_hotkey_slot(&slot);
}
}
// });
} }
} }
+29 -128
View File
@@ -4,31 +4,25 @@ mod update;
use eframe::{HardwareAcceleration, NativeOptions, icon_data::from_png_bytes, run_native}; use eframe::{HardwareAcceleration, NativeOptions, icon_data::from_png_bytes, run_native};
use egui::{Context, Vec2, ViewportBuilder}; use egui::{Context, Vec2, ViewportBuilder};
use itertools::Itertools;
use pwsp::{ use pwsp::{
types::{ types::{
audio_player::PlayerState, audio_player::PlayerState,
config::GuiConfig, config::GuiConfig,
config::HotkeyConfig,
gui::{AppState, AudioPlayerState}, gui::{AppState, AudioPlayerState},
socket::Request, socket::Request,
}, },
utils::{ utils::{
daemon::get_daemon_config, daemon::get_daemon_config,
gui::{get_gui_config, make_request_async, make_request_sync, start_app_state_thread}, gui::{get_gui_config, make_request_sync, start_app_state_thread},
}, },
}; };
use rfd::FileDialog; use rfd::FileDialog;
use std::path::PathBuf;
use std::{ use std::{
error::Error, error::Error,
path::PathBuf,
sync::{Arc, Mutex}, sync::{Arc, Mutex},
}; };
const SUPPORTED_EXTENSIONS: [&str; 12] = [
"mp3", "wav", "ogg", "flac", "mp4", "m4a", "aac", "mov", "mkv", "mka", "webm", "avi",
];
struct SoundpadGui { struct SoundpadGui {
pub app_state: AppState, pub app_state: AppState,
pub config: GuiConfig, pub config: GuiConfig,
@@ -53,79 +47,53 @@ impl SoundpadGui {
}; };
soundpad_gui.app_state.dirs = config.dirs; soundpad_gui.app_state.dirs = config.dirs;
soundpad_gui.app_state.hotkey_config = HotkeyConfig::load().unwrap_or_default();
soundpad_gui soundpad_gui
} }
pub fn play_toggle(&mut self) { pub fn play_toggle(&mut self) {
let (new_state, request) = { let mut guard = self.audio_player_state_shared.lock().unwrap();
let guard = self guard.state = match guard.state {
.audio_player_state_shared PlayerState::Playing => {
.lock() make_request_sync(Request::pause()).ok();
.unwrap_or_else(|e| e.into_inner()); guard.new_state = Some(PlayerState::Paused);
match guard.state { PlayerState::Paused
PlayerState::Playing => (Some(PlayerState::Paused), Some(Request::pause(None))),
PlayerState::Paused => (Some(PlayerState::Playing), Some(Request::resume(None))),
PlayerState::Stopped => (None, None),
} }
PlayerState::Paused => {
make_request_sync(Request::resume()).ok();
guard.new_state = Some(PlayerState::Playing);
PlayerState::Playing
}
PlayerState::Stopped => PlayerState::Stopped,
}; };
if let Some(req) = request {
make_request_async(req);
} }
if let Some(state) = new_state { pub fn add_dir(&mut self) {
let mut guard = self
.audio_player_state_shared
.lock()
.unwrap_or_else(|e| e.into_inner());
guard.new_state = Some(state.clone());
guard.state = state;
}
}
pub fn open_file(&mut self) {
let file_dialog = FileDialog::new().add_filter("Audio File", &SUPPORTED_EXTENSIONS);
if let Some(path) = file_dialog.pick_file() {
self.play_file(&path, false);
}
}
pub fn add_dirs(&mut self) {
let file_dialog = FileDialog::new(); let file_dialog = FileDialog::new();
if let Some(paths) = file_dialog.pick_folders() { if let Some(path) = file_dialog.pick_folder() {
for path in paths { self.app_state.dirs.insert(path);
self.app_state.dirs.push(path);
}
self.app_state.dirs = self.app_state.dirs.iter().unique().cloned().collect();
self.config.dirs = self.app_state.dirs.clone(); self.config.dirs = self.app_state.dirs.clone();
self.config.save_to_file().ok(); self.config.save_to_file().ok();
} }
} }
pub fn open_dir(&mut self, path: &PathBuf) { pub fn remove_dir(&mut self, path: PathBuf) {
self.app_state.current_dir = Some(path.clone()); self.app_state.dirs.remove(&path);
match path.read_dir() { if let Some(current_dir) = &self.app_state.current_dir
Ok(read_dir) => { && current_dir == &path
self.app_state.files = read_dir {
.filter_map(|res| res.ok()) self.app_state.current_dir = None;
.map(|entry| entry.path())
.collect();
}
Err(e) => {
eprintln!("Failed to read directory {:?}: {}", path, e);
self.app_state.files.clear();
}
} }
self.config.dirs = self.app_state.dirs.clone();
self.config.save_to_file().ok();
} }
pub fn play_file(&mut self, path: &PathBuf, concurrent: bool) { pub fn play_file(&mut self, path: PathBuf) {
make_request_async(Request::play(&path.to_string_lossy(), concurrent)); make_request_sync(Request::play(path.to_str().unwrap())).ok();
} }
pub fn set_input(&mut self, name: String) { pub fn set_input(&mut self, name: String) {
make_request_async(Request::set_input(&name)); make_request_sync(Request::set_input(&name)).ok();
if self.config.save_input { if self.config.save_input {
let mut daemon_config = get_daemon_config(); let mut daemon_config = get_daemon_config();
@@ -133,67 +101,6 @@ impl SoundpadGui {
daemon_config.save_to_file().ok(); daemon_config.save_to_file().ok();
} }
} }
pub fn toggle_loop(&mut self, id: Option<u32>) {
make_request_async(Request::toggle_loop(id));
}
pub fn pause(&mut self, id: Option<u32>) {
make_request_async(Request::pause(id));
}
pub fn resume(&mut self, id: Option<u32>) {
make_request_async(Request::resume(id));
}
pub fn stop(&mut self, id: Option<u32>) {
make_request_async(Request::stop(id));
}
pub fn play_hotkey_slot(&mut self, slot: &str) {
make_request_async(Request::play_hotkey(slot));
}
pub fn get_filtered_files(&self) -> Vec<PathBuf> {
let mut files: Vec<PathBuf> = self.app_state.files.iter().cloned().collect();
files.sort();
let search_query = self.app_state.search_query.to_lowercase();
let search_query = search_query.trim();
files
.into_iter()
.filter(|entry_path| {
if entry_path.is_dir() {
return false;
}
if !SUPPORTED_EXTENSIONS.contains(
&entry_path
.extension()
.unwrap_or_default()
.to_str()
.unwrap_or_default(),
) {
return false;
}
if !search_query.is_empty() {
let file_name = entry_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
if !file_name.to_lowercase().contains(search_query) {
return false;
}
}
true
})
.collect()
}
} }
pub async fn run() -> Result<(), Box<dyn Error>> { pub async fn run() -> Result<(), Box<dyn Error>> {
@@ -221,13 +128,7 @@ pub async fn run() -> Result<(), Box<dyn Error>> {
Ok(Box::new(SoundpadGui::new(&cc.egui_ctx))) Ok(Box::new(SoundpadGui::new(&cc.egui_ctx)))
}), }),
) { ) {
Ok(_) => { Ok(_) => Ok(()),
let config = get_gui_config();
if config.pause_on_exit {
make_request_sync(Request::pause(None)).ok();
}
Ok(())
}
Err(e) => Err(e.into()), Err(e) => Err(e.into()),
} }
} }
+40 -97
View File
@@ -3,91 +3,19 @@ use eframe::{App, Frame as EFrame};
use egui::{CentralPanel, Context}; use egui::{CentralPanel, Context};
use pwsp::{ use pwsp::{
types::socket::Request, types::socket::Request,
utils::{daemon::get_daemon_config, gui::make_request_async}, utils::{
daemon::{get_daemon_config, is_daemon_running},
gui::make_request_sync,
},
}; };
use std::time::{Duration, Instant};
impl App for SoundpadGui { impl App for SoundpadGui {
fn logic(&mut self, ctx: &Context, _frame: &mut EFrame) { fn update(&mut self, ctx: &Context, _frame: &mut EFrame) {
// Remove directories
for path in self.app_state.dirs_to_remove.drain() {
self.app_state.dirs.retain(|x| x != &path);
if let Some(current_dir) = &self.app_state.current_dir
&& current_dir == &path
{ {
self.app_state.current_dir = None; let guard = self.audio_player_state_shared.lock().unwrap();
self.app_state.files.clear();
}
}
// Save directories if changed
if !self.config.dirs.eq(&self.app_state.dirs) {
self.config.dirs = self.app_state.dirs.clone();
self.config.save_to_file().ok();
}
// Seek and volume requests
let mut seek_requests = vec![];
let mut volume_requests = vec![];
for (id, ui_state) in &mut self.app_state.track_ui_states {
if ui_state.position_dragged {
seek_requests.push((*id, ui_state.position_slider_value));
}
if ui_state.volume_dragged {
volume_requests.push((*id, ui_state.volume_slider_value));
ui_state.volume_dragged = false;
}
}
for (id, pos) in seek_requests {
make_request_async(Request::seek(pos, Some(id)));
if let Some(ui_state) = self.app_state.track_ui_states.get_mut(&id) {
ui_state.position_dragged = false;
ui_state.ignore_position_update_until =
Some(Instant::now() + Duration::from_millis(300));
}
}
for (id, vol) in volume_requests {
make_request_async(Request::set_volume(vol, Some(id)));
if let Some(ui_state) = self.app_state.track_ui_states.get_mut(&id) {
ui_state.volume_dragged = false;
ui_state.ignore_volume_update_until =
Some(Instant::now() + Duration::from_millis(300));
}
}
if self.app_state.volume_dragged {
make_request_async(Request::set_volume(
self.app_state.volume_slider_value,
None,
));
self.app_state.volume_dragged = false;
self.app_state.ignore_volume_update_until =
Some(Instant::now() + Duration::from_millis(300));
if self.config.save_volume {
let mut daemon_config = get_daemon_config();
daemon_config.default_volume = Some(self.app_state.volume_slider_value);
daemon_config.save_to_file().ok();
}
}
// Sync audio player state
{
let mut guard = self
.audio_player_state_shared
.lock()
.unwrap_or_else(|e| e.into_inner());
if let Some(config) = guard.hotkey_config.take() {
self.app_state.hotkey_config = config;
}
self.audio_player_state = guard.clone(); self.audio_player_state = guard.clone();
} }
// Handle scale factor changes
let old_scale_factor = self.config.scale_factor; let old_scale_factor = self.config.scale_factor;
let new_scale_factor = ctx.zoom_factor().clamp(0.5, 2.0); let new_scale_factor = ctx.zoom_factor().clamp(0.5, 2.0);
@@ -98,37 +26,52 @@ impl App for SoundpadGui {
self.config.save_to_file().ok(); self.config.save_to_file().ok();
} }
// Handle input
self.handle_input(ctx); self.handle_input(ctx);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut EFrame) { CentralPanel::default().show(ctx, |ui| {
// Draw UI if !is_daemon_running().unwrap() {
CentralPanel::default().show_inside(ui, |ui| {
if !self.audio_player_state.is_daemon_running {
self.draw_waiting_for_daemon(ui); self.draw_waiting_for_daemon(ui);
return; return;
} }
if self.app_state.hotkey_capture_active {
self.draw_hotkey_capture(ui);
return;
}
if self.app_state.show_settings { if self.app_state.show_settings {
self.draw_settings(ui); self.draw_settings(ui);
return; return;
} }
if self.app_state.show_hotkeys { self.draw(ui).ok();
self.draw_hotkeys(ui);
return;
}
self.draw(ui);
}); });
// Request repaint if self.app_state.position_dragged {
ui.request_repaint_after_secs(1.0 / 60.0); 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);
} }
} }
+126 -374
View File
@@ -1,14 +1,13 @@
use crate::{ use crate::{
types::pipewire::{DeviceType, Terminate}, types::pipewire::{AudioDevice, DeviceType, Terminate},
utils::{ utils::{
daemon::get_daemon_config, daemon::get_daemon_config,
pipewire::{create_link, get_device, link_player_to_virtual_mic}, pipewire::{create_link, get_all_devices, get_device},
}, },
}; };
use rodio::{Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source}; use rodio::{Decoder, OutputStream, OutputStreamBuilder, Sink, Source};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{ use std::{
collections::HashMap,
error::Error, error::Error,
fs, fs,
path::{Path, PathBuf}, path::{Path, PathBuf},
@@ -23,453 +22,206 @@ pub enum PlayerState {
Playing, Playing,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrackInfo {
pub id: u32,
pub path: PathBuf,
pub duration: Option<f32>,
pub position: f32,
pub volume: f32,
pub looped: bool,
pub paused: bool,
}
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
pub struct FullState {
pub state: PlayerState,
pub tracks: Vec<TrackInfo>,
pub volume: f32,
pub current_input: String,
pub all_inputs: HashMap<String, String>,
}
pub struct PlayingSound {
pub id: u32,
pub sink: Player,
pub path: PathBuf,
pub duration: Option<f32>,
pub looped: bool,
pub volume: f32,
}
pub struct AudioPlayer { pub struct AudioPlayer {
stream_handle: Option<MixerDeviceSink>, _stream_handle: OutputStream,
pub tracks: HashMap<u32, PlayingSound>, sink: Sink,
pub next_id: u32,
input_link_sender: Option<pipewire::channel::Sender<Terminate>>, input_link_sender: Option<pipewire::channel::Sender<Terminate>>,
player_link_sender: Option<pipewire::channel::Sender<Terminate>>, pub current_input_device: Option<AudioDevice>,
pub input_device_name: Option<String>,
pub volume: f32, // Master volume pub volume: f32,
pub duration: Option<f32>,
pub current_file_path: Option<PathBuf>,
} }
impl AudioPlayer { impl AudioPlayer {
pub async fn new() -> Result<Self, Box<dyn Error>> { pub async fn new() -> Result<Self, Box<dyn Error>> {
let daemon_config = get_daemon_config(); let daemon_config = get_daemon_config();
let default_volume = daemon_config.default_volume.unwrap_or(1.0); let default_volume = daemon_config.default_volume.unwrap_or(1.0);
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 { let mut audio_player = AudioPlayer {
stream_handle: None, _stream_handle: stream_handle,
tracks: HashMap::new(), sink,
next_id: 1,
input_link_sender: None, input_link_sender: None,
player_link_sender: None, current_input_device: default_input_device.clone(),
input_device_name: daemon_config.default_input_name.clone(),
volume: default_volume, volume: default_volume,
duration: None,
current_file_path: None,
}; };
if audio_player.input_device_name.is_some() { if default_input_device.is_some() {
audio_player.link_devices().await?; audio_player.link_devices().await?;
} }
Ok(audio_player) Ok(audio_player)
} }
fn ensure_stream(&mut self) -> Result<&MixerDeviceSink, Box<dyn Error>> {
if self.stream_handle.is_none() {
let mut sink = DeviceSinkBuilder::open_default_sink()?;
sink.log_on_drop(false);
self.stream_handle = Some(sink);
}
Ok(self.stream_handle.as_ref().unwrap())
}
fn drop_stream(&mut self) {
if self.stream_handle.is_some() {
self.stream_handle = None;
self.abort_player_link_thread();
}
}
fn abort_link_thread(&mut self) { fn abort_link_thread(&mut self) {
if let Some(sender) = &self.input_link_sender { if let Some(sender) = &self.input_link_sender {
if sender.send(Terminate {}).is_ok() { match sender.send(Terminate {}) {
println!("Sent terminate signal to input link thread"); Ok(_) => println!("Sent terminate signal to link thread"),
self.input_link_sender = None; Err(_) => println!("Failed to send terminate signal to link thread"),
} else {
eprintln!("Failed to send terminate signal to input link thread");
} }
} }
} }
fn abort_player_link_thread(&mut self) {
if let Some(sender) = &self.player_link_sender {
if sender.send(Terminate {}).is_ok() {
println!("Sent terminate signal to player link thread");
self.player_link_sender = None;
} else {
eprintln!("Failed to send terminate signal to player link thread");
}
}
}
async fn link_player(&mut self) -> Result<(), Box<dyn Error>> {
if self.player_link_sender.is_some() {
return Ok(());
}
match link_player_to_virtual_mic().await {
Ok(sender) => {
self.player_link_sender = Some(sender);
Ok(())
}
Err(_) => Ok(()),
}
}
async fn link_devices(&mut self) -> Result<(), Box<dyn Error>> { async fn link_devices(&mut self) -> Result<(), Box<dyn Error>> {
self.abort_link_thread(); self.abort_link_thread();
let input_device; if self.current_input_device.is_none() {
if let Some(input_device_name) = &self.input_device_name { println!("No input device selected, skipping device linking");
if let Ok(device) = get_device(input_device_name).await {
input_device = device;
} else {
eprintln!(
"Could not find selected input device {}, skipping device linking",
input_device_name
);
return Ok(());
}
} else {
eprintln!("No input device selected, skipping device linking");
return Ok(()); return Ok(());
} }
let daemon_input; let (input_devices, _) = get_all_devices().await?;
if let Ok(device) = get_device("pwsp-virtual-mic").await {
daemon_input = device; let mut pwsp_daemon_input: Option<AudioDevice> = None;
} else { for input_device in input_devices {
eprintln!("Could not find pwsp-virtual-mic device, skipping device linking"); 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(()); return Ok(());
} }
let Some(output_fl) = input_device.output_fl.clone() else { let pwsp_daemon_input = pwsp_daemon_input.unwrap();
eprintln!("Failed to get pwsp-daemon output_fl");
return Ok(());
};
let Some(output_fr) = input_device.output_fr.clone() else {
eprintln!("Failed to get pwsp-daemon output_fr");
return Ok(());
};
let Some(input_fl) = daemon_input.input_fl.clone() else {
eprintln!("Failed to get pwsp-daemon input_fl");
return Ok(());
};
let Some(input_fr) = daemon_input.input_fr.clone() else {
eprintln!("Failed to get pwsp-daemon input_fr");
return Ok(());
};
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)?); self.input_link_sender = Some(create_link(output_fl, output_fr, input_fl, input_fr)?);
Ok(()) Ok(())
} }
pub fn pause(&mut self, id: Option<u32>) { pub fn pause(&mut self) {
if let Some(id) = id { if self.get_state() == PlayerState::Playing {
if let Some(sound) = self.tracks.get_mut(&id) { self.sink.pause();
sound.sink.pause();
}
} else {
for sound in self.tracks.values_mut() {
sound.sink.pause();
}
} }
} }
pub fn resume(&mut self, id: Option<u32>) { pub fn resume(&mut self) {
if let Some(id) = id { if self.get_state() == PlayerState::Paused {
if let Some(sound) = self.tracks.get_mut(&id) { self.sink.play();
sound.sink.play();
}
} else {
for sound in self.tracks.values_mut() {
sound.sink.play();
}
} }
} }
pub fn stop(&mut self, id: Option<u32>) { pub fn stop(&mut self) {
if let Some(id) = id { self.sink.stop();
self.tracks.remove(&id);
} else {
self.tracks.clear();
}
if self.tracks.is_empty() {
self.drop_stream();
}
} }
pub fn is_paused(&self) -> bool { pub fn is_paused(&self) -> bool {
if self.tracks.is_empty() { self.sink.is_paused()
return false;
}
self.tracks.values().all(|s| s.sink.is_paused())
} }
pub fn get_state(&self) -> PlayerState { pub fn get_state(&mut self) -> PlayerState {
if self.tracks.is_empty() { if self.sink.len() == 0 {
return PlayerState::Stopped; return PlayerState::Stopped;
} }
if self if self.sink.is_paused() {
.tracks
.values()
.any(|s| !s.sink.is_paused() && !s.sink.empty())
{
return PlayerState::Playing;
}
if self.is_paused() {
return PlayerState::Paused; return PlayerState::Paused;
} }
PlayerState::Stopped PlayerState::Playing
} }
pub fn get_volume(&mut self, id: Option<u32>) -> Option<f32> { pub fn set_volume(&mut self, volume: f32) {
if let Some(id) = id {
if let Some(sound) = self.tracks.get_mut(&id) {
Some(sound.sink.volume())
} else {
None
}
} else {
Some(self.volume)
}
}
pub fn set_volume(&mut self, volume: f32, id: Option<u32>) {
if let Some(id) = id {
if let Some(sound) = self.tracks.get_mut(&id) {
sound.volume = volume;
sound.sink.set_volume(self.volume * volume);
}
} else {
self.volume = volume; self.volume = volume;
for sound in self.tracks.values_mut() { self.sink.set_volume(volume);
sound.sink.set_volume(self.volume * sound.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, position: f32) -> Result<(), Box<dyn Error>> {
match self.sink.try_seek(Duration::from_secs_f32(position)) {
Ok(_) => Ok(()),
Err(err) => Err(err.into()),
} }
} }
pub fn get_position(&self, id: Option<u32>) -> f32 { pub fn get_duration(&mut self) -> Result<f32, Box<dyn Error>> {
if let Some(id) = id { if self.get_state() == PlayerState::Stopped {
if let Some(sound) = self.tracks.get(&id) { Err("Nothing is playing right now".into())
return sound.sink.get_pos().as_secs_f32();
}
} else if let Some(sound) = self.tracks.values().last() {
// Fallback to last added track if no ID
return sound.sink.get_pos().as_secs_f32();
}
0.0
}
pub fn seek(&mut self, position: f32, id: Option<u32>) -> Result<(), Box<dyn Error>> {
let position = if position < 0.0 { 0.0 } else { position };
if let Some(id) = id {
if let Some(sound) = self.tracks.get_mut(&id) {
sound.sink.try_seek(Duration::from_secs_f32(position))?;
}
} else { } else {
// Seek all? Or last? Let's seek all for now if no ID provided match self.duration {
for sound in self.tracks.values_mut() { Some(duration) => Ok(duration),
sound.sink.try_seek(Duration::from_secs_f32(position)).ok(); 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(()) Ok(())
} }
Err(err) => Err(err.into()),
pub fn get_duration(&mut self, id: Option<u32>) -> Result<f32, Box<dyn Error>> {
if let Some(id) = id {
if let Some(sound) = self.tracks.get(&id) {
return sound.duration.ok_or("Unknown duration".into());
}
} else if let Some(sound) = self.tracks.values().last() {
return sound.duration.ok_or("Unknown duration".into());
}
Err("No track playing".into())
}
pub async fn play(
&mut self,
file_path: &Path,
concurrent: bool,
) -> Result<u32, Box<dyn Error>> {
let path_buf = file_path.to_path_buf();
let decoder_result =
tokio::task::spawn_blocking(move || -> Result<_, Box<dyn Error + Send + Sync>> {
if !path_buf.exists() {
return Err(format!("File does not exist: {}", path_buf.display()).into());
}
let file = fs::File::open(&path_buf)?;
let decoder = Decoder::try_from(file)
.map_err(|e| Box::new(e) as Box<dyn Error + Send + Sync>)?;
Ok(decoder)
})
.await?;
match decoder_result {
Ok(source) => {
if !concurrent {
self.tracks.clear();
}
self.ensure_stream()?;
self.link_player().await.ok();
let id = self.next_id;
self.next_id += 1;
let duration = source.total_duration().map(|d| d.as_secs_f32());
let mixer = self.stream_handle.as_ref().unwrap().mixer();
let sink = Player::connect_new(mixer);
sink.set_volume(self.volume); // Default volume is 1.0 * master
sink.append(source);
sink.play();
let sound = PlayingSound {
id,
sink,
path: file_path.to_path_buf(),
duration,
looped: false,
volume: 1.0,
};
self.tracks.insert(id, sound);
Ok(id)
}
Err(err) => Err(err as Box<dyn Error>),
} }
} }
pub fn set_loop(&mut self, enabled: bool, id: Option<u32>) { pub fn get_current_file_path(&mut self) -> &Option<PathBuf> {
if let Some(id) = id { if self.get_state() == PlayerState::Stopped {
if let Some(sound) = self.tracks.get_mut(&id) { self.current_file_path = None;
sound.looped = enabled;
}
} else {
// Set loop for all? Or just last?
// Let's set for all.
for sound in self.tracks.values_mut() {
sound.looped = enabled;
}
}
}
pub fn get_tracks(&self) -> Vec<TrackInfo> {
let mut tracks: Vec<_> = self
.tracks
.values()
.map(|sound| TrackInfo {
id: sound.id,
path: sound.path.clone(),
duration: sound.duration,
position: sound.sink.get_pos().as_secs_f32(),
volume: sound.volume,
looped: sound.looped,
paused: sound.sink.is_paused(),
})
.collect();
tracks.sort_by_key(|t| t.id);
tracks
}
pub async fn update(&mut self, check_devices: bool) {
if check_devices {
if let Some(input_device_name) = &self.input_device_name {
// Unlink devices if selected input device was removed
if self.input_link_sender.is_some() && get_device(input_device_name).await.is_err()
{
eprintln!(
"Selected input device {} was removed, unlinking devices",
input_device_name
);
self.abort_link_thread();
}
// Link devices if not linked
else if self.input_link_sender.is_none() {
self.link_devices().await.ok();
}
}
if self.stream_handle.is_some() && self.player_link_sender.is_none() {
self.link_player().await.ok();
}
}
// Handle looped sounds
let mut restarts = vec![];
for (id, sound) in &self.tracks {
if sound.sink.empty() && sound.looped {
restarts.push(*id);
}
}
let mut restart_futures = vec![];
for id in restarts {
if let Some(sound) = self.tracks.get(&id) {
let path = sound.path.clone();
let handle = tokio::task::spawn_blocking(move || {
if let Ok(file) = fs::File::open(&path)
&& let Ok(source) = Decoder::try_from(file)
{
return Some((id, source));
}
None
});
restart_futures.push(handle);
}
}
for handle in restart_futures {
if let Ok(res) = handle.await
&& let Some((id, source)) = res
&& let Some(sound) = self.tracks.get_mut(&id)
{
sound.sink.append(source);
sound.sink.play();
}
}
self.tracks
.retain(|_, sound| !sound.sink.empty() || sound.looped);
if self.tracks.is_empty() {
self.drop_stream();
} }
&self.current_file_path
} }
pub async fn set_current_input_device(&mut self, name: &str) -> Result<(), Box<dyn Error>> { pub async fn set_current_input_device(&mut self, name: &str) -> Result<(), Box<dyn Error>> {
@@ -479,7 +231,7 @@ impl AudioPlayer {
return Err("Selected device is not an input device".into()); return Err("Selected device is not an input device".into());
} }
self.input_device_name = Some(name.to_string()); self.current_input_device = Some(input_device);
self.link_devices().await?; self.link_devices().await?;
+43 -531
View File
@@ -1,17 +1,9 @@
use crate::{ use crate::{
types::{ types::socket::Response,
audio_player::{FullState, PlayerState}, utils::{daemon::get_audio_player, pipewire::get_all_devices},
config::HotkeyConfig,
socket::{Request, Response},
},
utils::{
commands::parse_command,
daemon::get_audio_player,
pipewire::{get_all_devices, get_device},
},
}; };
use async_trait::async_trait; use async_trait::async_trait;
use std::{collections::HashMap, path::PathBuf}; use std::path::PathBuf;
#[async_trait] #[async_trait]
pub trait Executable { pub trait Executable {
@@ -20,56 +12,35 @@ pub trait Executable {
pub struct PingCommand {} pub struct PingCommand {}
pub struct KillCommand {} pub struct PauseCommand {}
pub struct PauseCommand { pub struct ResumeCommand {}
pub id: Option<u32>,
}
pub struct ResumeCommand { pub struct StopCommand {}
pub id: Option<u32>,
}
pub struct TogglePauseCommand {
pub id: Option<u32>,
}
pub struct StopCommand {
pub id: Option<u32>,
}
pub struct IsPausedCommand {} pub struct IsPausedCommand {}
pub struct GetStateCommand {} pub struct GetStateCommand {}
pub struct GetVolumeCommand { pub struct GetVolumeCommand {}
pub id: Option<u32>,
}
pub struct SetVolumeCommand { pub struct SetVolumeCommand {
pub volume: Option<f32>, pub volume: Option<f32>,
pub id: Option<u32>,
} }
pub struct GetPositionCommand { pub struct GetPositionCommand {}
pub id: Option<u32>,
}
pub struct SeekCommand { pub struct SeekCommand {
pub position: Option<f32>, pub position: Option<f32>,
pub id: Option<u32>,
} }
pub struct GetDurationCommand { pub struct GetDurationCommand {}
pub id: Option<u32>,
}
pub struct PlayCommand { pub struct PlayCommand {
pub file_path: Option<PathBuf>, pub file_path: Option<PathBuf>,
pub concurrent: Option<bool>,
} }
pub struct GetTracksCommand {} pub struct GetCurrentFilePathCommand {}
pub struct GetCurrentInputCommand {} pub struct GetCurrentInputCommand {}
@@ -79,54 +50,6 @@ pub struct SetCurrentInputCommand {
pub name: Option<String>, pub name: Option<String>,
} }
pub struct SetLoopCommand {
pub enabled: Option<bool>,
pub id: Option<u32>,
}
pub struct ToggleLoopCommand {
pub id: Option<u32>,
}
pub struct GetDaemonVersionCommand {}
pub struct GetFullStateCommand {}
pub struct GetHotkeysCommand {}
pub struct SetHotkeyCommand {
pub slot: Option<String>,
pub file_path: Option<PathBuf>,
}
pub struct SetHotkeyActionCommand {
pub slot: Option<String>,
pub action: Option<Request>,
}
pub struct SetHotkeyKeyCommand {
pub slot: Option<String>,
pub key_chord: Option<String>,
}
pub struct SetHotkeyActionAndKeyCommand {
pub slot: Option<String>,
pub action: Option<Request>,
pub key_chord: Option<String>,
}
pub struct PlayHotkeyCommand {
pub slot: Option<String>,
}
pub struct ClearHotkeyCommand {
pub slot: Option<String>,
}
pub struct ClearHotkeyKeyCommand {
pub slot: Option<String>,
}
#[async_trait] #[async_trait]
impl Executable for PingCommand { impl Executable for PingCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
@@ -134,21 +57,11 @@ impl Executable for PingCommand {
} }
} }
#[async_trait]
impl Executable for KillCommand {
async fn execute(&self) -> Response {
Response::new(true, "killed")
}
}
#[async_trait] #[async_trait]
impl Executable for PauseCommand { impl Executable for PauseCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let mut audio_player = match get_audio_player().await { let mut audio_player = get_audio_player().await.lock().await;
Ok(player) => player.lock().await, audio_player.pause();
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
audio_player.pause(self.id);
Response::new(true, "Audio was paused") Response::new(true, "Audio was paused")
} }
} }
@@ -156,64 +69,17 @@ impl Executable for PauseCommand {
#[async_trait] #[async_trait]
impl Executable for ResumeCommand { impl Executable for ResumeCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let mut audio_player = match get_audio_player().await { let mut audio_player = get_audio_player().await.lock().await;
Ok(player) => player.lock().await, audio_player.resume();
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
audio_player.resume(self.id);
Response::new(true, "Audio was resumed") Response::new(true, "Audio was resumed")
} }
} }
#[async_trait]
impl Executable for TogglePauseCommand {
async fn execute(&self) -> Response {
let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
if audio_player.get_state() == PlayerState::Stopped {
return Response::new(false, "Audio is not playing");
}
// This logic is a bit tricky with multiple tracks.
// If ID is provided, toggle that track.
// If not, toggle global pause state?
// For now, let's just use pause/resume based on global state if no ID.
if let Some(id) = self.id {
if let Some(track) = audio_player.tracks.get(&id) {
if track.sink.is_paused() {
audio_player.resume(Some(id));
Response::new(true, "Audio was resumed")
} else {
audio_player.pause(Some(id));
Response::new(true, "Audio was paused")
}
} else {
Response::new(false, "Track not found")
}
} else {
if audio_player.is_paused() {
audio_player.resume(None);
Response::new(true, "Audio was resumed")
} else {
audio_player.pause(None);
Response::new(true, "Audio was paused")
}
}
}
}
#[async_trait] #[async_trait]
impl Executable for StopCommand { impl Executable for StopCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let mut audio_player = match get_audio_player().await { let mut audio_player = get_audio_player().await.lock().await;
Ok(player) => player.lock().await, audio_player.stop();
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
audio_player.stop(self.id);
Response::new(true, "Audio was stopped") Response::new(true, "Audio was stopped")
} }
} }
@@ -221,10 +87,7 @@ impl Executable for StopCommand {
#[async_trait] #[async_trait]
impl Executable for IsPausedCommand { impl Executable for IsPausedCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let audio_player = match get_audio_player().await { let audio_player = get_audio_player().await.lock().await;
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
let is_paused = audio_player.is_paused().to_string(); let is_paused = audio_player.is_paused().to_string();
Response::new(true, is_paused) Response::new(true, is_paused)
} }
@@ -233,32 +96,18 @@ impl Executable for IsPausedCommand {
#[async_trait] #[async_trait]
impl Executable for GetStateCommand { impl Executable for GetStateCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let audio_player = match get_audio_player().await { let mut audio_player = get_audio_player().await.lock().await;
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
let state = audio_player.get_state(); let state = audio_player.get_state();
match serde_json::to_string(&state) { Response::new(true, serde_json::to_string(&state).unwrap())
Ok(json) => Response::new(true, json),
Err(err) => Response::new(false, format!("Failed to serialize state: {}", err)),
}
} }
} }
#[async_trait] #[async_trait]
impl Executable for GetVolumeCommand { impl Executable for GetVolumeCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let mut audio_player = match get_audio_player().await { let audio_player = get_audio_player().await.lock().await;
Ok(player) => player.lock().await, let volume = audio_player.volume;
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
let volume = audio_player.get_volume(self.id);
if let Some(volume) = volume {
Response::new(true, volume.to_string()) Response::new(true, volume.to_string())
} else {
Response::new(false, "Failed to get volume")
}
} }
} }
@@ -266,11 +115,8 @@ impl Executable for GetVolumeCommand {
impl Executable for SetVolumeCommand { impl Executable for SetVolumeCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
if let Some(volume) = self.volume { if let Some(volume) = self.volume {
let mut audio_player = match get_audio_player().await { let mut audio_player = get_audio_player().await.lock().await;
Ok(player) => player.lock().await, audio_player.set_volume(volume);
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
audio_player.set_volume(volume, self.id);
Response::new(true, format!("Audio volume was set to {}", volume)) Response::new(true, format!("Audio volume was set to {}", volume))
} else { } else {
Response::new(false, "Invalid volume value") Response::new(false, "Invalid volume value")
@@ -281,11 +127,8 @@ impl Executable for SetVolumeCommand {
#[async_trait] #[async_trait]
impl Executable for GetPositionCommand { impl Executable for GetPositionCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let audio_player = match get_audio_player().await { let mut audio_player = get_audio_player().await.lock().await;
Ok(player) => player.lock().await, let position = audio_player.get_position();
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
let position = audio_player.get_position(self.id);
Response::new(true, position.to_string()) Response::new(true, position.to_string())
} }
} }
@@ -294,11 +137,8 @@ impl Executable for GetPositionCommand {
impl Executable for SeekCommand { impl Executable for SeekCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
if let Some(position) = self.position { if let Some(position) = self.position {
let mut audio_player = match get_audio_player().await { let mut audio_player = get_audio_player().await.lock().await;
Ok(player) => player.lock().await, match audio_player.seek(position) {
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
match audio_player.seek(position, self.id) {
Ok(_) => Response::new(true, format!("Audio position was set to {}", position)), Ok(_) => Response::new(true, format!("Audio position was set to {}", position)),
Err(err) => Response::new(false, err.to_string()), Err(err) => Response::new(false, err.to_string()),
} }
@@ -311,11 +151,8 @@ impl Executable for SeekCommand {
#[async_trait] #[async_trait]
impl Executable for GetDurationCommand { impl Executable for GetDurationCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let mut audio_player = match get_audio_player().await { let mut audio_player = get_audio_player().await.lock().await;
Ok(player) => player.lock().await, match audio_player.get_duration() {
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
match audio_player.get_duration(self.id) {
Ok(duration) => Response::new(true, duration.to_string()), Ok(duration) => Response::new(true, duration.to_string()),
Err(err) => Response::new(false, err.to_string()), Err(err) => Response::new(false, err.to_string()),
} }
@@ -326,15 +163,9 @@ impl Executable for GetDurationCommand {
impl Executable for PlayCommand { impl Executable for PlayCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
if let Some(file_path) = &self.file_path { if let Some(file_path) = &self.file_path {
let mut audio_player = match get_audio_player().await { let mut audio_player = get_audio_player().await.lock().await;
Ok(player) => player.lock().await, match audio_player.play(file_path).await {
Err(err) => return Response::new(false, format!("Audio player error: {}", err)), Ok(_) => Response::new(true, format!("Now playing {}", file_path.display())),
};
match audio_player
.play(file_path, self.concurrent.unwrap_or(false))
.await
{
Ok(id) => Response::new(true, id.to_string()),
Err(err) => Response::new(false, err.to_string()), Err(err) => Response::new(false, err.to_string()),
} }
} else { } else {
@@ -344,16 +175,14 @@ impl Executable for PlayCommand {
} }
#[async_trait] #[async_trait]
impl Executable for GetTracksCommand { impl Executable for GetCurrentFilePathCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let audio_player = match get_audio_player().await { let mut audio_player = get_audio_player().await.lock().await;
Ok(player) => player.lock().await, let current_file_path = audio_player.get_current_file_path();
Err(err) => return Response::new(false, format!("Audio player error: {}", err)), if let Some(current_file_path) = current_file_path {
}; Response::new(true, current_file_path.to_str().unwrap())
let tracks = audio_player.get_tracks(); } else {
match serde_json::to_string(&tracks) { Response::new(false, "No file is playing")
Ok(json) => Response::new(true, json),
Err(err) => Response::new(false, format!("Failed to serialize tracks: {}", err)),
} }
} }
} }
@@ -361,19 +190,12 @@ impl Executable for GetTracksCommand {
#[async_trait] #[async_trait]
impl Executable for GetCurrentInputCommand { impl Executable for GetCurrentInputCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let audio_player = match get_audio_player().await { let audio_player = get_audio_player().await.lock().await;
Ok(player) => player.lock().await, if let Some(input_device) = &audio_player.current_input_device {
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
if let Some(input_device_name) = &audio_player.input_device_name {
if let Ok(input_device) = get_device(input_device_name).await {
Response::new( Response::new(
true, true,
format!("{} - {}", input_device.name, input_device.nick), format!("{} - {}", input_device.name, input_device.nick),
) )
} else {
Response::new(false, "Failed to get current input device")
}
} else { } else {
Response::new(false, "No input device selected") Response::new(false, "No input device selected")
} }
@@ -383,10 +205,7 @@ impl Executable for GetCurrentInputCommand {
#[async_trait] #[async_trait]
impl Executable for GetAllInputsCommand { impl Executable for GetAllInputsCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let (input_devices, _output_devices) = match get_all_devices().await { let (input_devices, _output_devices) = get_all_devices().await.unwrap();
Ok(devices) => devices,
Err(err) => return Response::new(false, format!("Failed to get devices: {}", err)),
};
let mut input_devices_strings = vec![]; let mut input_devices_strings = vec![];
for device in input_devices { for device in input_devices {
if device.name == "pwsp-virtual-mic" { if device.name == "pwsp-virtual-mic" {
@@ -406,10 +225,7 @@ impl Executable for GetAllInputsCommand {
impl Executable for SetCurrentInputCommand { impl Executable for SetCurrentInputCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
if let Some(name) = &self.name { if let Some(name) = &self.name {
let mut audio_player = match get_audio_player().await { let mut audio_player = get_audio_player().await.lock().await;
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
match audio_player.set_current_input_device(name).await { match audio_player.set_current_input_device(name).await {
Ok(_) => Response::new(true, "Input device was set"), Ok(_) => Response::new(true, "Input device was set"),
Err(err) => Response::new(false, err.to_string()), Err(err) => Response::new(false, err.to_string()),
@@ -419,307 +235,3 @@ impl Executable for SetCurrentInputCommand {
} }
} }
} }
#[async_trait]
impl Executable for SetLoopCommand {
async fn execute(&self) -> Response {
let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
match self.enabled {
Some(enabled) => {
audio_player.set_loop(enabled, self.id);
Response::new(true, format!("Loop was set to {}", enabled))
}
None => Response::new(false, "Invalid enabled value"),
}
}
}
#[async_trait]
impl Executable for ToggleLoopCommand {
async fn execute(&self) -> Response {
let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
if let Some(id) = self.id {
if let Some(track) = audio_player.tracks.get_mut(&id) {
track.looped = !track.looped;
Response::new(true, format!("Loop was set to {}", track.looped))
} else {
Response::new(false, "Track not found")
}
} else {
// Toggle all?
for track in audio_player.tracks.values_mut() {
track.looped = !track.looped;
}
Response::new(true, "Loop toggled for all tracks")
}
}
}
#[async_trait]
impl Executable for GetDaemonVersionCommand {
async fn execute(&self) -> Response {
Response::new(true, env!("CARGO_PKG_VERSION"))
}
}
#[async_trait]
impl Executable for GetFullStateCommand {
async fn execute(&self) -> Response {
let (input_devices, _output_devices) = match get_all_devices().await {
Ok(devices) => devices,
Err(err) => return Response::new(false, format!("Failed to get devices: {}", err)),
};
let mut all_inputs = HashMap::new();
let mut current_input_nick = String::new();
let audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
if let Some(current_input_name) = &audio_player.input_device_name {
for device in input_devices {
if device.name == "pwsp-virtual-mic" {
continue;
}
if device.name == *current_input_name {
current_input_nick = format!("{} - {}", device.name, device.nick);
}
all_inputs.insert(device.name, device.nick);
}
} else {
for device in input_devices {
if device.name == "pwsp-virtual-mic" {
continue;
}
all_inputs.insert(device.name, device.nick);
}
}
let full_state = FullState {
state: audio_player.get_state(),
tracks: audio_player.get_tracks(),
volume: audio_player.volume,
current_input: current_input_nick,
all_inputs,
};
match serde_json::to_string(&full_state) {
Ok(json) => Response::new(true, json),
Err(err) => Response::new(false, format!("Failed to serialize full state: {}", err)),
}
}
}
#[async_trait]
impl Executable for GetHotkeysCommand {
async fn execute(&self) -> Response {
match HotkeyConfig::load() {
Ok(config) => match serde_json::to_string(&config) {
Ok(json) => Response::new(true, json),
Err(err) => Response::new(false, format!("Failed to serialize hotkeys: {}", err)),
},
Err(err) => Response::new(false, format!("Failed to load hotkeys: {}", err)),
}
}
}
#[async_trait]
impl Executable for SetHotkeyCommand {
async fn execute(&self) -> Response {
let Some(slot) = &self.slot else {
return Response::new(false, "Missing slot name");
};
let Some(file_path) = &self.file_path else {
return Response::new(false, "Missing file path");
};
let mut config = match HotkeyConfig::load() {
Ok(c) => c,
Err(err) => return Response::new(false, format!("Failed to load hotkeys: {}", err)),
};
config.set_slot(
slot.clone(),
Request::play(&file_path.to_string_lossy(), false),
);
match config.save() {
Ok(_) => Response::new(true, format!("Hotkey slot '{}' set", slot)),
Err(err) => Response::new(false, format!("Failed to save hotkeys: {}", err)),
}
}
}
#[async_trait]
impl Executable for SetHotkeyActionCommand {
async fn execute(&self) -> Response {
let Some(slot) = &self.slot else {
return Response::new(false, "Missing slot name");
};
let Some(action) = &self.action else {
return Response::new(false, "Missing or invalid action");
};
let mut config = match HotkeyConfig::load() {
Ok(c) => c,
Err(err) => return Response::new(false, format!("Failed to load hotkeys: {}", err)),
};
config.set_slot(slot.clone(), action.clone());
match config.save() {
Ok(_) => Response::new(true, format!("Hotkey slot '{}' set", slot)),
Err(err) => Response::new(false, format!("Failed to save hotkeys: {}", err)),
}
}
}
#[async_trait]
impl Executable for SetHotkeyKeyCommand {
async fn execute(&self) -> Response {
let Some(slot) = &self.slot else {
return Response::new(false, "Missing slot name");
};
let Some(key_chord) = &self.key_chord else {
return Response::new(false, "Missing key chord");
};
let mut config = match HotkeyConfig::load() {
Ok(c) => c,
Err(err) => return Response::new(false, format!("Failed to load hotkeys: {}", err)),
};
if !config.set_key_chord(slot, Some(key_chord.clone())) {
return Response::new(false, format!("Slot '{}' not found", slot));
}
match config.save() {
Ok(_) => Response::new(
true,
format!("Key chord for slot '{}' set to '{}'", slot, key_chord),
),
Err(err) => Response::new(false, format!("Failed to save hotkeys: {}", err)),
}
}
}
#[async_trait]
impl Executable for SetHotkeyActionAndKeyCommand {
async fn execute(&self) -> Response {
let Some(slot) = &self.slot else {
return Response::new(false, "Missing slot name");
};
let Some(action) = &self.action else {
return Response::new(false, "Missing or invalid action");
};
let Some(key_chord) = &self.key_chord else {
return Response::new(false, "Missing key chord");
};
let mut config = match HotkeyConfig::load() {
Ok(c) => c,
Err(err) => return Response::new(false, format!("Failed to load hotkeys: {}", err)),
};
// Set the action and then the key chord
config.set_slot(slot.clone(), action.clone());
if !config.set_key_chord(slot, Some(key_chord.clone())) {
return Response::new(
false,
format!("Slot '{}' not found after setting action", slot),
);
}
match config.save() {
Ok(_) => Response::new(
true,
format!(
"Hotkey slot '{}' set with action and key chord '{}'",
slot, key_chord
),
),
Err(err) => Response::new(false, format!("Failed to save hotkeys: {}", err)),
}
}
}
#[async_trait]
impl Executable for PlayHotkeyCommand {
async fn execute(&self) -> Response {
let Some(slot) = &self.slot else {
return Response::new(false, "Missing slot name");
};
let config = match HotkeyConfig::load() {
Ok(c) => c,
Err(err) => return Response::new(false, format!("Failed to load hotkeys: {}", err)),
};
let Some(hotkey_slot) = config.find_slot(slot) else {
return Response::new(false, format!("Slot '{}' not found", slot));
};
let action = hotkey_slot.action.clone();
if let Some(cmd) = parse_command(&action) {
cmd.execute().await
} else {
Response::new(false, "Unknown command in hotkey slot")
}
}
}
#[async_trait]
impl Executable for ClearHotkeyCommand {
async fn execute(&self) -> Response {
let Some(slot) = &self.slot else {
return Response::new(false, "Missing slot name");
};
let mut config = match HotkeyConfig::load() {
Ok(c) => c,
Err(err) => return Response::new(false, format!("Failed to load hotkeys: {}", err)),
};
if config.remove_slot(slot) {
match config.save() {
Ok(_) => Response::new(true, format!("Hotkey slot '{}' cleared", slot)),
Err(err) => Response::new(false, format!("Failed to save hotkeys: {}", err)),
}
} else {
Response::new(false, format!("Slot '{}' not found", slot))
}
}
}
#[async_trait]
impl Executable for ClearHotkeyKeyCommand {
async fn execute(&self) -> Response {
let Some(slot) = &self.slot else {
return Response::new(false, "Missing slot name");
};
let mut config = match HotkeyConfig::load() {
Ok(c) => c,
Err(err) => return Response::new(false, format!("Failed to load hotkeys: {}", err)),
};
if !config.set_key_chord(slot, None) {
return Response::new(false, format!("Slot '{}' not found", slot));
}
match config.save() {
Ok(_) => Response::new(true, format!("Key chord for slot '{}' cleared", slot)),
Err(err) => Response::new(false, format!("Failed to save hotkeys: {}", err)),
}
}
}
+10 -134
View File
@@ -1,9 +1,8 @@
use crate::{types::socket::Request, utils::config::get_config_path}; use crate::utils::config::get_config_path;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{collections::HashMap, error::Error, fs, path::PathBuf}; use std::{collections::HashSet, error::Error, fs, path::PathBuf};
#[derive(Default, Clone, Serialize, Deserialize)] #[derive(Default, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DaemonConfig { pub struct DaemonConfig {
pub default_input_name: Option<String>, pub default_input_name: Option<String>,
pub default_volume: Option<f32>, pub default_volume: Option<f32>,
@@ -12,10 +11,9 @@ pub struct DaemonConfig {
impl DaemonConfig { impl DaemonConfig {
pub fn save_to_file(&self) -> Result<(), Box<dyn Error>> { pub fn save_to_file(&self) -> Result<(), Box<dyn Error>> {
let config_path = get_config_path()?.join("daemon.json"); let config_path = get_config_path()?.join("daemon.json");
let config_dir = config_path.parent().unwrap();
if let Some(config_dir) = config_path.parent() if !config_path.exists() {
&& !config_path.exists()
{
fs::create_dir_all(config_dir)?; fs::create_dir_all(config_dir)?;
} }
@@ -27,39 +25,31 @@ impl DaemonConfig {
pub fn load_from_file() -> Result<DaemonConfig, Box<dyn Error>> { pub fn load_from_file() -> Result<DaemonConfig, Box<dyn Error>> {
let config_path = get_config_path()?.join("daemon.json"); let config_path = get_config_path()?.join("daemon.json");
let bytes = fs::read(config_path)?; let bytes = fs::read(config_path)?;
match serde_json::from_slice::<DaemonConfig>(&bytes) { Ok(serde_json::from_slice::<DaemonConfig>(&bytes)?)
Ok(config) => Ok(config),
Err(_) => Ok(DaemonConfig::default()),
}
} }
} }
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct GuiConfig { pub struct GuiConfig {
pub scale_factor: f32, pub scale_factor: f32,
pub left_panel_width: f32,
pub save_volume: bool, pub save_volume: bool,
pub save_input: bool, pub save_input: bool,
pub save_scale_factor: bool, pub save_scale_factor: bool,
pub pause_on_exit: bool,
pub dirs: Vec<PathBuf>, pub dirs: HashSet<PathBuf>,
} }
impl Default for GuiConfig { impl Default for GuiConfig {
fn default() -> Self { fn default() -> Self {
GuiConfig { GuiConfig {
scale_factor: 1.0, scale_factor: 1.0,
left_panel_width: 280.0,
save_volume: false, save_volume: false,
save_input: false, save_input: false,
save_scale_factor: false, save_scale_factor: false,
pause_on_exit: false,
dirs: vec![], dirs: HashSet::default(),
} }
} }
} }
@@ -67,10 +57,9 @@ impl Default for GuiConfig {
impl GuiConfig { impl GuiConfig {
pub fn save_to_file(&mut self) -> Result<(), Box<dyn Error>> { pub fn save_to_file(&mut self) -> Result<(), Box<dyn Error>> {
let config_path = get_config_path()?.join("gui.json"); let config_path = get_config_path()?.join("gui.json");
let config_dir = config_path.parent().unwrap();
if let Some(config_dir) = config_path.parent() if !config_path.exists() {
&& !config_path.exists()
{
fs::create_dir_all(config_dir)?; fs::create_dir_all(config_dir)?;
} }
@@ -87,119 +76,6 @@ impl GuiConfig {
pub fn load_from_file() -> Result<GuiConfig, Box<dyn Error>> { pub fn load_from_file() -> Result<GuiConfig, Box<dyn Error>> {
let config_path = get_config_path()?.join("gui.json"); let config_path = get_config_path()?.join("gui.json");
let bytes = fs::read(config_path)?; let bytes = fs::read(config_path)?;
match serde_json::from_slice::<GuiConfig>(&bytes) { Ok(serde_json::from_slice::<GuiConfig>(&bytes)?)
Ok(config) => Ok(config),
Err(_) => Ok(GuiConfig::default()),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HotkeySlot {
pub slot: String,
pub action: Request,
pub key_chord: Option<String>,
}
#[derive(Default, Clone, Debug, Serialize, Deserialize)]
pub struct HotkeyConfig {
#[serde(default)]
pub slots: Vec<HotkeySlot>,
}
impl HotkeyConfig {
pub fn config_path() -> Result<PathBuf, Box<dyn Error>> {
Ok(get_config_path()?.join("hotkeys.json"))
}
pub fn load() -> Result<HotkeyConfig, Box<dyn Error>> {
let path = Self::config_path()?;
if !path.exists() {
return Ok(HotkeyConfig::default());
}
let bytes = fs::read(&path)?;
match serde_json::from_slice::<HotkeyConfig>(&bytes) {
Ok(config) => Ok(config),
Err(e) => Err(e.into()),
}
}
pub fn save(&self) -> Result<(), Box<dyn Error>> {
let path = Self::config_path()?;
if let Some(dir) = path.parent()
&& !dir.exists()
{
fs::create_dir_all(dir)?;
}
let json = serde_json::to_string_pretty(self)?;
fs::write(path, json.as_bytes())?;
Ok(())
}
pub fn find_slot(&self, slot: &str) -> Option<&HotkeySlot> {
self.slots.iter().find(|s| s.slot == slot)
}
pub fn find_slot_mut(&mut self, slot: &str) -> Option<&mut HotkeySlot> {
self.slots.iter_mut().find(|s| s.slot == slot)
}
pub fn set_slot(&mut self, slot: String, action: Request) {
if let Some(existing) = self.find_slot_mut(&slot) {
existing.action = action;
} else {
self.slots.push(HotkeySlot {
slot,
action,
key_chord: None,
});
}
}
pub fn set_key_chord(&mut self, slot: &str, key_chord: Option<String>) -> bool {
if let Some(existing) = self.find_slot_mut(slot) {
existing.key_chord = key_chord;
true
} else {
false
}
}
pub fn remove_slot(&mut self, slot: &str) -> bool {
let len = self.slots.len();
self.slots.retain(|s| s.slot != slot);
self.slots.len() != len
}
/// Returns pairs of slot names that share the same key chord.
pub fn find_conflicts(&self) -> Vec<(&str, &str)> {
let mut conflicts = vec![];
let mut chord_map: HashMap<&str, Vec<&str>> = HashMap::new();
for s in &self.slots {
if let Some(chord) = &s.key_chord {
chord_map.entry(chord.as_str()).or_default().push(&s.slot);
}
}
for slots in chord_map.values() {
if slots.len() > 1 {
for i in 0..slots.len() {
for j in (i + 1)..slots.len() {
conflicts.push((slots[i], slots[j]));
}
}
}
}
conflicts
}
/// Find which slot(s) have the given key chord.
pub fn slots_for_chord(&self, chord: &str) -> Vec<&HotkeySlot> {
self.slots
.iter()
.filter(|s| s.key_chord.as_deref() == Some(chord))
.collect()
} }
} }
+12 -46
View File
@@ -1,73 +1,39 @@
use crate::types::{ use crate::types::audio_player::PlayerState;
audio_player::{PlayerState, TrackInfo},
config::HotkeyConfig,
};
use egui::Id;
use std::{ use std::{
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
path::PathBuf, path::PathBuf,
time::Instant,
}; };
#[derive(Default, Debug)] #[derive(Default, Debug)]
pub struct TrackUiState { pub struct AppState {
pub search_query: String,
pub position_slider_value: f32, pub position_slider_value: f32,
pub volume_slider_value: f32, pub volume_slider_value: f32,
pub position_dragged: bool, pub position_dragged: bool,
pub volume_dragged: bool, pub volume_dragged: bool,
pub ignore_position_update_until: Option<Instant>,
pub ignore_volume_update_until: Option<Instant>,
}
#[derive(Default, Debug)]
pub struct AppState {
pub search_query: String,
pub track_ui_states: HashMap<u32, TrackUiState>,
pub show_settings: bool, pub show_settings: bool,
pub volume_dragged: bool,
pub force_focus_search: bool,
pub volume_slider_value: f32,
pub search_field_id: Option<Id>,
pub ignore_volume_update_until: Option<Instant>,
pub current_dir: Option<PathBuf>, pub current_dir: Option<PathBuf>,
pub dirs: Vec<PathBuf>, pub dirs: HashSet<PathBuf>,
pub dirs_to_remove: HashSet<PathBuf>,
pub selected_file: Option<PathBuf>,
pub files: HashSet<PathBuf>,
pub show_hotkeys: bool,
pub hotkey_config: HotkeyConfig,
pub hotkey_search_query: String,
pub assigning_hotkey_slot: Option<String>,
pub assigning_hotkey_for_file: Option<PathBuf>,
pub hotkey_capture_active: bool,
} }
#[derive(Default, Debug, Clone)] #[derive(Default, Debug, Clone)]
pub struct AudioPlayerState { pub struct AudioPlayerState {
pub state: PlayerState, pub state: PlayerState,
pub new_state: Option<PlayerState>, pub new_state: Option<PlayerState>,
pub current_file_path: PathBuf,
pub tracks: Vec<TrackInfo>, pub is_paused: bool,
pub volume: f32, // Master volume pub volume: f32,
pub new_volume: Option<f32>,
pub position: f32,
pub new_position: Option<f32>,
pub duration: f32,
pub current_input: String, pub current_input: String,
pub all_inputs: HashMap<String, String>, pub all_inputs: HashMap<String, String>,
pub all_inputs_sorted: Vec<(String, String)>,
pub is_daemon_running: bool,
pub hotkey_config: Option<HotkeyConfig>,
} }
+21 -160
View File
@@ -1,9 +1,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
pub const MAX_MESSAGE_SIZE: usize = 128 * 1024; #[derive(Default, Debug, Clone, Serialize, Deserialize)]
#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Request { pub struct Request {
pub name: String, pub name: String,
pub args: HashMap<String, String>, pub args: HashMap<String, String>,
@@ -26,100 +24,44 @@ impl Request {
Request::new("ping", vec![]) Request::new("ping", vec![])
} }
pub fn kill() -> Self { pub fn pause() -> Self {
Request::new("kill", vec![]) Request::new("pause", vec![])
} }
pub fn pause(id: Option<u32>) -> Self { pub fn resume() -> Self {
let mut args = vec![]; Request::new("resume", vec![])
let id_str;
if let Some(id) = id {
id_str = id.to_string();
args.push(("id", id_str.as_str()));
}
Request::new("pause", args)
} }
pub fn resume(id: Option<u32>) -> Self { pub fn stop() -> Self {
let mut args = vec![]; Request::new("stop", vec![])
let id_str;
if let Some(id) = id {
id_str = id.to_string();
args.push(("id", id_str.as_str()));
}
Request::new("resume", args)
} }
pub fn toggle_pause(id: Option<u32>) -> Self { pub fn play(file_path: &str) -> Self {
let mut args = vec![]; Request::new("play", vec![("file_path", file_path)])
let id_str;
if let Some(id) = id {
id_str = id.to_string();
args.push(("id", id_str.as_str()));
}
Request::new("toggle_pause", args)
}
pub fn stop(id: Option<u32>) -> Self {
let mut args = vec![];
let id_str;
if let Some(id) = id {
id_str = id.to_string();
args.push(("id", id_str.as_str()));
}
Request::new("stop", args)
}
pub fn play(file_path: &str, concurrent: bool) -> Self {
Request::new(
"play",
vec![
("file_path", file_path),
("concurrent", &concurrent.to_string()),
],
)
} }
pub fn get_is_paused() -> Self { pub fn get_is_paused() -> Self {
Request::new("is_paused", vec![]) Request::new("is_paused", vec![])
} }
pub fn get_volume(id: Option<u32>) -> Self { pub fn get_volume() -> Self {
let mut args = vec![]; Request::new("get_volume", vec![])
let id_str;
if let Some(id) = id {
id_str = id.to_string();
args.push(("id", id_str.as_str()));
}
Request::new("get_volume", args)
} }
pub fn get_position(id: Option<u32>) -> Self { pub fn get_position() -> Self {
let mut args = vec![]; Request::new("get_position", vec![])
let id_str;
if let Some(id) = id {
id_str = id.to_string();
args.push(("id", id_str.as_str()));
}
Request::new("get_position", args)
} }
pub fn get_duration(id: Option<u32>) -> Self { pub fn get_duration() -> Self {
let mut args = vec![]; Request::new("get_duration", vec![])
let id_str;
if let Some(id) = id {
id_str = id.to_string();
args.push(("id", id_str.as_str()));
}
Request::new("get_duration", args)
} }
pub fn get_state() -> Self { pub fn get_state() -> Self {
Request::new("get_state", vec![]) Request::new("get_state", vec![])
} }
pub fn get_tracks() -> Self { pub fn get_current_file_path() -> Self {
Request::new("get_tracks", vec![]) Request::new("get_current_file_path", vec![])
} }
pub fn get_input() -> Self { pub fn get_input() -> Self {
@@ -130,98 +72,17 @@ impl Request {
Request::new("get_inputs", vec![]) Request::new("get_inputs", vec![])
} }
pub fn set_volume(volume: f32, id: Option<u32>) -> Self { pub fn set_volume(volume: f32) -> Self {
let mut args = vec![("volume".to_string(), volume.to_string())]; Request::new("set_volume", vec![("volume", &volume.to_string())])
if let Some(id) = id {
args.push(("id".to_string(), id.to_string()));
}
Request::new("set_volume".to_string(), args)
} }
pub fn seek(position: f32, id: Option<u32>) -> Self { pub fn seek(position: f32) -> Self {
let mut args = vec![("position".to_string(), position.to_string())]; Request::new("seek", vec![("position", &position.to_string())])
if let Some(id) = id {
args.push(("id".to_string(), id.to_string()));
}
Request::new("seek".to_string(), args)
} }
pub fn set_input(name: &str) -> Self { pub fn set_input(name: &str) -> Self {
Request::new("set_input", vec![("input_name", name)]) Request::new("set_input", vec![("input_name", name)])
} }
pub fn set_loop(enabled: &str, id: Option<u32>) -> Self {
let mut args = vec![("enabled".to_string(), enabled.to_string())];
if let Some(id) = id {
args.push(("id".to_string(), id.to_string()));
}
Request::new("set_loop".to_string(), args)
}
pub fn toggle_loop(id: Option<u32>) -> Self {
let mut args = vec![];
let id_str;
if let Some(id) = id {
id_str = id.to_string();
args.push(("id", id_str.as_str()));
}
Request::new("toggle_loop", args)
}
pub fn get_daemon_version() -> Self {
Request::new("get_daemon_version", vec![])
}
pub fn get_full_state() -> Self {
Request::new("get_full_state", vec![])
}
pub fn get_hotkeys() -> Self {
Request::new("get_hotkeys", vec![])
}
pub fn set_hotkey(slot: &str, file_path: &str) -> Self {
Request::new("set_hotkey", vec![("slot", slot), ("file_path", file_path)])
}
pub fn set_hotkey_key(slot: &str, key_chord: &str) -> Self {
Request::new(
"set_hotkey_key",
vec![("slot", slot), ("key_chord", key_chord)],
)
}
pub fn clear_hotkey(slot: &str) -> Self {
Request::new("clear_hotkey", vec![("slot", slot)])
}
pub fn play_hotkey(slot: &str) -> Self {
Request::new("play_hotkey", vec![("slot", slot)])
}
pub fn set_hotkey_action(slot: &str, action: &Request) -> Self {
let action_json = serde_json::to_string(action).unwrap_or_default();
Request::new(
"set_hotkey_action",
vec![("slot", slot), ("action", &action_json)],
)
}
pub fn clear_hotkey_key(slot: &str) -> Self {
Request::new("clear_hotkey_key", vec![("slot", slot)])
}
pub fn set_hotkey_action_and_key(slot: &str, action: &Request, key_chord: &str) -> Self {
let action_json = serde_json::to_string(action).unwrap_or_default();
Request::new(
"set_hotkey_action_and_key",
vec![
("slot", slot),
("action", &action_json),
("key_chord", key_chord),
],
)
}
} }
#[derive(Default, Debug, Clone, Serialize, Deserialize)] #[derive(Default, Debug, Clone, Serialize, Deserialize)]
+10 -82
View File
@@ -3,18 +3,14 @@ use crate::types::{commands::*, socket::Request};
use std::path::PathBuf; use std::path::PathBuf;
pub fn parse_command(request: &Request) -> Option<Box<dyn Executable + Send>> { pub fn parse_command(request: &Request) -> Option<Box<dyn Executable + Send>> {
let id = request.args.get("id").and_then(|s| s.parse::<u32>().ok());
match request.name.as_str() { match request.name.as_str() {
"ping" => Some(Box::new(PingCommand {})), "ping" => Some(Box::new(PingCommand {})),
"kill" => Some(Box::new(KillCommand {})), "pause" => Some(Box::new(PauseCommand {})),
"pause" => Some(Box::new(PauseCommand { id })), "resume" => Some(Box::new(ResumeCommand {})),
"resume" => Some(Box::new(ResumeCommand { id })), "stop" => Some(Box::new(StopCommand {})),
"toggle_pause" => Some(Box::new(TogglePauseCommand { id })),
"stop" => Some(Box::new(StopCommand { id })),
"is_paused" => Some(Box::new(IsPausedCommand {})), "is_paused" => Some(Box::new(IsPausedCommand {})),
"get_state" => Some(Box::new(GetStateCommand {})), "get_state" => Some(Box::new(GetStateCommand {})),
"get_volume" => Some(Box::new(GetVolumeCommand { id })), "get_volume" => Some(Box::new(GetVolumeCommand {})),
"set_volume" => { "set_volume" => {
let volume = request let volume = request
.args .args
@@ -22,9 +18,9 @@ pub fn parse_command(request: &Request) -> Option<Box<dyn Executable + Send>> {
.unwrap_or(&String::new()) .unwrap_or(&String::new())
.parse::<f32>() .parse::<f32>()
.ok(); .ok();
Some(Box::new(SetVolumeCommand { volume, id })) Some(Box::new(SetVolumeCommand { volume }))
} }
"get_position" => Some(Box::new(GetPositionCommand { id })), "get_position" => Some(Box::new(GetPositionCommand {})),
"seek" => { "seek" => {
let position = request let position = request
.args .args
@@ -32,9 +28,9 @@ pub fn parse_command(request: &Request) -> Option<Box<dyn Executable + Send>> {
.unwrap_or(&String::new()) .unwrap_or(&String::new())
.parse::<f32>() .parse::<f32>()
.ok(); .ok();
Some(Box::new(SeekCommand { position, id })) Some(Box::new(SeekCommand { position }))
} }
"get_duration" => Some(Box::new(GetDurationCommand { id })), "get_duration" => Some(Box::new(GetDurationCommand {})),
"play" => { "play" => {
let file_path = request let file_path = request
.args .args
@@ -42,83 +38,15 @@ pub fn parse_command(request: &Request) -> Option<Box<dyn Executable + Send>> {
.unwrap_or(&String::new()) .unwrap_or(&String::new())
.parse::<PathBuf>() .parse::<PathBuf>()
.ok(); .ok();
let concurrent = request Some(Box::new(PlayCommand { file_path }))
.args
.get("concurrent")
.unwrap_or(&String::new())
.parse::<bool>()
.ok();
Some(Box::new(PlayCommand {
file_path,
concurrent,
}))
} }
"get_tracks" => Some(Box::new(GetTracksCommand {})), "get_current_file_path" => Some(Box::new(GetCurrentFilePathCommand {})),
"get_input" => Some(Box::new(GetCurrentInputCommand {})), "get_input" => Some(Box::new(GetCurrentInputCommand {})),
"get_inputs" => Some(Box::new(GetAllInputsCommand {})), "get_inputs" => Some(Box::new(GetAllInputsCommand {})),
"set_input" => { "set_input" => {
let name = Some(request.args.get("input_name").unwrap_or(&String::new())).cloned(); let name = Some(request.args.get("input_name").unwrap_or(&String::new())).cloned();
Some(Box::new(SetCurrentInputCommand { name })) Some(Box::new(SetCurrentInputCommand { name }))
} }
"set_loop" => {
let enabled = request
.args
.get("enabled")
.unwrap_or(&String::new())
.parse::<bool>()
.ok();
Some(Box::new(SetLoopCommand { enabled, id }))
}
"toggle_loop" => Some(Box::new(ToggleLoopCommand { id })),
"get_daemon_version" => Some(Box::new(GetDaemonVersionCommand {})),
"get_full_state" => Some(Box::new(GetFullStateCommand {})),
"get_hotkeys" => Some(Box::new(GetHotkeysCommand {})),
"set_hotkey" => {
let slot = request.args.get("slot").cloned();
let file_path = request
.args
.get("file_path")
.and_then(|s| s.parse::<PathBuf>().ok());
Some(Box::new(SetHotkeyCommand { slot, file_path }))
}
"set_hotkey_key" => {
let slot = request.args.get("slot").cloned();
let key_chord = request.args.get("key_chord").cloned();
Some(Box::new(SetHotkeyKeyCommand { slot, key_chord }))
}
"clear_hotkey" => {
let slot = request.args.get("slot").cloned();
Some(Box::new(ClearHotkeyCommand { slot }))
}
"play_hotkey" => {
let slot = request.args.get("slot").cloned();
Some(Box::new(PlayHotkeyCommand { slot }))
}
"set_hotkey_action" => {
let slot = request.args.get("slot").cloned();
let action = request
.args
.get("action")
.and_then(|s| serde_json::from_str::<Request>(s).ok());
Some(Box::new(SetHotkeyActionCommand { slot, action }))
}
"clear_hotkey_key" => {
let slot = request.args.get("slot").cloned();
Some(Box::new(ClearHotkeyKeyCommand { slot }))
}
"set_hotkey_action_and_key" => {
let slot = request.args.get("slot").cloned();
let action = request
.args
.get("action")
.and_then(|s| serde_json::from_str::<Request>(s).ok());
let key_chord = request.args.get("key_chord").cloned();
Some(Box::new(SetHotkeyActionAndKeyCommand {
slot,
action,
key_chord,
}))
}
_ => None, _ => None,
} }
} }
+63 -20
View File
@@ -1,10 +1,12 @@
use crate::types::{ use crate::{
types::{
audio_player::AudioPlayer, audio_player::AudioPlayer,
config::DaemonConfig, config::DaemonConfig,
socket::{MAX_MESSAGE_SIZE, Request, Response}, pipewire::AudioDevice,
socket::{Request, Response},
},
utils::pipewire::{create_link, get_all_devices},
}; };
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf; use std::path::PathBuf;
use std::{error::Error, fs}; use std::{error::Error, fs};
use tokio::{ use tokio::{
@@ -16,14 +18,11 @@ use tokio::{
static AUDIO_PLAYER: OnceCell<Mutex<AudioPlayer>> = OnceCell::const_new(); static AUDIO_PLAYER: OnceCell<Mutex<AudioPlayer>> = OnceCell::const_new();
pub async fn get_audio_player() -> Result<&'static Mutex<AudioPlayer>, String> { pub async fn get_audio_player() -> &'static Mutex<AudioPlayer> {
AUDIO_PLAYER AUDIO_PLAYER
.get_or_try_init(|| async { .get_or_init(|| async {
println!("Initializing audio player"); println!("Initializing audio player");
match AudioPlayer::new().await { Mutex::new(AudioPlayer::new().await.unwrap())
Ok(player) => Ok(Mutex::new(player)),
Err(err) => Err(err.to_string()),
}
}) })
.await .await
} }
@@ -36,6 +35,59 @@ pub fn get_daemon_config() -> DaemonConfig {
}) })
} }
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 { pub fn get_runtime_dir() -> PathBuf {
dirs::runtime_dir().unwrap_or(PathBuf::from("/run/pwsp")) dirs::runtime_dir().unwrap_or(PathBuf::from("/run/pwsp"))
} }
@@ -45,7 +97,6 @@ pub fn create_runtime_dir() -> Result<(), Box<dyn Error>> {
if !runtime_dir.exists() { if !runtime_dir.exists() {
fs::create_dir_all(&runtime_dir)?; fs::create_dir_all(&runtime_dir)?;
} }
fs::set_permissions(&runtime_dir, fs::Permissions::from_mode(0o700))?;
Ok(()) Ok(())
} }
@@ -73,7 +124,7 @@ pub async fn wait_for_daemon() -> Result<(), Box<dyn Error>> {
Ok(()) Ok(())
} }
pub async fn make_request(request: Request) -> Result<Response, Box<dyn Error + Send + Sync>> { pub async fn make_request(request: Request) -> Result<Response, Box<dyn Error>> {
let socket_path = get_runtime_dir().join("daemon.sock"); let socket_path = get_runtime_dir().join("daemon.sock");
let mut stream = UnixStream::connect(socket_path).await?; let mut stream = UnixStream::connect(socket_path).await?;
@@ -95,14 +146,6 @@ pub async fn make_request(request: Request) -> Result<Response, Box<dyn Error +
} }
let response_len = u32::from_le_bytes(len_bytes) as usize; let response_len = u32::from_le_bytes(len_bytes) as usize;
if response_len > MAX_MESSAGE_SIZE {
eprintln!(
"Failed to read response from daemon: response too large ({} bytes)!",
response_len
);
return Err("Response too large".into());
}
let mut buffer = vec![0u8; response_len]; let mut buffer = vec![0u8; response_len];
if stream.read_exact(&mut buffer).await.is_err() { if stream.read_exact(&mut buffer).await.is_err() {
return Err("Failed to read response".into()); return Err("Failed to read response".into());
-201
View File
@@ -1,201 +0,0 @@
use crate::{types::config::HotkeyConfig, utils::commands::parse_command};
use evdev::{Device, EventStream, EventSummary, KeyCode};
struct ModifierState {
ctrl: bool,
alt: bool,
shift: bool,
meta: bool,
}
impl ModifierState {
fn new() -> Self {
Self {
ctrl: false,
alt: false,
shift: false,
meta: false,
}
}
fn update(&mut self, key: KeyCode, pressed: bool) {
match key {
KeyCode::KEY_LEFTCTRL | KeyCode::KEY_RIGHTCTRL => self.ctrl = pressed,
KeyCode::KEY_LEFTALT | KeyCode::KEY_RIGHTALT => self.alt = pressed,
KeyCode::KEY_LEFTSHIFT | KeyCode::KEY_RIGHTSHIFT => self.shift = pressed,
KeyCode::KEY_LEFTMETA | KeyCode::KEY_RIGHTMETA => self.meta = pressed,
_ => {}
}
}
fn any_active(&self) -> bool {
self.ctrl || self.alt || self.shift || self.meta
}
fn is_modifier(key: KeyCode) -> bool {
matches!(
key,
KeyCode::KEY_LEFTCTRL
| KeyCode::KEY_RIGHTCTRL
| KeyCode::KEY_LEFTALT
| KeyCode::KEY_RIGHTALT
| KeyCode::KEY_LEFTSHIFT
| KeyCode::KEY_RIGHTSHIFT
| KeyCode::KEY_LEFTMETA
| KeyCode::KEY_RIGHTMETA
)
}
}
fn evdev_key_name(key: KeyCode) -> Option<&'static str> {
match key {
KeyCode::KEY_A => Some("A"),
KeyCode::KEY_B => Some("B"),
KeyCode::KEY_C => Some("C"),
KeyCode::KEY_D => Some("D"),
KeyCode::KEY_E => Some("E"),
KeyCode::KEY_F => Some("F"),
KeyCode::KEY_G => Some("G"),
KeyCode::KEY_H => Some("H"),
KeyCode::KEY_I => Some("I"),
KeyCode::KEY_J => Some("J"),
KeyCode::KEY_K => Some("K"),
KeyCode::KEY_L => Some("L"),
KeyCode::KEY_M => Some("M"),
KeyCode::KEY_N => Some("N"),
KeyCode::KEY_O => Some("O"),
KeyCode::KEY_P => Some("P"),
KeyCode::KEY_Q => Some("Q"),
KeyCode::KEY_R => Some("R"),
KeyCode::KEY_S => Some("S"),
KeyCode::KEY_T => Some("T"),
KeyCode::KEY_U => Some("U"),
KeyCode::KEY_V => Some("V"),
KeyCode::KEY_W => Some("W"),
KeyCode::KEY_X => Some("X"),
KeyCode::KEY_Y => Some("Y"),
KeyCode::KEY_Z => Some("Z"),
KeyCode::KEY_1 => Some("1"),
KeyCode::KEY_2 => Some("2"),
KeyCode::KEY_3 => Some("3"),
KeyCode::KEY_4 => Some("4"),
KeyCode::KEY_5 => Some("5"),
KeyCode::KEY_6 => Some("6"),
KeyCode::KEY_7 => Some("7"),
KeyCode::KEY_8 => Some("8"),
KeyCode::KEY_9 => Some("9"),
KeyCode::KEY_0 => Some("0"),
KeyCode::KEY_F1 => Some("F1"),
KeyCode::KEY_F2 => Some("F2"),
KeyCode::KEY_F3 => Some("F3"),
KeyCode::KEY_F4 => Some("F4"),
KeyCode::KEY_F5 => Some("F5"),
KeyCode::KEY_F6 => Some("F6"),
KeyCode::KEY_F7 => Some("F7"),
KeyCode::KEY_F8 => Some("F8"),
KeyCode::KEY_F9 => Some("F9"),
KeyCode::KEY_F10 => Some("F10"),
KeyCode::KEY_F11 => Some("F11"),
KeyCode::KEY_F12 => Some("F12"),
_ => None,
}
}
fn build_chord(modifiers: &ModifierState, key_name: &str) -> String {
let mut parts = Vec::with_capacity(5);
if modifiers.ctrl {
parts.push("Ctrl");
}
if modifiers.alt {
parts.push("Alt");
}
if modifiers.shift {
parts.push("Shift");
}
if modifiers.meta {
parts.push("Super");
}
parts.push(key_name);
parts.join("+")
}
fn is_keyboard(device: &Device) -> bool {
device
.supported_keys()
.is_some_and(|keys| keys.contains(KeyCode::KEY_A) && keys.contains(KeyCode::KEY_Z))
}
async fn handle_device_events(mut stream: EventStream) {
let mut modifiers = ModifierState::new();
loop {
match stream.next_event().await {
Ok(event) => {
if let EventSummary::Key(_, key, value) = event.destructure() {
// 0 = released, 1 = pressed, 2 = repeat
if value == 0 || value == 1 {
modifiers.update(key, value == 1);
}
// Only trigger on press, skip modifiers and bare keys
if value != 1 || ModifierState::is_modifier(key) || !modifiers.any_active() {
continue;
}
let Some(key_name) = evdev_key_name(key) else {
continue;
};
let chord = build_chord(&modifiers, key_name);
let config = match HotkeyConfig::load() {
Ok(c) => c,
Err(_) => continue,
};
let slots = config.slots_for_chord(&chord);
for slot in slots {
if let Some(cmd) = parse_command(&slot.action) {
cmd.execute().await;
}
}
}
}
Err(e) => {
eprintln!("Global hotkeys: device read error: {e}");
break;
}
}
}
}
pub async fn start_global_hotkey_listener() {
let keyboards: Vec<_> = evdev::enumerate()
.filter(|(_, dev)| is_keyboard(dev))
.collect();
if keyboards.is_empty() {
eprintln!(
"Global hotkeys: no keyboard devices found. \
Make sure your user is in the 'input' group."
);
return;
}
println!(
"Global hotkeys: found {} keyboard device(s)",
keyboards.len()
);
for (path, device) in keyboards {
match device.into_event_stream() {
Ok(stream) => {
println!("Global hotkeys: listening on {}", path.display());
tokio::spawn(handle_device_events(stream));
}
Err(e) => {
eprintln!("Global hotkeys: failed to open {}: {}", path.display(), e);
}
}
}
}
+99 -74
View File
@@ -1,16 +1,17 @@
use crate::{ use crate::{
types::{ types::{
audio_player::FullState, audio_player::PlayerState,
config::{GuiConfig, HotkeyConfig}, config::GuiConfig,
gui::AudioPlayerState, gui::AudioPlayerState,
socket::{Request, Response}, socket::{Request, Response},
}, },
utils::daemon::{is_daemon_running, make_request}, utils::daemon::{make_request, wait_for_daemon},
}; };
use std::{ use std::{
collections::HashMap,
error::Error, error::Error,
path::PathBuf,
sync::{Arc, Mutex}, sync::{Arc, Mutex},
time::Instant,
}; };
use tokio::time::{Duration, sleep}; use tokio::time::{Duration, sleep};
@@ -23,17 +24,7 @@ pub fn get_gui_config() -> GuiConfig {
} }
pub fn make_request_sync(request: Request) -> Result<Response, Box<dyn Error>> { pub fn make_request_sync(request: Request) -> Result<Response, Box<dyn Error>> {
tokio::task::block_in_place(|| { futures::executor::block_on(make_request(request))
tokio::runtime::Handle::current()
.block_on(make_request(request))
.map_err(|e| e as Box<dyn Error>)
})
}
pub fn make_request_async(request: Request) {
tokio::spawn(async move {
make_request(request).await.ok();
});
} }
pub fn format_time_pair(position: f32, duration: f32) -> String { pub fn format_time_pair(position: f32, duration: f32) -> String {
@@ -49,78 +40,112 @@ pub fn format_time_pair(position: f32, duration: f32) -> String {
pub fn start_app_state_thread(audio_player_state_shared: Arc<Mutex<AudioPlayerState>>) { pub fn start_app_state_thread(audio_player_state_shared: Arc<Mutex<AudioPlayerState>>) {
tokio::spawn(async move { tokio::spawn(async move {
let sleep_duration = Duration::from_secs_f32(1.0 / 60.0); let sleep_duration = Duration::from_millis(100);
let mut last_hotkey_poll = Instant::now();
loop { loop {
let is_running = is_daemon_running().unwrap_or(false); 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(),
};
if !is_running {
{ {
let mut guard = audio_player_state_shared let mut guard = audio_player_state_shared.lock().unwrap();
.lock()
.unwrap_or_else(|e| e.into_inner());
guard.is_daemon_running = false;
}
sleep(Duration::from_millis(500)).await;
continue;
}
let full_state_req = Request::get_full_state();
let full_state_res = make_request(full_state_req).await.unwrap_or_default();
if full_state_res.status {
let full_state: FullState =
serde_json::from_str(&full_state_res.message).unwrap_or_default();
let mut guard = audio_player_state_shared
.lock()
.unwrap_or_else(|e| e.into_inner());
guard.state = match guard.new_state.clone() { guard.state = match guard.new_state.clone() {
Some(new_state) => { Some(new_state) => {
guard.new_state = None; guard.new_state = None;
new_state new_state
} }
None => full_state.state, None => state,
}; };
guard.tracks = full_state.tracks; guard.current_file_path = file_path;
guard.volume = full_state.volume; guard.is_paused = is_paused;
guard.current_input = full_state guard.volume = match guard.new_volume {
.current_input Some(new_volume) => {
.split(" - ") guard.new_volume = None;
.next() new_volume
.unwrap_or_default()
.to_string();
if guard.all_inputs != full_state.all_inputs {
guard.all_inputs = full_state.all_inputs;
let mut sorted: Vec<(String, String)> = guard
.all_inputs
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
sorted.sort_by(|a, b| a.0.cmp(&b.0));
guard.all_inputs_sorted = sorted;
} }
None => volume,
guard.is_daemon_running = true; };
guard.position = match guard.new_position {
Some(new_position) => {
guard.new_position = None;
new_position
} }
None => position,
// Poll hotkey config at a lower frequency (~every 2 seconds) };
if last_hotkey_poll.elapsed() >= Duration::from_secs(2) { guard.duration = if duration > 0.0 { duration } else { 1.0 };
let hotkey_res = make_request(Request::get_hotkeys()) guard.current_input = current_input;
.await guard.all_inputs = all_inputs;
.unwrap_or_default();
if hotkey_res.status
&& let Ok(config) = serde_json::from_str::<HotkeyConfig>(&hotkey_res.message)
{
let mut guard = audio_player_state_shared
.lock()
.unwrap_or_else(|e| e.into_inner());
guard.hotkey_config = Some(config);
}
last_hotkey_poll = Instant::now();
} }
sleep(sleep_duration).await; sleep(sleep_duration).await;
-1
View File
@@ -1,6 +1,5 @@
pub mod commands; pub mod commands;
pub mod config; pub mod config;
pub mod daemon; pub mod daemon;
pub mod global_hotkeys;
pub mod gui; pub mod gui;
pub mod pipewire; pub mod pipewire;
+24 -72
View File
@@ -9,13 +9,6 @@ use tokio::{
time::{Duration, timeout}, time::{Duration, timeout},
}; };
pub fn setup_pipewire_context() -> (MainLoopRc, ContextRc) {
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");
(main_loop, context)
}
fn parse_global_object( fn parse_global_object(
global_object: &GlobalObject<&DictRef>, global_object: &GlobalObject<&DictRef>,
) -> (Option<AudioDevice>, Option<Port>) { ) -> (Option<AudioDevice>, Option<Port>) {
@@ -63,13 +56,11 @@ fn parse_global_object(
(None, None) (None, None)
}; };
// Check if the object is a port // Check if the object is a port
} else if props.get("port.direction").is_some() } else if props.get("port.direction").is_some() {
&& let (Some(node_id), Some(port_id), Some(port_name)) = ( let node_id = props.get("node.id").unwrap().parse::<u32>().unwrap();
props.get("node.id").and_then(|id| id.parse::<u32>().ok()), let port_id = props.get("port.id").unwrap().parse::<u32>().unwrap();
props.get("port.id").and_then(|id| id.parse::<u32>().ok()), let port_name = props.get("port.name").unwrap();
props.get("port.name"),
)
{
let port = Port { let port = Port {
node_id, node_id,
port_id, port_id,
@@ -86,7 +77,7 @@ async fn pw_get_global_objects_thread(
main_sender: mpsc::Sender<(Option<AudioDevice>, Option<Port>)>, main_sender: mpsc::Sender<(Option<AudioDevice>, Option<Port>)>,
pw_receiver: pipewire::channel::Receiver<Terminate>, pw_receiver: pipewire::channel::Receiver<Terminate>,
) { ) {
let (main_loop, context) = setup_pipewire_context(); let main_loop = MainLoopRc::new(None).expect("Failed to initialize pipewire main loop");
// Stop main loop on Terminate message // Stop main loop on Terminate message
let _receiver = pw_receiver.attach(main_loop.loop_(), { let _receiver = pw_receiver.attach(main_loop.loop_(), {
@@ -94,6 +85,7 @@ async fn pw_get_global_objects_thread(
move |_| _main_loop.quit() move |_| _main_loop.quit()
}); });
let context = ContextRc::new(&main_loop, None).expect("Failed to create pipewire context");
let core = context let core = context
.connect(None) .connect(None)
.expect("Failed to connect to pipewire context"); .expect("Failed to connect to pipewire context");
@@ -157,7 +149,8 @@ pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>), B
for port in ports { for port in ports {
let node_id = port.node_id; let node_id = port.node_id;
if let Some(input_device) = input_devices.get_mut(&node_id) { if input_devices.contains_key(&node_id) {
let input_device = input_devices.get_mut(&node_id).unwrap();
match port.name.as_str() { match port.name.as_str() {
"input_FL" => input_device.input_fl = Some(port), "input_FL" => input_device.input_fl = Some(port),
"input_FR" => input_device.input_fr = Some(port), "input_FR" => input_device.input_fr = Some(port),
@@ -169,13 +162,10 @@ pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>), B
input_device.input_fl = Some(port.clone()); input_device.input_fl = Some(port.clone());
input_device.input_fr = Some(port) input_device.input_fr = Some(port)
} }
"capture_MONO" => {
input_device.output_fl = Some(port.clone());
input_device.output_fr = Some(port);
}
_ => {} _ => {}
} }
} else if let Some(output_device) = output_devices.get_mut(&node_id) { } else if output_devices.contains_key(&node_id) {
let output_device = output_devices.get_mut(&node_id).unwrap();
match port.name.as_str() { match port.name.as_str() {
"input_FL" => output_device.input_fl = Some(port), "input_FL" => output_device.input_fl = Some(port),
"input_FR" => output_device.input_fr = Some(port), "input_FR" => output_device.input_fr = Some(port),
@@ -210,25 +200,24 @@ pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>), B
} }
pub async fn get_device(device_name: &str) -> Result<AudioDevice, Box<dyn Error>> { pub async fn get_device(device_name: &str) -> Result<AudioDevice, Box<dyn Error>> {
let (input_devices, output_devices) = get_all_devices().await?; let (mut input_devices, output_devices) = get_all_devices().await?;
input_devices.extend(output_devices);
input_devices for device in input_devices {
.into_iter() if device.name == device_name {
.chain(output_devices) return Ok(device);
.find(|device| { }
device.name == device_name }
|| device.nick == device_name
|| device.name.contains(device_name) Err("Device not found".into())
|| device.nick.contains(device_name)
})
.ok_or_else(|| "Device not found".into())
} }
pub fn create_virtual_mic() -> Result<pipewire::channel::Sender<Terminate>, Box<dyn Error>> { pub fn create_virtual_mic() -> Result<pipewire::channel::Sender<Terminate>, Box<dyn Error>> {
let (pw_sender, pw_receiver) = pipewire::channel::channel::<Terminate>(); let (pw_sender, pw_receiver) = pipewire::channel::channel::<Terminate>();
let _pw_thread = thread::spawn(move || { let _pw_thread = thread::spawn(move || {
let (main_loop, context) = setup_pipewire_context(); 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 let core = context
.connect(None) .connect(None)
.expect("Failed to connect to pipewire context"); .expect("Failed to connect to pipewire context");
@@ -259,44 +248,6 @@ pub fn create_virtual_mic() -> Result<pipewire::channel::Sender<Terminate>, Box<
Ok(pw_sender) Ok(pw_sender)
} }
pub async fn link_player_to_virtual_mic()
-> Result<pipewire::channel::Sender<Terminate>, Box<dyn Error>> {
let pwsp_daemon_output = match get_device("pwsp-daemon").await {
Ok(device) => device,
Err(_) => {
return Err(
"Could not find alsa_playback.pwsp-daemon device, skipping device linking".into(),
);
}
};
let pwsp_daemon_input = match get_device("pwsp-virtual-mic").await {
Ok(device) => device,
Err(_) => {
return Err("Could not find pwsp-virtual-mic device, skipping device linking".into());
}
};
let output_fl = match pwsp_daemon_output.output_fl {
Some(port) => port,
None => return Err("Failed to get pwsp-daemon output_fl".into()),
};
let output_fr = match pwsp_daemon_output.output_fr {
Some(port) => port,
None => return Err("Failed to get pwsp-daemon output_fr".into()),
};
let input_fl = match pwsp_daemon_input.input_fl {
Some(port) => port,
None => return Err("Failed to get pwsp-virtual-mic input_fl".into()),
};
let input_fr = match pwsp_daemon_input.input_fr {
Some(port) => port,
None => return Err("Failed to get pwsp-virtual-mic input_fr".into()),
};
create_link(output_fl, output_fr, input_fl, input_fr)
}
pub fn create_link( pub fn create_link(
output_fl: Port, output_fl: Port,
output_fr: Port, output_fr: Port,
@@ -306,7 +257,8 @@ pub fn create_link(
let (pw_sender, pw_receiver) = pipewire::channel::channel::<Terminate>(); let (pw_sender, pw_receiver) = pipewire::channel::channel::<Terminate>();
let _pw_thread = thread::spawn(move || { let _pw_thread = thread::spawn(move || {
let (main_loop, context) = setup_pipewire_context(); 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 let core = context
.connect(None) .connect(None)
.expect("Failed to connect to pipewire context"); .expect("Failed to connect to pipewire context");