mirror of
https://github.com/arabianq/pipewire-soundpad.git
synced 2026-06-19 20:23:33 +00:00
Compare commits
326 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ef3f8d76e | |||
| bec77f59bd | |||
| dad1a62798 | |||
| 84a4a01282 | |||
| 88995f6fd1 | |||
| 660ece9866 | |||
| f2dcf2e0fe | |||
| fe655be59a | |||
| 78960cdc10 | |||
| 0439cf815e | |||
| 5ae82ef28c | |||
| 5f69345d45 | |||
| 930857312d | |||
| e884993dba | |||
| 05dd4319cc | |||
| e320c85a6f | |||
| f02bbc1e1c | |||
| 02f1116076 | |||
| 8155cceac8 | |||
| d974a93c04 | |||
| c6d9f2d6e7 | |||
| dc1ecc81ea | |||
| 9b70bcd69d | |||
| a07025b1f6 | |||
| c1d145fbc8 | |||
| 3d4b59761b | |||
| ca9b5dd517 | |||
| 6863c9a6f8 | |||
| 958a3efde5 | |||
| 30e75e924c | |||
| 2b4b7ea730 | |||
| dafe67f35f | |||
| 8fa22ca5b0 | |||
| d72eaabf54 | |||
| 377b218592 | |||
| 911417af40 | |||
| 573958c05b | |||
| 0bb7ef3f33 | |||
| 10f07cd895 | |||
| b2f2894aa1 | |||
| e6c8d720d5 | |||
| a6d93ff528 | |||
| bcf791d84c | |||
| e4b0b10393 | |||
| 11de96db58 | |||
| 7396c0aef8 | |||
| fc2cd5e2da | |||
| 1a37729cf1 | |||
| 86b38a250e | |||
| 54fa278cea | |||
| db040aa820 | |||
| 04449e7525 | |||
| 9f50809a99 | |||
| 7dda4bc2b1 | |||
| 1569955e12 | |||
| 9adc6cfbda | |||
| 76b1d4f345 | |||
| 10f9937dc3 | |||
| 498c09eb50 | |||
| 78e0a133b6 | |||
| 7f8b7194b6 | |||
| 302f153b91 | |||
| f87dcb1564 | |||
| d4d16f6ce7 | |||
| 949307fcf8 | |||
| 2a8fcca06b | |||
| 5c4b8f4b45 | |||
| 70c7e3789b | |||
| 5367a3daae | |||
| 42c0170044 | |||
| cb56cb3a04 | |||
| 5a2418325d | |||
| a948ea2dcd | |||
| a156df346b | |||
| 7a13ae55a6 | |||
| b2b83f5c32 | |||
| f01a0e656c | |||
| 6114b9a7f8 | |||
| b8baeb6226 | |||
| 02306b5893 | |||
| 3add499bd7 | |||
| 3c2e943e18 | |||
| 261f83efd4 | |||
| c6577cd5e0 | |||
| 95761f6a5a | |||
| d6effc972e | |||
| 498d0d25af | |||
| c99d0749e3 | |||
| 151f43f1ab | |||
| 077518019f | |||
| 968eba80e6 | |||
| aa77a8d212 | |||
| 4b50645c93 | |||
| 39648f7781 | |||
| c649ef5410 | |||
| 0dfd841e6d | |||
| 89ce111542 | |||
| 80a8b1a45f | |||
| f5c7d9bb2c | |||
| bcd39eb6a2 | |||
| 47a7674c14 | |||
| d33ee0c69e | |||
| 624310eae5 | |||
| 92a576de37 | |||
| ce948ce678 | |||
| e72fc519a0 | |||
| 8126efe8d9 | |||
| a7dd0b97d1 | |||
| 5f9aad7fa2 | |||
| 7e66a9241b | |||
| 02ad7337a1 | |||
| c08898e4f2 | |||
| ed8b04caa9 | |||
| 58e5f039be | |||
| eb89733715 | |||
| 476fd325ef | |||
| da49c96e53 | |||
| f0e05379f7 | |||
| 3d3523fd7a | |||
| 81da36f03c | |||
| 8bfa5daf78 | |||
| b816d2aa88 | |||
| 23ae562849 | |||
| e3bc1fd55f | |||
| 15964f205b | |||
| 6a0ac61033 | |||
| 4b802273f4 | |||
| baae7a1ccf | |||
| 654694cecf | |||
| 04ecf66beb | |||
| 0fe94f9112 | |||
| 9fbe42c201 | |||
| fac04c4533 | |||
| f93852bf8e | |||
| 1bb0aa959a | |||
| 7a1723fbcb | |||
| 712a0968a7 | |||
| e98e6bc2f3 | |||
| 5007b483aa | |||
| b936b58e75 | |||
| 502ef2ed89 | |||
| ce5910b9a6 | |||
| b0c670235e | |||
| f1d4ffd7fa | |||
| 6f35ab7b8b | |||
| 9a67f5479a | |||
| b727eba988 | |||
| 330c3d79d4 | |||
| dff20daace | |||
| cdc44328a8 | |||
| ac61a71dcb | |||
| 71c800c396 | |||
| 577a6d279b | |||
| 49e01f0318 | |||
| 5ea9b3b0ba | |||
| ca85d4c369 | |||
| 4499b1d3aa | |||
| d385e5356e | |||
| b4a0dc6a83 | |||
| 2e570b3bb0 | |||
| ee4554286e | |||
| 2c6f0d932e | |||
| 4e7606fdc6 | |||
| 03df631690 | |||
| 6df826f210 | |||
| cdf306cfe9 | |||
| 74a436b171 | |||
| 2f33c48bcc | |||
| 1f48dad71c | |||
| 8066f6d96b | |||
| 5c3bc30a6e | |||
| 90ed05d88d | |||
| f59050ef04 | |||
| bae10edc99 | |||
| cfa2681ba3 | |||
| 03e936ac34 | |||
| f7f96abcbb | |||
| 4d54443593 | |||
| 5afe3dd45b | |||
| bd75ac6190 | |||
| 3e6a8b6e79 | |||
| c1c8deb1b3 | |||
| dc61342af8 | |||
| fded7f7b3f | |||
| ec83680333 | |||
| 16e94e71d3 | |||
| 56040934e9 | |||
| 89975cb124 | |||
| 03e20ffa7e | |||
| 8f7ea09ef5 | |||
| c639721f2a | |||
| 84512efda8 | |||
| 6cf4a9744d | |||
| d9ced4e650 | |||
| c1f3f34169 | |||
| 6c06da7b0d | |||
| 023caa4616 | |||
| fc593f8a8e | |||
| f281ad5a78 | |||
| fe8fec4ea7 | |||
| 7583e72687 | |||
| e4b4a3d198 | |||
| a7af1caace | |||
| c5086189d6 | |||
| ac667f1273 | |||
| fdb79e609f | |||
| 531a49dbdf | |||
| 2a13c25230 | |||
| 408f5317c4 | |||
| 3672d6f43e | |||
| cc3bec0746 | |||
| 180a69bd40 | |||
| 6de51bc11c | |||
| bc9f086245 | |||
| f84cc03a6c | |||
| 96dda1d8a9 | |||
| 87b791ee3a | |||
| ae5da6d9bd | |||
| 546b6fd13f | |||
| e8d1947f18 | |||
| 9e55cb5f8b | |||
| 7f57175519 | |||
| b21b32f746 | |||
| 3827d54ba8 | |||
| 9801b0cdf5 | |||
| 154f33b15b | |||
| fa217c3b25 | |||
| 1fb8daf389 | |||
| f2213d1def | |||
| 6997a6fa71 | |||
| e1ae768f26 | |||
| 57c4461952 | |||
| 5c46ff4a1e | |||
| 9112102c16 | |||
| 9d1eb8bee9 | |||
| 39fe8a59c7 | |||
| 5870f7d440 | |||
| 4a060e4669 | |||
| 3981833a3b | |||
| 1ea84dbb28 | |||
| 195aa56ceb | |||
| b205800a66 | |||
| 5e538db201 | |||
| 0bcd23195f | |||
| ca0196f927 | |||
| 874c8063aa | |||
| d877f79d5c | |||
| 3cfd2cb9bd | |||
| bf1f8410c1 | |||
| ad7c63617b | |||
| 00b25dde9d | |||
| e3118a4b89 | |||
| 23f5ee0d22 | |||
| 5c4df232d5 | |||
| 7f0e6f7996 | |||
| ab60e6b0ab | |||
| 3b3c5c32f9 | |||
| 74b76dd61d | |||
| 3b3db608e8 | |||
| b1a24cb50e | |||
| 1855a8b72c | |||
| 42ab513ad9 | |||
| ba04546b17 | |||
| 6feec98b4c | |||
| 43e1c60baa | |||
| 2f5fc04244 | |||
| aa45a49c3a | |||
| dd8ff8b1bf | |||
| 25667ce0f0 | |||
| ccf48e6426 | |||
| da79be0d22 | |||
| 01afb01198 | |||
| 37723ddc30 | |||
| b672d9bd10 | |||
| fa8a7b7590 | |||
| d3c8706c33 | |||
| 071e805457 | |||
| 4f30f5047e | |||
| e173a27c6e | |||
| 5891cc071e | |||
| b02acb44fb | |||
| c4a01a9f72 | |||
| be5a619b89 | |||
| 00a3aa4ca4 | |||
| dee908a347 | |||
| 4f8681a8c6 | |||
| 675f29f51f | |||
| 2efe16376c | |||
| 607e30da7c | |||
| d6f96f3035 | |||
| 81cea863e4 | |||
| ac721d0c4d | |||
| 06f12cd740 | |||
| dda87f3222 | |||
| 8b1c4773c3 | |||
| 9b71c105fc | |||
| 82d603cbab | |||
| e6d0e6e128 | |||
| 4c62d9f1e7 | |||
| c40251b2a1 | |||
| b7fa90d37e | |||
| e3b787204d | |||
| 0a5d61b510 | |||
| ef3cb0f48c | |||
| 19b5e52ba6 | |||
| 6b75654511 | |||
| 22c5ca6384 | |||
| eacfaaf8f6 | |||
| f3e574e8c9 | |||
| cc02c6c3c0 | |||
| 7ec53f259e | |||
| dddd4c5440 | |||
| 1a9836a7d9 | |||
| 26eec0c0ef | |||
| 3ba7a79010 | |||
| 6ab54d7ec9 | |||
| af4f175e0c | |||
| 9907408541 | |||
| b3e0d730da | |||
| edfd2ba9b7 | |||
| f2ce47cdcd | |||
| 6b10d365fc | |||
| da756ccffe | |||
| 5e99870a71 | |||
| ed5d6f63b1 | |||
| 89a58c27a0 |
@@ -0,0 +1 @@
|
|||||||
|
custom: ['https://boosty.to/arabian']
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "cargo"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
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 \
|
||||||
|
libdbus-1-dev \
|
||||||
|
pkg-config
|
||||||
|
|
||||||
|
- 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 binaries
|
||||||
|
run: cargo build --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/debug/$BIN")
|
||||||
|
done <<< "${{ steps.cargo-meta.outputs.bin_names }}"
|
||||||
|
|
||||||
|
if [ "${#FILES[@]}" -eq 0 ]; then
|
||||||
|
echo "Error: no binaries were discovered via cargo metadata." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
name: Flatter
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main, master ]
|
||||||
|
release:
|
||||||
|
types: [ published ]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag_name:
|
||||||
|
description: "TAG (empty to build from current branch)"
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
build_branch:
|
||||||
|
description: "Flatpak branch to build (stable/nightly)"
|
||||||
|
required: true
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- stable
|
||||||
|
- nightly
|
||||||
|
default: "stable"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
flatter:
|
||||||
|
name: Flatter
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
container:
|
||||||
|
image: ghcr.io/andyholmes/flatter/freedesktop:25.08
|
||||||
|
options: --privileged
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.tag_name || github.ref }}
|
||||||
|
|
||||||
|
- name: Setup GPG
|
||||||
|
id: gpg
|
||||||
|
uses: crazy-max/ghaction-import-gpg@v6
|
||||||
|
with:
|
||||||
|
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||||
|
passphrase: ${{ secrets.GPG_PASSPHRASE }}
|
||||||
|
|
||||||
|
- name: Set Default Branch
|
||||||
|
id: set_branch
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" == "release" ]; then
|
||||||
|
echo "branch=stable" >> $GITHUB_OUTPUT
|
||||||
|
elif [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
|
||||||
|
echo "branch=${{ inputs.build_branch }}" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "branch=nightly" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Modify Manifest
|
||||||
|
run: |
|
||||||
|
echo "branch: ${{ steps.set_branch.outputs.branch }}" >> packages/flatpak/ru.arabianq.pwsp.yaml
|
||||||
|
echo "default-branch: ${{ steps.set_branch.outputs.branch }}" >> packages/flatpak/ru.arabianq.pwsp.yaml
|
||||||
|
|
||||||
|
- name: Install SDK Extensions
|
||||||
|
run: flatpak install -y flathub org.freedesktop.Sdk.Extension.rust-stable//25.08
|
||||||
|
org.freedesktop.Sdk.Extension.llvm20//25.08
|
||||||
|
|
||||||
|
- name: Build Flatpak
|
||||||
|
uses: andyholmes/flatter@main
|
||||||
|
with:
|
||||||
|
files: packages/flatpak/ru.arabianq.pwsp.yaml
|
||||||
|
gpg-sign: ${{ steps.gpg.outputs.fingerprint }}
|
||||||
|
upload-bundles: false
|
||||||
|
upload-pages-artifact: true
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy to GitHub Pages
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: flatter
|
||||||
|
permissions:
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: GitHub Pages
|
||||||
|
id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
name: Release deb
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
on:
|
|
||||||
release:
|
|
||||||
types: [created]
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
tag:
|
|
||||||
description: 'Tag to attach assets to (e.g. v1.0.0)'
|
|
||||||
required: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-release:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Install apt deps (jq/zip + dev-libs)
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y \
|
|
||||||
zip jq \
|
|
||||||
libpipewire-0.3-dev \
|
|
||||||
libclang-dev \
|
|
||||||
libasound2-dev
|
|
||||||
|
|
||||||
- name: Determine tag to use
|
|
||||||
id: tag
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
INPUT_TAG="${{ github.event.inputs.tag || '' }}"
|
|
||||||
if [ -n "$INPUT_TAG" ]; then
|
|
||||||
echo "Using input tag: $INPUT_TAG"
|
|
||||||
echo "tag=$INPUT_TAG" >> $GITHUB_OUTPUT
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
EVENT_TAG="${{ github.event.release.tag_name || '' }}"
|
|
||||||
if [ -n "$EVENT_TAG" ]; then
|
|
||||||
echo "Using event tag: $EVENT_TAG"
|
|
||||||
echo "tag=$EVENT_TAG" >> $GITHUB_OUTPUT
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "${GITHUB_REF:-}" =~ ^refs/tags/(.+)$ ]]; then
|
|
||||||
echo "Using GITHUB_REF tag: ${BASH_REMATCH[1]}"
|
|
||||||
echo "tag=${BASH_REMATCH[1]}" >> $GITHUB_OUTPUT
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "No tag in input/event/GITHUB_REF — querying latest release via API..."
|
|
||||||
LATEST_JSON=$(curl -sSf -H "Authorization: Bearer $GITHUB_TOKEN" -H "Accept: application/vnd.github+json" "https://api.github.com/repos/${{ github.repository }}/releases/latest" || true)
|
|
||||||
TAG_NAME=$(echo "$LATEST_JSON" | jq -r '.tag_name // empty')
|
|
||||||
if [ -n "$TAG_NAME" ]; then
|
|
||||||
echo "Found latest release tag: $TAG_NAME"
|
|
||||||
echo "tag=$TAG_NAME" >> $GITHUB_OUTPUT
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "No tag found"
|
|
||||||
echo "tag=" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Fail if no tag determined
|
|
||||||
if: ${{ steps.tag.outputs.tag == '' }}
|
|
||||||
run: |
|
|
||||||
echo "ERROR: No tag determined. Provide a tag when running manually or ensure a release exists."
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
- name: Checkout code at tag
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
ref: ${{ steps.tag.outputs.tag || github.ref }}
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Setup Rust toolchain
|
|
||||||
uses: actions-rs/toolchain@v1
|
|
||||||
with:
|
|
||||||
toolchain: stable
|
|
||||||
|
|
||||||
- name: Build all release binaries
|
|
||||||
run: cargo build --release --locked
|
|
||||||
|
|
||||||
- name: Install cargo-deb and create .deb
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
cargo install --locked cargo-deb
|
|
||||||
export PATH="$HOME/.cargo/bin:$PATH"
|
|
||||||
|
|
||||||
cargo-deb
|
|
||||||
|
|
||||||
- name: Upload .deb(s) to release
|
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
tag_name: ${{ steps.tag.outputs.tag }}
|
|
||||||
files: |
|
|
||||||
target/debian/*.deb
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Release archive
|
name: Release
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -10,29 +10,20 @@ 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:
|
||||||
build-and-release:
|
prepare:
|
||||||
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"
|
||||||
@@ -40,7 +31,6 @@ 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"
|
||||||
@@ -48,16 +38,14 @@ 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 $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 ${{ secrets.GITHUB_TOKEN }}" -H "Accept: application/vnd.github+json" "https://api.github.com/repos/${{ github.repository }}/releases/latest" || true)
|
||||||
TAG_NAME=$(echo "$LATEST_JSON" | jq -r '.tag_name // empty')
|
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"
|
||||||
@@ -74,16 +62,32 @@ 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 \
|
||||||
|
libdbus-1-dev \
|
||||||
|
pkg-config
|
||||||
|
|
||||||
- name: Checkout code at tag
|
- name: Checkout code at tag
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
ref: ${{ steps.tag.outputs.tag || github.ref }}
|
ref: ${{ needs.prepare.outputs.tag }}
|
||||||
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: stable
|
toolchain: 1.94.1
|
||||||
|
|
||||||
- name: Extract all binary names
|
- name: Extract all binary names
|
||||||
id: cargo-meta
|
id: cargo-meta
|
||||||
@@ -91,7 +95,6 @@ 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
|
||||||
@@ -103,11 +106,10 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
TAG="${{ steps.tag.outputs.tag }}"
|
TAG="${{ needs.prepare.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,7 +121,6 @@ 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
|
||||||
@@ -128,13 +129,50 @@ 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: ${{ steps.tag.outputs.tag }}
|
tag_name: ${{ needs.prepare.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
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
/target
|
/target
|
||||||
.idea
|
.idea
|
||||||
|
packages/aur/bin/.git
|
||||||
|
packages/aur/standart/.git
|
||||||
|
|||||||
Generated
+1971
-959
File diff suppressed because it is too large
Load Diff
+75
-17
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "pwsp"
|
name = "pwsp"
|
||||||
version = "1.0.2"
|
version = "1.9.1"
|
||||||
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,23 +12,57 @@ keywords = ["soundpad", "pipewire", "linux", "cli", "gui"]
|
|||||||
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tokio = { version = "1.47.1", features = ["full"] }
|
tokio = { version = "1.52.3", 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.145"
|
serde_json = "1.0.149"
|
||||||
|
|
||||||
|
clap = { version = "4.6.1", default-features = false, features = [
|
||||||
|
"std",
|
||||||
|
"suggestions",
|
||||||
|
"help",
|
||||||
|
"usage",
|
||||||
|
"error-context",
|
||||||
|
"derive",
|
||||||
|
] }
|
||||||
|
|
||||||
clap = { version = "4.5.48", default-features = false, features = ["std", "suggestions", "help", "usage", "error-context", "derive"] }
|
|
||||||
dirs = "6.0.0"
|
dirs = "6.0.0"
|
||||||
|
itertools = "0.14.0"
|
||||||
|
evdev = { version = "0.13.2", features = ["tokio"] }
|
||||||
|
rfd = { version = "0.17.2", default-features = false, features = [
|
||||||
|
|
||||||
rodio = { version = "0.21.1", default-features = false, features = ["symphonia-all", "playback"] }
|
"xdg-portal",
|
||||||
pipewire = "0.9.2"
|
|
||||||
rfd = "0.15.4"
|
|
||||||
|
|
||||||
egui = { version = "0.32.3", default-features = false, features = ["default_fonts", "rayon"] }
|
] }
|
||||||
eframe = { version = "0.32.3", default-features = false, features = ["default_fonts", "glow", "x11", "wayland"] }
|
opener = { version = "0.8.4", features = ["reveal"] }
|
||||||
egui_material_icons = "0.4.0"
|
system-fonts = "0.1.0"
|
||||||
|
anyhow = "1.0.102"
|
||||||
|
rustix = { version = "1.1.4", features = ["process"] }
|
||||||
|
|
||||||
|
rust-i18n = "4.0.0"
|
||||||
|
sys-locale = "0.3.2"
|
||||||
|
|
||||||
|
rodio = { git = "https://github.com/arabianq/rodio.git", rev = "1a08f281c352622bd82b87b8731585245802d9cf", default-features = false, features = [
|
||||||
|
"symphonia-all",
|
||||||
|
"symphonia-libopus",
|
||||||
|
"playback",
|
||||||
|
] }
|
||||||
|
pipewire = "0.10.0"
|
||||||
|
|
||||||
|
egui = { version = "0.34.2", default-features = false, features = [
|
||||||
|
"default_fonts",
|
||||||
|
"rayon",
|
||||||
|
] }
|
||||||
|
eframe = { version = "0.34.2", default-features = false, features = [
|
||||||
|
"default_fonts",
|
||||||
|
"glow",
|
||||||
|
"x11",
|
||||||
|
"wayland",
|
||||||
|
] }
|
||||||
|
egui_extras = "0.34.1"
|
||||||
|
egui_material_icons = "0.6.0"
|
||||||
|
egui_dnd = "0.15.0"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "pwsp-daemon"
|
name = "pwsp-daemon"
|
||||||
@@ -51,10 +85,34 @@ panic = "abort"
|
|||||||
|
|
||||||
[package.metadata.deb]
|
[package.metadata.deb]
|
||||||
assets = [
|
assets = [
|
||||||
["target/release/pwsp-daemon", "usr/bin/", "755"],
|
[
|
||||||
["target/release/pwsp-cli", "usr/bin/", "755"],
|
"target/release/pwsp-daemon",
|
||||||
["target/release/pwsp-gui", "usr/bin/", "755"],
|
"usr/bin/",
|
||||||
["assets/pwsp-gui.desktop", "usr/share/applications/pwsp.desktop", "644"],
|
"755",
|
||||||
["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",
|
||||||
|
],
|
||||||
]
|
]
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2025 Tarasov Alexander
|
Copyright (c) 2026 arabianq
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
@@ -1,185 +1,118 @@
|
|||||||
# **🎵 Pipewire Soundpad (PWSP)**
|
<div align="center">
|
||||||
|
<h1>🎵 PipeWire Soundpad (PWSP)</h1>
|
||||||
|
<p><b>A simple, modern, and powerful soundboard for Linux, written in Rust.</b></p>
|
||||||
|
<img src="assets/screenshot.png" alt="PWSP Screenshot" width="700"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
**PipeWire Soundpad (PWSP)** is a simple yet powerful **soundboard application** written in **Rust**. It provides a
|
## 🌟 Overview
|
||||||
user-friendly graphical interface for **managing and playing audio files, directing their output directly to the virtual
|
**PipeWire Soundpad (PWSP)** is a graphical soundboard application that routes audio directly to your virtual microphone using **PipeWire**. It provides an intuitive interface for managing your audio collection, making it an ideal tool for gamers, streamers, and anyone looking to inject sound effects into voice chats on platforms like Discord, Zoom, or TeamSpeak.
|
||||||
microphone.** This makes it an ideal tool for gamers, streamers, and anyone looking to inject sound effects into voice
|
|
||||||
chats on platforms like **Discord, Zoom, or Teamspeak**.
|
|
||||||
|
|
||||||

|
## ✨ Key Features
|
||||||
|
* **🎙️ Virtual Microphone Output:** Seamlessly mixes your microphone input with sound effects by automatically managing PipeWire virtual devices.
|
||||||
|
* **🎵 Multi-Format Support:** Plays popular audio formats including `mp3`, `wav`, `ogg`, `flac`, `mp4`, and `aac`.
|
||||||
|
* **⚡ Global Hotkeys:** Trigger sounds instantly from anywhere, even when the app is running in the background.
|
||||||
|
* **📂 Smart Collection Management:** Drag-and-drop folders, quick search, and collapsible tracks to keep your library organized.
|
||||||
|
* **🎛️ Advanced Playback Controls:** Individual volume sliders, play/pause, position scrubbing, and concurrent multi-track playback.
|
||||||
|
* **🔌 Plug & Play:** Automatically detects when an input device is connected or disconnected and handles linking/unlinking on the fly.
|
||||||
|
* **🖥️ Modern GUI:** Clean, responsive, and lightweight interface powered by [egui](https://egui.rs/).
|
||||||
|
|
||||||
# **🌟 Key Features**
|
## ⚙️ Architecture
|
||||||
|
PWSP is built with a client-server model to ensure stability and separation of concerns:
|
||||||
|
* **`pwsp-daemon`**: The background engine. It runs silently, managing PipeWire virtual devices, audio routing, and playback.
|
||||||
|
* **`pwsp-gui`**: The graphical interface. Communicates with the daemon via a Unix socket to control playback and settings.
|
||||||
|
* **`pwsp-cli`**: The command-line tool. Perfect for scripting, hotkey binding, or quick terminal-based control.
|
||||||
|
|
||||||
* **Multi-Format Support**: Play audio files in popular formats, including _**mp3**_, _**wav**_, _**ogg**_, _**flac**_,
|
---
|
||||||
_**mp4**_, and _**aac**_.
|
|
||||||
* **Virtual Microphone Output**: The application routes audio through a virtual device created by PipeWire, allowing
|
|
||||||
other users to hear the sounds as if you were speaking into your microphone.
|
|
||||||
* **Modern and Clean GUI**: The interface is built with the [egui](https://egui.rs) library, ensuring an intuitive and
|
|
||||||
responsive user experience.
|
|
||||||
* **Sound Collection Management**: Easily add and remove directories containing your audio files. The application scans
|
|
||||||
these folders and displays all supported files for quick access.
|
|
||||||
* **Quick Search**: Use the built-in search bar to instantly find any sound file within your library.
|
|
||||||
* **Detailed Playback Controls**:
|
|
||||||
* **Play/Pause button**.
|
|
||||||
* **Volume slider** for individual sound adjustment.
|
|
||||||
* **Position slider** to fast-forward or rewind the audio.
|
|
||||||
* **Persistent Configuration**: The list of added directories and your selected audio output device are saved
|
|
||||||
automatically, so you won't need to reconfigure them every time you launch the application.
|
|
||||||
|
|
||||||
# **⚙️ How It Works**
|
## 🚀 Installation
|
||||||
|
|
||||||
PWSP is designed with a clear separation of concerns, operating through a client-server architecture. It consists of
|
### 📦 Flatpak (Recommended)
|
||||||
three main components:
|
Install PWSP via Flatpak from our custom repository:
|
||||||
|
```bash
|
||||||
|
flatpak remote-add --user --if-not-exists pwsp-repo https://arabianq.github.io/pipewire-soundpad/index.flatpakrepo
|
||||||
|
|
||||||
* **pwsp-daemon**: This is the core of the application. It runs silently in the background, managing all the
|
# Install stable version
|
||||||
heavy-lifting tasks. The daemon is responsible for:
|
flatpak install --user arabianq-repo ru.arabianq.pwsp//stable
|
||||||
* Creating and managing virtual audio devices.
|
|
||||||
* Linking these devices within the PipeWire graph.
|
|
||||||
* Handling all audio playback.
|
|
||||||
* **pwsp-gui**: This is the graphical user interface. It acts as a client that communicates with pwsp-daemon via a *
|
|
||||||
*UnixSocket**. This is how you interact with your sound collection, control playback, and configure settings.
|
|
||||||
* **pwsp-cli**: This is the command-line interface, also acting as a client. It provides a way to control the daemon
|
|
||||||
without a GUI, allowing for scripting or quick command-based actions.
|
|
||||||
|
|
||||||
# **🚀 Installation**
|
# Or install the nightly version (latest commit)
|
||||||
|
flatpak install --user arabianq-repo ru.arabianq.pwsp//nightly
|
||||||
## **Pre-built Packages**
|
```
|
||||||
|
|
||||||
You can download pre-built binaries, .deb and .rpm packages from
|
|
||||||
the [releases page](https://github.com/arabianq/pipewire-soundpad/releases).
|
|
||||||
|
|
||||||
## **Fedora Linux**
|
|
||||||
|
|
||||||
If you're using Fedora, you can install PWSP from a dedicated repository using DNF.
|
|
||||||
|
|
||||||
Add the repository:
|
|
||||||
|
|
||||||
|
### 🐧 Linux Packages
|
||||||
|
**Fedora (and derivatives):**
|
||||||
```bash
|
```bash
|
||||||
sudo dnf copr enable arabianq/pwsp
|
sudo dnf copr enable arabianq/pwsp
|
||||||
```
|
|
||||||
|
|
||||||
Update cache:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo dnf makecache
|
|
||||||
```
|
|
||||||
|
|
||||||
Install PWSP:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo dnf install pwsp
|
sudo dnf install pwsp
|
||||||
```
|
```
|
||||||
|
|
||||||
## **Arch Linux**
|
**Arch Linux (AUR):**
|
||||||
There is pwsp package in AUR.
|
|
||||||
You can install it using yay, paru or any other AUR helper.
|
|
||||||
```bash
|
```bash
|
||||||
paru pwsp
|
paru -S pwsp-bin # or 'pwsp' to build from source
|
||||||
```
|
```
|
||||||
|
|
||||||
## **Installing using cargo**
|
**Debian / Ubuntu:**
|
||||||
|
Download pre-built `.deb` packages or standalone binaries from the [Releases page](https://github.com/arabianq/pipewire-soundpad/releases).
|
||||||
|
|
||||||
|
### 🦀 Cargo / Source Build
|
||||||
```bash
|
```bash
|
||||||
cargo install pwsp
|
cargo install pwsp
|
||||||
```
|
|
||||||
|
|
||||||
## **Building from source**
|
# OR clone and build manually:
|
||||||
|
|
||||||
#### **Requirements**
|
|
||||||
|
|
||||||
* **Rust**: Install [Rust](https://www.rust-lang.org/tools/install) (using rustup is recommended).
|
|
||||||
* **PipeWire**: Ensure that [PipeWire](https://pipewire.org/) is installed and running on your system.
|
|
||||||
|
|
||||||
#### **Build Instructions**
|
|
||||||
|
|
||||||
Clone the repository:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/arabianq/pipewire-soundpad.git
|
git clone https://github.com/arabianq/pipewire-soundpad.git
|
||||||
cd pipewire-soundpad
|
cd pipewire-soundpad
|
||||||
```
|
|
||||||
|
|
||||||
Build the project:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo build --release
|
cargo build --release
|
||||||
```
|
```
|
||||||
|
*(Note: Requires Rust toolchain and PipeWire running on your system).*
|
||||||
|
|
||||||
Now you have three binary files inside ./target/release/:
|
---
|
||||||
|
|
||||||
- **pwsp-gui**
|
## 🎮 Usage
|
||||||
- **pwsp-cli**
|
|
||||||
- **pwsp-daemon**
|
|
||||||
|
|
||||||
# **🎮 Usage**
|
### 1. Start the Daemon
|
||||||
|
Before using the GUI or CLI, the daemon must be running in the background.
|
||||||
Before using pwsp-gui or pwsp-cli, you **must** first run the pwsp-daemon in the background.
|
|
||||||
|
|
||||||
### **Running the Daemon**
|
|
||||||
|
|
||||||
You can start the daemon from the terminal or enable the systemd service for automatic startup.
|
|
||||||
|
|
||||||
* **Manual Start:**
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/path/to/your/pwsp-daemon &
|
# Recommended: Start and enable via systemd (starts on login)
|
||||||
```
|
|
||||||
|
|
||||||
* **Using systemd (recommended):**
|
|
||||||
If you installed PWSP using prebuilt packages, the systemd service is added automatically.
|
|
||||||
1. **Start the service:**
|
|
||||||
```bash
|
|
||||||
systemctl --user start pwsp-daemon
|
|
||||||
```
|
|
||||||
2. **Enable autostart (starts on login):**
|
|
||||||
```bash
|
|
||||||
systemctl --user enable --now pwsp-daemon
|
systemctl --user enable --now pwsp-daemon
|
||||||
|
|
||||||
|
# Manual start (if not using systemd):
|
||||||
|
pwsp-daemon &
|
||||||
```
|
```
|
||||||
|
|
||||||
### **Using the GUI**
|
### 2. Using the GUI
|
||||||
|
1. **Add Sounds:** Click the **"+"** button to add a directory containing your audio files.
|
||||||
1. **Add Sounds**: Click the **"Add Directory"** button and select a folder containing your audio files. The application
|
2. **Select Mic:** Choose your physical microphone from the dropdown. PWSP will instantly create a virtual microphone combining your voice and the soundboard.
|
||||||
will automatically list all supported files.
|
3. **Play:** Click any sound to play it, adjust its volume, or assign a hotkey for quick access.
|
||||||
2. **Select Microphone**: In the main application window, select your **physical microphone**. PWSP will automatically
|
|
||||||
create a virtual microphone and feed it sound from two sources: **your microphone** and the **audio files**.
|
|
||||||
3. **Playback**: Click on a file in the list to load it, then use the **"Play"** and **"Pause"** buttons to control
|
|
||||||
playback.
|
|
||||||
|
|
||||||
### **Using the CLI**
|
|
||||||
|
|
||||||
The pwsp-cli tool allows you to control the daemon from the command line.
|
|
||||||
|
|
||||||
* **General Help**: To see a list of all available commands, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pwsp-cli --help
|
|
||||||
```
|
|
||||||
|
|
||||||
* **Example Commands**:
|
|
||||||
* **Play a file**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pwsp-cli action play <file_path>
|
|
||||||
```
|
|
||||||
|
|
||||||
* **Get the current volume**:
|
|
||||||
|
|
||||||
|
### 3. Using the CLI
|
||||||
|
Control the daemon directly from your terminal:
|
||||||
```bash
|
```bash
|
||||||
|
pwsp-cli action play /path/to/sound.mp3
|
||||||
pwsp-cli get volume
|
pwsp-cli get volume
|
||||||
```
|
|
||||||
|
|
||||||
* **Set playback position to 20 seconds**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pwsp-cli set position 20
|
pwsp-cli set position 20
|
||||||
|
pwsp-cli --help # View all commands
|
||||||
```
|
```
|
||||||
|
|
||||||
# **🤝 Contributing**
|
---
|
||||||
|
|
||||||
Contributions are welcome\! If you have ideas for improvements or find a bug, feel free to create
|
## ⌨️ Shortcuts & Controls
|
||||||
an [issue](https://github.com/arabianq/pipewire-soundpad/issues) or submit
|
|
||||||
a [pull request](https://github.com/arabianq/pipewire-soundpad/pulls).
|
|
||||||
|
|
||||||
# **📜 License**
|
| Action | Keyboard | Mouse |
|
||||||
|
| :----------------------------------- | :--------------------- | :------------------- |
|
||||||
|
| **Play Track** (Stops others) | | `Left Click` |
|
||||||
|
| **Add Track** (Plays simultaneously) | | `Ctrl + Left Click` |
|
||||||
|
| **Replace Last Track** | | `Shift + Left Click` |
|
||||||
|
| **Pause / Resume** | `Space` | |
|
||||||
|
| **Stop All Tracks** | `Backspace` | |
|
||||||
|
| **Open / Close Settings** | `I` | |
|
||||||
|
| **Search** | `/` | |
|
||||||
|
|
||||||
This project is licensed under
|
---
|
||||||
the [MIT License](https://github.com/arabianq/pipewire-soundpad/blob/main/LICENSE).
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
Contributions, issues, and feature requests are welcome! Feel free to check out the [issues page](https://github.com/arabianq/pipewire-soundpad/issues).
|
||||||
|
|
||||||
|
[](https://deepwiki.com/arabianq/pipewire-soundpad)
|
||||||
|
|
||||||
|
## 📜 License
|
||||||
|
This project is licensed under the [MIT License](LICENSE).
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
[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: 200 KiB After Width: | Height: | Size: 94 KiB |
@@ -0,0 +1,376 @@
|
|||||||
|
_version = 2
|
||||||
|
|
||||||
|
# ----------------
|
||||||
|
# Main page
|
||||||
|
# ----------------
|
||||||
|
|
||||||
|
[gui.play_file_button]
|
||||||
|
en = "Play file"
|
||||||
|
ru = "Выбрать файл"
|
||||||
|
es = "Reproducir archivo"
|
||||||
|
fr = "Lire le fichier"
|
||||||
|
zh = "播放文件"
|
||||||
|
ar = "تشغيل الملف"
|
||||||
|
kz = "Файлды ойнату"
|
||||||
|
he = "נגן קובץ"
|
||||||
|
pt-BR = "Reproduzir arquivo"
|
||||||
|
|
||||||
|
[gui.choose_mic_select]
|
||||||
|
en = "Select microphone"
|
||||||
|
ru = "Выбрать микрофон"
|
||||||
|
es = "Seleccionar micrófono"
|
||||||
|
fr = "Sélectionner le microphone"
|
||||||
|
zh = "选择麦克风"
|
||||||
|
ar = "اختر الميكروفون"
|
||||||
|
kz = "Микрофонды таңдау"
|
||||||
|
he = "בחר מיקרופון"
|
||||||
|
pt-BR = "Selecionar microfone"
|
||||||
|
|
||||||
|
[gui.search_placeholder]
|
||||||
|
en = "Search files..."
|
||||||
|
ru = "Поиск файлов..."
|
||||||
|
es = "Buscar archivos..."
|
||||||
|
fr = "Rechercher des fichiers..."
|
||||||
|
zh = "搜索文件..."
|
||||||
|
ar = "البحث عن ملفات..."
|
||||||
|
kz = "Файлдарды іздеу..."
|
||||||
|
he = "חפש קבצים..."
|
||||||
|
pt-BR = "Buscar arquivos..."
|
||||||
|
|
||||||
|
[gui.context.dirs.open]
|
||||||
|
en = "Open"
|
||||||
|
ru = "Открыть"
|
||||||
|
es = "Abrir"
|
||||||
|
fr = "Ouvrir"
|
||||||
|
zh = "打开"
|
||||||
|
ar = "فتح"
|
||||||
|
kz = "Ашу"
|
||||||
|
he = "פתח"
|
||||||
|
pt-BR = "Abrir"
|
||||||
|
|
||||||
|
[gui.context.dirs.open_in_fm]
|
||||||
|
en = "Open in File Manager"
|
||||||
|
ru = "Открыть в менеджере файлов"
|
||||||
|
es = "Abrir en el gestor de archivos"
|
||||||
|
fr = "Ouvrir dans le gestionnaire de fichiers"
|
||||||
|
zh = "在文件管理器中打开"
|
||||||
|
ar = "فتح في مدير الملفات"
|
||||||
|
kz = "Файл менеджерінде ашу"
|
||||||
|
he = "פתח במנהל הקבצים"
|
||||||
|
pt-BR = "Abrir no gestor de arquivos"
|
||||||
|
|
||||||
|
[gui.context.dirs.remove]
|
||||||
|
en = "Remove"
|
||||||
|
ru = "Удалить"
|
||||||
|
es = "Eliminar"
|
||||||
|
fr = "Supprimer"
|
||||||
|
zh = "移除"
|
||||||
|
ar = "إزالة"
|
||||||
|
kz = "Жою"
|
||||||
|
he = "הסר"
|
||||||
|
pt-BR = "Remover"
|
||||||
|
|
||||||
|
[gui.context.files.play_solo]
|
||||||
|
en = "Play Solo"
|
||||||
|
ru = "Играть"
|
||||||
|
es = "Reproducir solo"
|
||||||
|
fr = "Jouer en solo"
|
||||||
|
zh = "单独播放"
|
||||||
|
ar = "تشغيل منفرد"
|
||||||
|
kz = "Жалғыз ойнату"
|
||||||
|
he = "נגן סולו"
|
||||||
|
pt-BR = "Reproduzir"
|
||||||
|
|
||||||
|
[gui.context.files.add_new]
|
||||||
|
en = "Add New"
|
||||||
|
ru = "Добавить"
|
||||||
|
es = "Añadir nuevo"
|
||||||
|
fr = "Ajouter un nouveau"
|
||||||
|
zh = "添加新项"
|
||||||
|
ar = "إضافة جديد"
|
||||||
|
kz = "Жаңасын қосу"
|
||||||
|
he = "הוסף חדש"
|
||||||
|
pt-BR = "Adicionar"
|
||||||
|
|
||||||
|
[gui.context.files.replace_last]
|
||||||
|
en = "Replace Last"
|
||||||
|
ru = "Заменить Последний"
|
||||||
|
es = "Reemplazar último"
|
||||||
|
fr = "Remplacer le dernier"
|
||||||
|
zh = "替换上一个"
|
||||||
|
ar = "استبدال الأخير"
|
||||||
|
kz = "Соңғысын ауыстыру"
|
||||||
|
he = "החלף אחרון"
|
||||||
|
pt-BR = "Substituir"
|
||||||
|
|
||||||
|
[gui.context.files.show_in_fm]
|
||||||
|
en = "Show in File Manager"
|
||||||
|
ru = "Открыть в менеджере файлов"
|
||||||
|
es = "Mostrar en el gestor de archivos"
|
||||||
|
fr = "Afficher dans le gestionnaire de fichiers"
|
||||||
|
zh = "在文件管理器中显示"
|
||||||
|
ar = "عرض في مدير الملفات"
|
||||||
|
kz = "Файл менеджерінде көрсету"
|
||||||
|
he = "הצג במנהל הקבצים"
|
||||||
|
pt-BR = "Mostrar no gestor de arquivos"
|
||||||
|
|
||||||
|
[gui.context.files.asign_hotkey]
|
||||||
|
en = "Assign Hotkey"
|
||||||
|
ru = "Назначить Горячую Клавишу"
|
||||||
|
es = "Asignar atajo"
|
||||||
|
fr = "Assigner un raccourci"
|
||||||
|
zh = "分配快捷键"
|
||||||
|
ar = "تعيين مفتاح اختصار"
|
||||||
|
kz = "Ыстық пернені тағайындау"
|
||||||
|
he = "הקצה מקש קיצור"
|
||||||
|
pt-BR = "Definir tecla de atalho"
|
||||||
|
|
||||||
|
# ----------------
|
||||||
|
# Settings
|
||||||
|
# ----------------
|
||||||
|
|
||||||
|
[gui.settings.header]
|
||||||
|
en = "Settings"
|
||||||
|
ru = "Настройки"
|
||||||
|
es = "Ajustes"
|
||||||
|
fr = "Paramètres"
|
||||||
|
zh = "设置"
|
||||||
|
ar = "الإعدادات"
|
||||||
|
kz = "Баптаулар"
|
||||||
|
he = "הגדרות"
|
||||||
|
pt-BR = "Configurações"
|
||||||
|
|
||||||
|
[gui.settings.remember_volume]
|
||||||
|
en = "Always remember volume"
|
||||||
|
ru = "Всегда запоминать громкость"
|
||||||
|
es = "Recordar siempre el volumen"
|
||||||
|
fr = "Toujours se souvenir du volume"
|
||||||
|
zh = "始终记住音量"
|
||||||
|
ar = "تذكر مستوى الصوت دائمًا"
|
||||||
|
kz = "Әрқашан дыбыс деңгейін есте сақтау"
|
||||||
|
he = "זכור תמיד עוצמת קול"
|
||||||
|
pt-BR = "Lembrar volume"
|
||||||
|
|
||||||
|
[gui.settings.remember_mic]
|
||||||
|
en = "Always remember microphone"
|
||||||
|
ru = "Всегда запоминать микрофон"
|
||||||
|
es = "Recordar siempre el micrófono"
|
||||||
|
fr = "Toujours se souvenir du microphone"
|
||||||
|
zh = "始终记住麦克风"
|
||||||
|
ar = "تذكر الميكروفون دائمًا"
|
||||||
|
kz = "Әрқашан микрофонды есте сақтау"
|
||||||
|
he = "זכור תמיד מיקרופון"
|
||||||
|
pt-BR = "Lembrar microfone"
|
||||||
|
|
||||||
|
[gui.settings.remember_ui_scale]
|
||||||
|
en = "Always remember UI scale factor"
|
||||||
|
ru = "Всегда запоминать масштаб интерфейса"
|
||||||
|
es = "Recordar siempre la escala de la interfaz"
|
||||||
|
fr = "Toujours se souvenir de l'échelle de l'interface"
|
||||||
|
zh = "始终记住界面缩放比例"
|
||||||
|
ar = "تذكر عامل تكبير الواجهة دائمًا"
|
||||||
|
kz = "Әрқашан интерфейс масштабын есте сақтау"
|
||||||
|
he = "זכור תמיד קנה מידה של ממשק משתמש"
|
||||||
|
pt-BR = "Lembrar fator de escala da interface"
|
||||||
|
|
||||||
|
[gui.settings.pause_on_window_close]
|
||||||
|
en = "Pause audio playback when the window is closed"
|
||||||
|
ru = "Останавливать воспроизведение при закрытии окна"
|
||||||
|
es = "Pausar la reproducción de audio al cerrar la ventana"
|
||||||
|
fr = "Mettre en pause la lecture audio à la fermeture de la fenêtre"
|
||||||
|
zh = "关闭窗口时暂停音频播放"
|
||||||
|
ar = "إيقاف الصوت مؤقتًا عند إغلاق النافذة"
|
||||||
|
kz = "Терезе жабылған кезде дыбысты ойнатуды кідірту"
|
||||||
|
he = "השהה השמעת שמע כאשר החלון נסגר"
|
||||||
|
pt-BR = "Pausar reprodução de aúdio ao fechar a janela"
|
||||||
|
|
||||||
|
[gui.settings.version]
|
||||||
|
en = "GUI version: %{version}"
|
||||||
|
ru = "Версия GUI: %{version}"
|
||||||
|
es = "Versión de la GUI: %{version}"
|
||||||
|
fr = "Version de l'interface : %{version}"
|
||||||
|
zh = "GUI 版本: %{version}"
|
||||||
|
ar = "إصدار الواجهة: %{version}"
|
||||||
|
kz = "GUI нұсқасы: %{version}"
|
||||||
|
he = "גרסת ממשק משתמש: %{version}"
|
||||||
|
pt-BR = "Versão da GUI: %{version}"
|
||||||
|
|
||||||
|
# ----------------
|
||||||
|
# Hotkeys
|
||||||
|
# ----------------
|
||||||
|
|
||||||
|
[gui.hotkeys.header]
|
||||||
|
en = "Hotkeys"
|
||||||
|
ru = "Горячие клавиши"
|
||||||
|
es = "Atajos de teclado"
|
||||||
|
fr = "Raccourcis clavier"
|
||||||
|
zh = "快捷键"
|
||||||
|
ar = "اختصارات لوحة المفاتيح"
|
||||||
|
kz = "Ыстық пернелер"
|
||||||
|
he = "מקשי קיצור"
|
||||||
|
pt-BR = "Atalhos"
|
||||||
|
|
||||||
|
[gui.hotkeys.search_placeholder]
|
||||||
|
en = "Search hotkeys..."
|
||||||
|
ru = "Поиск горячих клавиш..."
|
||||||
|
es = "Buscar atajos..."
|
||||||
|
fr = "Rechercher des raccourcis..."
|
||||||
|
zh = "搜索快捷键..."
|
||||||
|
ar = "البحث عن الاختصارات..."
|
||||||
|
kz = "Ыстық пернелерді іздеу..."
|
||||||
|
he = "חפש מקשי קיצור..."
|
||||||
|
pt-BR = "Buscar atalhos..."
|
||||||
|
|
||||||
|
[gui.hotkeys.add_command_select]
|
||||||
|
en = "Add Command"
|
||||||
|
ru = "Добавить команду"
|
||||||
|
es = "Añadir comando"
|
||||||
|
fr = "Ajouter une commande"
|
||||||
|
zh = "添加命令"
|
||||||
|
ar = "إضافة أمر"
|
||||||
|
kz = "Команда қосу"
|
||||||
|
he = "הוסף פקודה"
|
||||||
|
pt-BR = "Adicionar comando"
|
||||||
|
|
||||||
|
[gui.hotkeys.toggle_pause_command]
|
||||||
|
en = "Toggle Pause"
|
||||||
|
ru = "Переключить паузу"
|
||||||
|
es = "Alternar pausa"
|
||||||
|
fr = "Basculer la pause"
|
||||||
|
zh = "切换暂停"
|
||||||
|
ar = "تبديل الإيقاف المؤقت"
|
||||||
|
kz = "Кідіртуді ауыстыру"
|
||||||
|
he = "הפעל/השהה"
|
||||||
|
pt-BR = "Alternar reprodução"
|
||||||
|
|
||||||
|
[gui.hotkeys.stop_playback_command]
|
||||||
|
en = "Stop Playback"
|
||||||
|
ru = "Остановить воспроизведение"
|
||||||
|
es = "Detener reproducción"
|
||||||
|
fr = "Arrêter la lecture"
|
||||||
|
zh = "停止播放"
|
||||||
|
ar = "إيقاف التشغيل"
|
||||||
|
kz = "Ойнатуды тоқтату"
|
||||||
|
he = "עצור השמעה"
|
||||||
|
pt-BR = "Parar reprodução"
|
||||||
|
|
||||||
|
[gui.hotkeys.pause_playback_command]
|
||||||
|
en = "Pause Playback"
|
||||||
|
ru = "Поставить воспроизведение на паузу"
|
||||||
|
es = "Pausar reproducción"
|
||||||
|
fr = "Mettre en pause la lecture"
|
||||||
|
zh = "暂停播放"
|
||||||
|
ar = "إيقاف التشغيل مؤقتاً"
|
||||||
|
kz = "Ойнатуды кідірту"
|
||||||
|
he = "השהה השמעה"
|
||||||
|
pt-BR = "Pausar reprodução"
|
||||||
|
|
||||||
|
[gui.hotkeys.resume_playback_command]
|
||||||
|
en = "Resume Playback"
|
||||||
|
ru = "Продолжить воспроизведение"
|
||||||
|
es = "Reanudar reproducción"
|
||||||
|
fr = "Reprendre la lecture"
|
||||||
|
zh = "恢复播放"
|
||||||
|
ar = "استئناف التشغيل"
|
||||||
|
kz = "Ойнатуды жалғастыру"
|
||||||
|
he = "המשך השמעה"
|
||||||
|
pt-BR = "Resumir reprodução"
|
||||||
|
|
||||||
|
[gui.hotkeys.toggle_loop_command]
|
||||||
|
en = "Toggle Loop"
|
||||||
|
ru = "Переключить зацикливание"
|
||||||
|
es = "Alternar bucle"
|
||||||
|
fr = "Basculer la boucle"
|
||||||
|
zh = "切换循环"
|
||||||
|
ar = "تبديل التكرار"
|
||||||
|
kz = "Қайталауды ауыстыру"
|
||||||
|
he = "הפעל/כבה לולאה"
|
||||||
|
pt-BR = "Alternar loop"
|
||||||
|
|
||||||
|
[gui.hotkeys.column_slot]
|
||||||
|
en = "Slot"
|
||||||
|
ru = "Слот"
|
||||||
|
es = "Ranura"
|
||||||
|
fr = "Emplacement"
|
||||||
|
zh = "插槽"
|
||||||
|
ar = "الخانة"
|
||||||
|
kz = "Ұяшық"
|
||||||
|
he = "משבצת"
|
||||||
|
pt-BR = "Slot"
|
||||||
|
|
||||||
|
[gui.hotkeys.column_sound]
|
||||||
|
en = "Sound"
|
||||||
|
ru = "Звук"
|
||||||
|
es = "Sonido"
|
||||||
|
fr = "Son"
|
||||||
|
zh = "声音"
|
||||||
|
ar = "الصوت"
|
||||||
|
kz = "Дыбыс"
|
||||||
|
he = "צליל"
|
||||||
|
pt-BR = "Som"
|
||||||
|
|
||||||
|
[gui.hotkeys.column_key_chord]
|
||||||
|
en = "Key Chord"
|
||||||
|
ru = "Клавиша"
|
||||||
|
es = "Combinación de teclas"
|
||||||
|
fr = "Combinaison de touches"
|
||||||
|
zh = "组合键"
|
||||||
|
ar = "تركيبة المفاتيح"
|
||||||
|
kz = "Пернелер тіркесімі"
|
||||||
|
he = "צירוף מקשים"
|
||||||
|
pt-BR = "Combinação de teclas"
|
||||||
|
|
||||||
|
[gui.hotkeys.column_actions]
|
||||||
|
en = "Actions"
|
||||||
|
ru = "Действия"
|
||||||
|
es = "Acciones"
|
||||||
|
fr = "Actions"
|
||||||
|
zh = "操作"
|
||||||
|
ar = "الإجراءات"
|
||||||
|
kz = "Әрекеттер"
|
||||||
|
he = "פעולות"
|
||||||
|
pt-BR = "Ações"
|
||||||
|
|
||||||
|
[gui.hotkeys.no_hotkeys_configured]
|
||||||
|
en = "No hotkeys configured"
|
||||||
|
ru = "Горячие клавиши не настроены"
|
||||||
|
es = "No hay atajos configurados"
|
||||||
|
fr = "Aucun raccourci configuré"
|
||||||
|
zh = "未配置快捷键"
|
||||||
|
ar = "لا توجد اختصارات معينة"
|
||||||
|
kz = "Ыстық пернелер бапталмаған"
|
||||||
|
he = "לא הוגדרו מקשי קיצור"
|
||||||
|
pt-BR = "Nenhum atalho configurado"
|
||||||
|
|
||||||
|
[gui.hotkeys.capture.header]
|
||||||
|
en = "Press a key combination (e.g. Ctrl+Alt+1)"
|
||||||
|
ru = "Нажмите сочетание клавиш (например, Ctrl+Alt+1)"
|
||||||
|
es = "Presione una combinación de teclas (ej. Ctrl+Alt+1)"
|
||||||
|
fr = "Appuyez sur une combinaison de touches (ex. Ctrl+Alt+1)"
|
||||||
|
zh = "按下一个组合键 (例如 Ctrl+Alt+1)"
|
||||||
|
ar = "اضغط على تركيبة مفاتيح (مثلاً Ctrl+Alt+1)"
|
||||||
|
kz = "Пернелер тіркесімін басыңыз (мысалы, Ctrl+Alt+1)"
|
||||||
|
he = "לחץ על צירוף מקשים (למשל Ctrl+Alt+1)"
|
||||||
|
pt-BR = "Pressione uma combinação de tecla (ex: Ctrl+Alt+1)"
|
||||||
|
|
||||||
|
[gui.hotkeys.capture.for]
|
||||||
|
en = "for"
|
||||||
|
ru = "для"
|
||||||
|
es = "para"
|
||||||
|
fr = "pour"
|
||||||
|
zh = "用于"
|
||||||
|
ar = "لـ"
|
||||||
|
kz = "үшін"
|
||||||
|
he = "עבור"
|
||||||
|
pt-BR = "para"
|
||||||
|
|
||||||
|
[gui.hotkeys.capture.cancel]
|
||||||
|
en = "Press Escape to cancel"
|
||||||
|
ru = "Нажмите Escape для отмены"
|
||||||
|
es = "Presione Escape para cancelar"
|
||||||
|
fr = "Appuyez sur Échap pour annuler"
|
||||||
|
zh = "按 Escape 取消"
|
||||||
|
ar = "اضغط Esc للإلغاء"
|
||||||
|
kz = "Болдырмау үшін Escape пернесін басыңыз"
|
||||||
|
he = "לחץ על Escape לביטול"
|
||||||
|
pt-BR = "Pressione Esc para cancelar"
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
pkgbase = pwsp-bin
|
||||||
|
pkgdesc = Lets you play audio files through your microphone (Pre-built binaries)
|
||||||
|
pkgver = 1.9.1
|
||||||
|
pkgrel = 1
|
||||||
|
url = https://github.com/arabianq/pipewire-soundpad
|
||||||
|
arch = x86_64
|
||||||
|
license = MIT
|
||||||
|
depends = pipewire
|
||||||
|
depends = alsa-lib
|
||||||
|
provides = pwsp
|
||||||
|
conflicts = pwsp
|
||||||
|
source = pwsp-bin-1.9.1.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.9.1/pwsp-v1.9.1-linux-x64.zip
|
||||||
|
source = pipewire-soundpad-1.9.1.tar.gz :: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.9.1.tar.gz
|
||||||
|
sha256sums = SKIP
|
||||||
|
sha256sums = SKIP
|
||||||
|
|
||||||
|
pkgname = pwsp-bin
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# 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/
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Maintainer: Alexander Tarasov <a.tevg@ya.ru>
|
||||||
|
pkgname=pwsp-bin
|
||||||
|
_pkgname=pipewire-soundpad
|
||||||
|
pkgver=1.9.1
|
||||||
|
pkgrel=1
|
||||||
|
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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
pkgbase = pwsp
|
||||||
|
pkgdesc = Lets you play audio files through your microphone
|
||||||
|
pkgver = 1.9.1
|
||||||
|
pkgrel = 1
|
||||||
|
url = https://github.com/arabianq/pipewire-soundpad
|
||||||
|
arch = any
|
||||||
|
license = MIT
|
||||||
|
makedepends = clang
|
||||||
|
makedepends = rust
|
||||||
|
makedepends = cargo
|
||||||
|
makedepends = cmake
|
||||||
|
makedepends = pipewire
|
||||||
|
makedepends = alsa-lib
|
||||||
|
source = https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.9.1.tar.gz
|
||||||
|
sha256sums = SKIP
|
||||||
|
|
||||||
|
pkgname = pwsp
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# 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/
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Maintainer: Alexander Tarasov <a.tevg@ya.ru>
|
||||||
|
pkgsubn=pwsp
|
||||||
|
pkgname=pwsp
|
||||||
|
pkgver=1.9.1
|
||||||
|
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 cmake pipewire alsa-lib)
|
||||||
|
source=("$url/archive/refs/tags/v$pkgver.tar.gz")
|
||||||
|
sha256sums=('SKIP')
|
||||||
|
|
||||||
|
|
||||||
|
prepare() {
|
||||||
|
cd "${srcdir}/pipewire-soundpad-${pkgver}"
|
||||||
|
|
||||||
|
export CARGO_HOME="${srcdir}/${pkgname%}/.cargo" # Download all to src directory, not in ~/.cargo
|
||||||
|
|
||||||
|
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
cd "${srcdir}/pipewire-soundpad-${pkgver}"
|
||||||
|
|
||||||
|
export CARGO_ENCODED_RUSTFLAGS="--remap-path-prefix=${srcdir}=/" # Prevent warning: 'Package contains reference to $srcdir'
|
||||||
|
[[ -n "${_sccache}" ]] && export RUSTC_WRAPPER=sccache # If $_sccache not empty, build using binary cache
|
||||||
|
|
||||||
|
export CARGO_HOME="${srcdir}/${pkgname%}/.cargo" # Use downloaded earlier from src directory, not from ~/.cargo
|
||||||
|
export CARGO_TARGET_DIR=target # Place the output in target relative to the current directory
|
||||||
|
|
||||||
|
cargo build --frozen --release
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
package() {
|
||||||
|
cd "${srcdir}/pipewire-soundpad-${pkgver}"
|
||||||
|
|
||||||
|
install -Dm755 "target/release/pwsp-cli" "${pkgdir}/usr/bin/pwsp-cli"
|
||||||
|
install -Dm755 "target/release/pwsp-daemon" "${pkgdir}/usr/bin/pwsp-daemon"
|
||||||
|
install -Dm755 "target/release/pwsp-gui" "${pkgdir}/usr/bin/pwsp-gui"
|
||||||
|
|
||||||
|
install -Dm644 "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 one or more lines are too long
Executable
+19
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ ! -f "Cargo.lock" ]; then
|
||||||
|
echo "Error: Cargo.lock not found. Please run this script from the project root."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Downloading flatpak-cargo-generator.py..."
|
||||||
|
curl -sLO https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/master/cargo/flatpak-cargo-generator.py
|
||||||
|
chmod +x flatpak-cargo-generator.py
|
||||||
|
|
||||||
|
echo "Generating cargo-sources.json..."
|
||||||
|
python3 flatpak-cargo-generator.py Cargo.lock -o packages/flatpak/cargo-sources.json
|
||||||
|
|
||||||
|
echo "Cleaning up..."
|
||||||
|
rm flatpak-cargo-generator.py
|
||||||
|
|
||||||
|
echo "Successfully generated packages/flatpak/cargo-sources.json"
|
||||||
Executable
+35
@@ -0,0 +1,35 @@
|
|||||||
|
#!/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"])
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[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;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?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.9.1" date="2026-05-22" />
|
||||||
|
</releases>
|
||||||
|
<content_rating type="oars-1.1" />
|
||||||
|
</component>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
%global cargo_install_lib 0
|
%global cargo_install_lib 0
|
||||||
|
|
||||||
Name: pwsp
|
Name: pwsp
|
||||||
Version: 1.0.2
|
Version: 1.9.1
|
||||||
Release: %autorelease
|
Release: %autorelease
|
||||||
Summary: Lets you play audio files through your microphone
|
Summary: Lets you play audio files through your microphone
|
||||||
|
|
||||||
@@ -18,6 +18,9 @@ BuildRequires: cargo
|
|||||||
BuildRequires: pipewire-devel
|
BuildRequires: pipewire-devel
|
||||||
BuildRequires: alsa-lib-devel
|
BuildRequires: alsa-lib-devel
|
||||||
BuildRequires: clang-devel
|
BuildRequires: clang-devel
|
||||||
|
BuildRequires: cmake
|
||||||
|
BuildRequires: dbus-devel
|
||||||
|
BuildRequires: pkgconf-pkg-config
|
||||||
|
|
||||||
%global _description %{expand:
|
%global _description %{expand:
|
||||||
PWSP lets you play audio files through your microphone. Has both CLI and
|
PWSP lets you play audio files through your microphone. Has both CLI and
|
||||||
+130
-31
@@ -1,9 +1,10 @@
|
|||||||
|
use anyhow::{Result, anyhow};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use pwsp::{
|
use pwsp::{
|
||||||
types::socket::Request,
|
types::socket::Request,
|
||||||
utils::daemon::{make_request, wait_for_daemon},
|
utils::daemon::{make_request, wait_for_daemon},
|
||||||
};
|
};
|
||||||
use std::{error::Error, path::PathBuf};
|
use std::path::PathBuf;
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(version, about, long_about = None)]
|
#[command(version, about, long_about = None)]
|
||||||
@@ -14,17 +15,17 @@ struct Cli {
|
|||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
enum Commands {
|
enum Commands {
|
||||||
/// Perform an action (ping, pause, resume, stop, play)
|
/// Perform an action (ping, pause, resume, toggle-pause, stop, play)
|
||||||
Action {
|
Action {
|
||||||
#[clap(subcommand)]
|
#[clap(subcommand)]
|
||||||
action: Actions,
|
action: Actions,
|
||||||
},
|
},
|
||||||
/// Get information from the player (is paused, volume, position, state)
|
/// Get information from the player (is paused, volume, position, duration, state, current-file-path, input, inputs)
|
||||||
Get {
|
Get {
|
||||||
#[clap(subcommand)]
|
#[clap(subcommand)]
|
||||||
parameter: GetCommands,
|
parameter: GetCommands,
|
||||||
},
|
},
|
||||||
/// Set information in the player (volume, position)
|
/// Set information in the player (volume, position, input)
|
||||||
Set {
|
Set {
|
||||||
#[clap(subcommand)]
|
#[clap(subcommand)]
|
||||||
parameter: SetCommands,
|
parameter: SetCommands,
|
||||||
@@ -35,14 +36,45 @@ 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 { file_path: PathBuf },
|
Play {
|
||||||
|
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)]
|
||||||
@@ -50,33 +82,72 @@ enum GetCommands {
|
|||||||
/// Check if the player is paused
|
/// Check if the player is paused
|
||||||
IsPaused,
|
IsPaused,
|
||||||
/// Playback volume
|
/// Playback volume
|
||||||
Volume,
|
Volume {
|
||||||
/// Playback position
|
#[clap(short, long)]
|
||||||
Position,
|
id: Option<u32>,
|
||||||
|
},
|
||||||
|
/// Playback position (in seconds)
|
||||||
|
Position {
|
||||||
|
#[clap(short, long)]
|
||||||
|
id: Option<u32>,
|
||||||
|
},
|
||||||
/// Duration of the current file
|
/// Duration of the current file
|
||||||
Duration,
|
Duration {
|
||||||
/// Player state
|
#[clap(short, long)]
|
||||||
|
id: Option<u32>,
|
||||||
|
},
|
||||||
|
/// Player state (Playing, Paused or Stopped)
|
||||||
State,
|
State,
|
||||||
/// Current playing file path
|
/// Get all playing tracks
|
||||||
CurrentFilePath,
|
Tracks,
|
||||||
/// 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: f32 },
|
Volume {
|
||||||
/// Playback position
|
volume: f32,
|
||||||
Position { position: f32 },
|
#[clap(short, long)]
|
||||||
/// Input
|
id: Option<u32>,
|
||||||
|
},
|
||||||
|
/// 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]
|
||||||
async fn main() -> Result<(), Box<dyn Error>> {
|
async fn main() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
wait_for_daemon().await?;
|
wait_for_daemon().await?;
|
||||||
@@ -84,29 +155,57 @@ 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::Pause => Request::pause(),
|
Actions::Kill => Request::kill(),
|
||||||
Actions::Resume => Request::resume(),
|
Actions::Pause { id } => Request::pause(id),
|
||||||
Actions::Stop => Request::stop(),
|
Actions::Resume { id } => Request::resume(id),
|
||||||
Actions::Play { file_path } => Request::play(file_path.to_str().unwrap()),
|
Actions::TogglePause { id } => Request::toggle_pause(id),
|
||||||
|
Actions::Stop { id } => Request::stop(id),
|
||||||
|
Actions::Play {
|
||||||
|
file_path,
|
||||||
|
concurrent,
|
||||||
|
} => Request::play(&file_path.to_string_lossy(), concurrent),
|
||||||
|
Actions::ToggleLoop { id } => Request::toggle_loop(id),
|
||||||
|
Actions::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 => Request::get_volume(),
|
GetCommands::Volume { id } => Request::get_volume(id),
|
||||||
GetCommands::Position => Request::get_position(),
|
GetCommands::Position { id } => Request::get_position(id),
|
||||||
GetCommands::Duration => Request::get_duration(),
|
GetCommands::Duration { id } => Request::get_duration(id),
|
||||||
GetCommands::State => Request::get_state(),
|
GetCommands::State => Request::get_state(),
|
||||||
GetCommands::CurrentFilePath => Request::get_current_file_path(),
|
GetCommands::Tracks => Request::get_tracks(),
|
||||||
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 } => Request::set_volume(volume),
|
SetCommands::Volume { volume, id } => Request::set_volume(volume, id),
|
||||||
SetCommands::Position { position } => Request::seek(position),
|
SetCommands::Position { position, id } => Request::seek(position, id),
|
||||||
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).await?;
|
let response = make_request(request).await.map_err(|e| anyhow!(e))?;
|
||||||
println!("{} : {}", response.status, response.message);
|
println!("{} : {}", response.status, response.message);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
+106
-14
@@ -1,49 +1,87 @@
|
|||||||
|
use anyhow::{Result, anyhow};
|
||||||
use pwsp::{
|
use pwsp::{
|
||||||
types::socket::{Request, Response},
|
types::socket::{MAX_MESSAGE_SIZE, 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, link_player_to_virtual_mic,
|
is_daemon_running,
|
||||||
},
|
},
|
||||||
|
global_hotkeys::start_global_hotkey_listener,
|
||||||
pipewire::create_virtual_mic,
|
pipewire::create_virtual_mic,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use std::{error::Error, fs};
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
use std::{fs, time::Duration};
|
||||||
use tokio::{
|
use tokio::{
|
||||||
io::{AsyncReadExt, AsyncWriteExt},
|
io::{AsyncReadExt, AsyncWriteExt},
|
||||||
net::UnixListener,
|
net::UnixListener,
|
||||||
|
time::sleep,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn Error>> {
|
async fn main() -> Result<()> {
|
||||||
create_runtime_dir()?;
|
create_runtime_dir()?;
|
||||||
|
|
||||||
if is_daemon_running()? {
|
if is_daemon_running()? {
|
||||||
return Err("Another instance is already running.".into());
|
return Err(anyhow!("Another instance is already running."));
|
||||||
}
|
}
|
||||||
|
|
||||||
get_daemon_config(); // Initialize daemon config
|
get_daemon_config(); // Initialize daemon config
|
||||||
create_virtual_mic()?;
|
create_virtual_mic()?;
|
||||||
get_audio_player().await; // Initialize audio player
|
if let Err(err) = get_audio_player().await {
|
||||||
link_player_to_virtual_mic().await?;
|
eprintln!("Failed to initialize audio player: {}", err);
|
||||||
|
} // Initialize audio player
|
||||||
|
|
||||||
|
tokio::spawn(async {
|
||||||
|
start_global_hotkey_listener().await;
|
||||||
|
});
|
||||||
|
|
||||||
let runtime_dir = get_runtime_dir();
|
let runtime_dir = get_runtime_dir();
|
||||||
|
|
||||||
let lock_file = fs::File::create(runtime_dir.join("daemon.lock"))?;
|
let lock_file = fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.write(true)
|
||||||
|
.truncate(false)
|
||||||
|
.open(runtime_dir.join("daemon.lock"))?;
|
||||||
lock_file.lock()?;
|
lock_file.lock()?;
|
||||||
|
|
||||||
let socket_path = runtime_dir.join("daemon.sock");
|
let socket_path = runtime_dir.join("daemon.sock");
|
||||||
if fs::metadata(&socket_path).is_ok() {
|
if let Err(e) = fs::remove_file(&socket_path)
|
||||||
fs::remove_file(&socket_path)?;
|
&& e.kind() != std::io::ErrorKind::NotFound
|
||||||
|
{
|
||||||
|
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<()> {
|
||||||
loop {
|
loop {
|
||||||
let (mut stream, _addr) = listener.accept().await?;
|
let (mut stream, _addr) = listener.accept().await?;
|
||||||
|
|
||||||
@@ -57,14 +95,35 @@ async fn main() -> 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 = serde_json::from_slice(&buffer).unwrap();
|
let request: Request = match serde_json::from_slice(&buffer) {
|
||||||
println!("Received request: {:?}", request);
|
Ok(req) => req,
|
||||||
|
Err(err) => {
|
||||||
|
let response =
|
||||||
|
Response::new(false, format!("Failed to parse request: {}", err));
|
||||||
|
let response_data = match serde_json::to_vec(&response) {
|
||||||
|
Ok(data) => data,
|
||||||
|
Err(_) => return, // Should not happen with this simple Response
|
||||||
|
};
|
||||||
|
let response_len = response_data.len() as u32;
|
||||||
|
let _ = stream.write_all(&response_len.to_le_bytes()).await;
|
||||||
|
let _ = stream.write_all(&response_data).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
// ---------- Read request (end) ----------
|
// ---------- Read request (end) ----------
|
||||||
|
|
||||||
// ---------- Generate response (start) ----------
|
// ---------- Generate response (start) ----------
|
||||||
@@ -78,7 +137,13 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
// ---------- Generate response (end) ----------
|
// ---------- Generate response (end) ----------
|
||||||
|
|
||||||
// ---------- Send response (start) ----------
|
// ---------- Send response (start) ----------
|
||||||
let response_data = serde_json::to_vec(&response).unwrap();
|
let response_data = match serde_json::to_vec(&response) {
|
||||||
|
Ok(data) => data,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("Failed to serialize response: {}", err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
let response_len = response_data.len() as u32;
|
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() {
|
||||||
@@ -89,8 +154,35 @@ async fn main() -> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
-325
@@ -1,325 +0,0 @@
|
|||||||
use crate::gui::SoundpadGui;
|
|
||||||
use egui::{
|
|
||||||
AtomExt, Button, Color32, ComboBox, FontFamily, Label, RichText, ScrollArea, Slider, TextEdit,
|
|
||||||
Ui, Vec2,
|
|
||||||
};
|
|
||||||
use egui_material_icons::icons;
|
|
||||||
use pwsp::types::audio_player::PlayerState;
|
|
||||||
use pwsp::utils::gui::format_time_pair;
|
|
||||||
use std::{error::Error, path::PathBuf};
|
|
||||||
|
|
||||||
impl SoundpadGui {
|
|
||||||
pub fn draw_waiting_for_daemon(&mut self, ui: &mut Ui) {
|
|
||||||
ui.centered_and_justified(|ui| {
|
|
||||||
ui.label(
|
|
||||||
RichText::new("Waiting for PWSP daemon to start...")
|
|
||||||
.size(34.0)
|
|
||||||
.monospace(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn draw_settings(&mut self, ui: &mut Ui) {
|
|
||||||
ui.vertical(|ui| {
|
|
||||||
ui.spacing_mut().item_spacing.y = 5.0;
|
|
||||||
// --------- Back Button and Title ----------
|
|
||||||
ui.horizontal_top(|ui| {
|
|
||||||
let back_button = Button::new(icons::ICON_ARROW_BACK).frame(false);
|
|
||||||
let back_button_response = ui.add(back_button);
|
|
||||||
if back_button_response.clicked() {
|
|
||||||
self.app_state.show_settings = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.add_space(ui.available_width() / 2.0 - 40.0);
|
|
||||||
|
|
||||||
ui.label(RichText::new("Settings").color(Color32::WHITE).monospace());
|
|
||||||
});
|
|
||||||
// --------------------------------
|
|
||||||
|
|
||||||
ui.separator();
|
|
||||||
ui.add_space(20.0);
|
|
||||||
|
|
||||||
// --------- Checkboxes ----------
|
|
||||||
let save_volume_response =
|
|
||||||
ui.checkbox(&mut self.config.save_volume, "Always remember volume");
|
|
||||||
let save_input_response =
|
|
||||||
ui.checkbox(&mut self.config.save_input, "Always remember microphone");
|
|
||||||
let save_scale_response = ui.checkbox(
|
|
||||||
&mut self.config.save_scale_factor,
|
|
||||||
"Always remember UI scale factor",
|
|
||||||
);
|
|
||||||
|
|
||||||
if save_volume_response.changed()
|
|
||||||
|| save_input_response.changed()
|
|
||||||
|| save_scale_response.changed()
|
|
||||||
{
|
|
||||||
self.config.save_to_file().ok();
|
|
||||||
}
|
|
||||||
// --------------------------------
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn draw(&mut self, ui: &mut Ui) -> Result<(), Box<dyn Error>> {
|
|
||||||
self.draw_header(ui);
|
|
||||||
self.draw_body(ui);
|
|
||||||
ui.separator();
|
|
||||||
self.draw_footer(ui);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_header(&mut self, ui: &mut Ui) {
|
|
||||||
ui.vertical_centered_justified(|ui| {
|
|
||||||
// Current file name
|
|
||||||
ui.label(
|
|
||||||
RichText::new(
|
|
||||||
self.audio_player_state
|
|
||||||
.current_file_path
|
|
||||||
.file_stem()
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_str()
|
|
||||||
.unwrap_or_default(),
|
|
||||||
)
|
|
||||||
.color(Color32::WHITE)
|
|
||||||
.family(FontFamily::Monospace),
|
|
||||||
);
|
|
||||||
// Media controls
|
|
||||||
self.draw_controls(ui);
|
|
||||||
ui.separator();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_controls(&mut self, ui: &mut Ui) {
|
|
||||||
ui.horizontal_top(|ui| {
|
|
||||||
// ---------- Play Button ----------
|
|
||||||
let play_button = Button::new(match self.audio_player_state.state {
|
|
||||||
PlayerState::Playing => icons::ICON_PAUSE,
|
|
||||||
PlayerState::Paused | PlayerState::Stopped => icons::ICON_PLAY_ARROW,
|
|
||||||
})
|
|
||||||
.corner_radius(15.0);
|
|
||||||
|
|
||||||
let play_button_response = ui.add_sized([30.0, 30.0], play_button);
|
|
||||||
if play_button_response.clicked() {
|
|
||||||
self.play_toggle();
|
|
||||||
}
|
|
||||||
// --------------------------------
|
|
||||||
|
|
||||||
// ---------- Position Slider ----------
|
|
||||||
let position_slider = Slider::new(
|
|
||||||
&mut self.app_state.position_slider_value,
|
|
||||||
0.0..=self.audio_player_state.duration,
|
|
||||||
)
|
|
||||||
.show_value(false)
|
|
||||||
.step_by(1.0);
|
|
||||||
|
|
||||||
let default_slider_width = ui.spacing().slider_width;
|
|
||||||
let position_slider_width = ui.available_width()
|
|
||||||
- (30.0 * 3.0)
|
|
||||||
- default_slider_width
|
|
||||||
- (ui.spacing().item_spacing.x * 5.0);
|
|
||||||
ui.spacing_mut().slider_width = position_slider_width;
|
|
||||||
let position_slider_response = ui.add_sized([30.0, 30.0], position_slider);
|
|
||||||
if position_slider_response.drag_stopped() {
|
|
||||||
self.app_state.position_dragged = true;
|
|
||||||
}
|
|
||||||
// --------------------------------
|
|
||||||
|
|
||||||
// ---------- Time Label ----------
|
|
||||||
let time_label = Label::new(
|
|
||||||
RichText::new(format_time_pair(
|
|
||||||
self.audio_player_state.position,
|
|
||||||
self.audio_player_state.duration,
|
|
||||||
))
|
|
||||||
.monospace(),
|
|
||||||
);
|
|
||||||
ui.add_sized([30.0, 30.0], time_label);
|
|
||||||
// --------------------------------
|
|
||||||
|
|
||||||
// ---------- Volume Icon ----------
|
|
||||||
let volume_icon = if self.audio_player_state.volume > 0.7 {
|
|
||||||
icons::ICON_VOLUME_UP
|
|
||||||
} else if self.audio_player_state.volume == 0.0 {
|
|
||||||
icons::ICON_VOLUME_OFF
|
|
||||||
} else if self.audio_player_state.volume < 0.3 {
|
|
||||||
icons::ICON_VOLUME_MUTE
|
|
||||||
} else {
|
|
||||||
icons::ICON_VOLUME_DOWN
|
|
||||||
};
|
|
||||||
let volume_icon = Label::new(RichText::new(volume_icon).size(18.0));
|
|
||||||
ui.add_sized([30.0, 25.0], volume_icon);
|
|
||||||
// --------------------------------
|
|
||||||
|
|
||||||
// ---------- Volume Slider ----------
|
|
||||||
let volume_slider = Slider::new(&mut self.app_state.volume_slider_value, 0.0..=1.0)
|
|
||||||
.show_value(false)
|
|
||||||
.step_by(0.01);
|
|
||||||
|
|
||||||
ui.spacing_mut().slider_width = default_slider_width;
|
|
||||||
ui.spacing_mut().item_spacing.x = 0.0;
|
|
||||||
|
|
||||||
let volume_slider_response = ui.add_sized([30.0, 30.0], volume_slider);
|
|
||||||
if volume_slider_response.drag_stopped() {
|
|
||||||
self.app_state.volume_dragged = true;
|
|
||||||
}
|
|
||||||
// --------------------------------
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_body(&mut self, ui: &mut Ui) {
|
|
||||||
let dirs_size = Vec2::new(ui.available_width() / 4.0, ui.available_height() - 40.0);
|
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
self.draw_dirs(ui, dirs_size);
|
|
||||||
ui.separator();
|
|
||||||
|
|
||||||
let files_size = Vec2::new(ui.available_width(), ui.available_height() - 40.0);
|
|
||||||
self.draw_files(ui, files_size);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_dirs(&mut self, ui: &mut Ui, area_size: Vec2) {
|
|
||||||
ui.vertical(|ui| {
|
|
||||||
ui.set_min_width(area_size.x);
|
|
||||||
ui.set_min_height(area_size.y);
|
|
||||||
|
|
||||||
ScrollArea::vertical().id_salt(0).show(ui, |ui| {
|
|
||||||
let mut dirs: Vec<PathBuf> = self.app_state.dirs.iter().cloned().collect();
|
|
||||||
dirs.sort();
|
|
||||||
for path in dirs.iter() {
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
let name = path
|
|
||||||
.file_name()
|
|
||||||
.map(|s| s.to_string_lossy().to_string())
|
|
||||||
.unwrap_or_else(|| path.to_string_lossy().to_string());
|
|
||||||
|
|
||||||
let dir_button =
|
|
||||||
Button::new(RichText::new(name).atom_max_width(area_size.x))
|
|
||||||
.frame(false);
|
|
||||||
let dir_button_response = ui.add(dir_button);
|
|
||||||
if dir_button_response.clicked() {
|
|
||||||
self.app_state.current_dir = Some(path.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
let delete_dir_button = Button::new(icons::ICON_DELETE).frame(false);
|
|
||||||
let delete_dir_button_response =
|
|
||||||
ui.add_sized([18.0, 18.0], delete_dir_button);
|
|
||||||
if delete_dir_button_response.clicked() {
|
|
||||||
self.remove_dir(path.clone());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
let add_dir_button = egui::Button::new(icons::ICON_ADD).frame(false);
|
|
||||||
let add_dir_button_response = ui.add_sized([18.0, 18.0], add_dir_button);
|
|
||||||
if add_dir_button_response.clicked() {
|
|
||||||
self.add_dir();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_files(&mut self, ui: &mut Ui, area_size: Vec2) {
|
|
||||||
let extensions = [
|
|
||||||
"mp3", "wav", "ogg", "flac", "mp4", "m4a", "aac", "mov", "mkv", "webm", "avi",
|
|
||||||
];
|
|
||||||
|
|
||||||
ui.vertical(|ui| {
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.add_sized(
|
|
||||||
[ui.available_width(), 22.0],
|
|
||||||
TextEdit::singleline(&mut self.app_state.search_query).hint_text("Search..."),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
ui.separator();
|
|
||||||
|
|
||||||
ScrollArea::vertical().id_salt(1).show(ui, |ui| {
|
|
||||||
ui.set_min_width(area_size.x);
|
|
||||||
ui.set_min_height(area_size.y);
|
|
||||||
|
|
||||||
ui.vertical(|ui| {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
let file_name = entry_path
|
|
||||||
.file_name()
|
|
||||||
.unwrap()
|
|
||||||
.to_string_lossy()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let search_query = self
|
|
||||||
.app_state
|
|
||||||
.search_query
|
|
||||||
.to_lowercase()
|
|
||||||
.trim()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
if !file_name.to_lowercase().contains(search_query.as_str()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let file_button = Button::new(file_name).frame(false);
|
|
||||||
let file_button_response = ui.add(file_button);
|
|
||||||
if file_button_response.clicked() {
|
|
||||||
self.play_file(entry_path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_footer(&mut self, ui: &mut Ui) {
|
|
||||||
ui.add_space(5.0);
|
|
||||||
ui.horizontal_top(|ui| {
|
|
||||||
// ---------- Microphone selection ----------
|
|
||||||
let mut mics: Vec<(&String, &String)> =
|
|
||||||
self.audio_player_state.all_inputs.iter().collect();
|
|
||||||
mics.sort_by_key(|(k, _)| *k);
|
|
||||||
|
|
||||||
let mut selected_input = self.audio_player_state.current_input.to_owned();
|
|
||||||
let prev_input = selected_input.to_owned();
|
|
||||||
ComboBox::from_label("Choose microphone")
|
|
||||||
.selected_text(
|
|
||||||
self.audio_player_state
|
|
||||||
.all_inputs
|
|
||||||
.get(&selected_input)
|
|
||||||
.unwrap_or(&String::new()),
|
|
||||||
)
|
|
||||||
.show_ui(ui, |ui| {
|
|
||||||
for (name, nick) in mics {
|
|
||||||
ui.selectable_value(&mut selected_input, name.to_owned(), nick);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if selected_input != prev_input {
|
|
||||||
self.set_input(selected_input);
|
|
||||||
}
|
|
||||||
// --------------------------------
|
|
||||||
|
|
||||||
ui.add_space(ui.available_width() - 18.0 - ui.spacing().item_spacing.x);
|
|
||||||
|
|
||||||
// ---------- Settings button ----------
|
|
||||||
let settings_button = Button::new(icons::ICON_SETTINGS).frame(false);
|
|
||||||
let settings_button_response = ui.add_sized([18.0, 18.0], settings_button);
|
|
||||||
if settings_button_response.clicked() {
|
|
||||||
self.app_state.show_settings = true;
|
|
||||||
}
|
|
||||||
// --------------------------------
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+204
-9
@@ -1,24 +1,219 @@
|
|||||||
use crate::gui::SoundpadGui;
|
use crate::gui::SoundpadGui;
|
||||||
use egui::{Context, Key};
|
use egui::{Context, Id, Key, Modifiers};
|
||||||
|
use pwsp::types::socket::Request;
|
||||||
|
use pwsp::utils::gui::make_request_async;
|
||||||
|
|
||||||
|
/// Convert an egui Key + Modifiers to a normalized chord string like "Ctrl+Shift+A".
|
||||||
|
fn chord_from_event(modifiers: &Modifiers, key: &Key) -> Option<String> {
|
||||||
|
let key_name = key.name();
|
||||||
|
let is_valid = (key_name.len() == 1
|
||||||
|
&& key_name
|
||||||
|
.chars()
|
||||||
|
.next()
|
||||||
|
.is_some_and(|c| c.is_ascii_alphanumeric()))
|
||||||
|
|| (key_name.starts_with('F')
|
||||||
|
&& key_name.len() > 1
|
||||||
|
&& key_name[1..].chars().all(|c| c.is_ascii_digit()));
|
||||||
|
if !is_valid {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require at least one modifier for hotkey chords (ignoring command/Super due to Wayland/Niri bug)
|
||||||
|
if !modifiers.ctrl && !modifiers.alt && !modifiers.shift {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut parts = vec![];
|
||||||
|
if modifiers.ctrl {
|
||||||
|
parts.push("Ctrl");
|
||||||
|
}
|
||||||
|
if modifiers.alt {
|
||||||
|
parts.push("Alt");
|
||||||
|
}
|
||||||
|
if modifiers.shift {
|
||||||
|
parts.push("Shift");
|
||||||
|
}
|
||||||
|
// We intentionally ignore modifiers.command (Super) here to bypass a Wayland/Niri bug
|
||||||
|
// where the Super key modifier is constantly active.
|
||||||
|
|
||||||
|
parts.push(key_name);
|
||||||
|
|
||||||
|
Some(parts.join("+"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a chord string back to (Modifiers, Key) for matching.
|
||||||
|
pub fn parse_chord(chord: &str) -> Option<(Modifiers, Key)> {
|
||||||
|
let parts: Vec<&str> = chord.split('+').collect();
|
||||||
|
if parts.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut modifiers = Modifiers::NONE;
|
||||||
|
for &part in &parts[..parts.len() - 1] {
|
||||||
|
match part {
|
||||||
|
"Ctrl" => modifiers.ctrl = true,
|
||||||
|
"Alt" => modifiers.alt = true,
|
||||||
|
"Shift" => modifiers.shift = true,
|
||||||
|
"Super" => modifiers.command = true,
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let key_name = parts[parts.len() - 1];
|
||||||
|
let is_valid = (key_name.len() == 1
|
||||||
|
&& key_name
|
||||||
|
.chars()
|
||||||
|
.next()
|
||||||
|
.is_some_and(|c| c.is_ascii_alphanumeric()))
|
||||||
|
|| (key_name.starts_with('F')
|
||||||
|
&& key_name.len() > 1
|
||||||
|
&& key_name[1..].chars().all(|c| c.is_ascii_digit()));
|
||||||
|
|
||||||
|
if !is_valid {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = Key::from_name(key_name)?;
|
||||||
|
|
||||||
|
Some((modifiers, key))
|
||||||
|
}
|
||||||
|
|
||||||
impl SoundpadGui {
|
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) {
|
||||||
ctx.input(|i| {
|
let _modifiers = self.modifiers(ctx);
|
||||||
if i.key_pressed(Key::Escape) {
|
let search_focused = {
|
||||||
std::process::exit(0);
|
if let Some(focused_id) = self.get_focused(ctx)
|
||||||
|
&& let Some(search_id) = self.app_state.search_field_id
|
||||||
|
&& focused_id.eq(&search_id)
|
||||||
|
{
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle hotkey capture mode: listen for a key chord to assign
|
||||||
|
if self.app_state.hotkey_capture_active {
|
||||||
|
if self.key_pressed(ctx, Key::Escape) {
|
||||||
|
self.app_state.hotkey_capture_active = false;
|
||||||
|
self.app_state.assigning_hotkey_slot = None;
|
||||||
|
self.app_state.assigning_hotkey_for_file = None;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if !self.app_state.show_settings && i.key_pressed(Key::Space) {
|
// Try to capture a chord from any key press
|
||||||
self.play_toggle();
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
if i.key_pressed(Key::Slash) {
|
// Open/close settings
|
||||||
|
if !search_focused && self.key_pressed(ctx, Key::I) {
|
||||||
self.app_state.show_settings = !self.app_state.show_settings;
|
self.app_state.show_settings = !self.app_state.show_settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.app_state.show_settings && i.key_pressed(Key::Backspace) {
|
// Toggle hotkeys view
|
||||||
self.app_state.show_settings = false;
|
if !search_focused && self.key_pressed(ctx, Key::H) {
|
||||||
|
self.app_state.show_hotkeys = !self.app_state.show_hotkeys;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !self.app_state.show_settings && !self.app_state.show_hotkeys {
|
||||||
|
// Pause / resume audio on space
|
||||||
|
if !search_focused && self.key_pressed(ctx, Key::Space) {
|
||||||
|
self.play_toggle();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop all audio tracks on backspace
|
||||||
|
if !search_focused && self.key_pressed(ctx, Key::Backspace) {
|
||||||
|
self.stop(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus search field
|
||||||
|
if self.key_pressed(ctx, Key::Slash) {
|
||||||
|
if search_focused {
|
||||||
|
ctx.memory_mut(|m| {
|
||||||
|
m.request_focus(Id::NULL);
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
self.app_state.force_focus_search = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+196
-34
@@ -1,27 +1,36 @@
|
|||||||
mod draw;
|
|
||||||
mod input;
|
mod input;
|
||||||
mod update;
|
mod update;
|
||||||
|
mod views;
|
||||||
|
|
||||||
|
use anyhow::{Result, anyhow};
|
||||||
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, FontData, FontDefinitions, FontFamily, FontTweak, 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_sync, start_app_state_thread},
|
gui::{get_gui_config, make_request_async, make_request_sync, start_app_state_thread},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use rfd::FileDialog;
|
use rfd::FileDialog;
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::{
|
use std::{
|
||||||
error::Error,
|
cmp::Ordering,
|
||||||
|
fs,
|
||||||
|
path::{Path, PathBuf},
|
||||||
sync::{Arc, Mutex},
|
sync::{Arc, Mutex},
|
||||||
};
|
};
|
||||||
|
use system_fonts::{FontStyle, FoundFontSource, find_for_locale};
|
||||||
|
|
||||||
|
const SUPPORTED_EXTENSIONS: [&str; 13] = [
|
||||||
|
"mp3", "wav", "ogg", "flac", "mp4", "m4a", "aac", "mov", "mkv", "mka", "webm", "avi", "opus",
|
||||||
|
];
|
||||||
|
|
||||||
struct SoundpadGui {
|
struct SoundpadGui {
|
||||||
pub app_state: AppState,
|
pub app_state: AppState,
|
||||||
@@ -47,53 +56,79 @@ 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 mut guard = self.audio_player_state_shared.lock().unwrap();
|
let (new_state, request) = {
|
||||||
guard.state = match guard.state {
|
let guard = self
|
||||||
PlayerState::Playing => {
|
.audio_player_state_shared
|
||||||
make_request_sync(Request::pause()).ok();
|
.lock()
|
||||||
guard.new_state = Some(PlayerState::Paused);
|
.unwrap_or_else(|e| e.into_inner());
|
||||||
PlayerState::Paused
|
match guard.state {
|
||||||
|
PlayerState::Playing => (Some(PlayerState::Paused), Some(Request::pause(None))),
|
||||||
|
PlayerState::Paused => (Some(PlayerState::Playing), Some(Request::resume(None))),
|
||||||
|
PlayerState::Stopped => (None, None),
|
||||||
}
|
}
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_dir(&mut self) {
|
if let Some(state) = new_state {
|
||||||
|
let mut guard = self
|
||||||
|
.audio_player_state_shared
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner());
|
||||||
|
guard.new_state = Some(state.clone());
|
||||||
|
guard.state = state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open_file(&mut self) {
|
||||||
|
let file_dialog = FileDialog::new().add_filter("Audio File", &SUPPORTED_EXTENSIONS);
|
||||||
|
if let Some(path) = file_dialog.pick_file() {
|
||||||
|
self.play_file(&path, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_dirs(&mut self) {
|
||||||
let file_dialog = FileDialog::new();
|
let file_dialog = FileDialog::new();
|
||||||
if let Some(path) = file_dialog.pick_folder() {
|
if let Some(paths) = file_dialog.pick_folders() {
|
||||||
self.app_state.dirs.insert(path);
|
for path in paths {
|
||||||
|
self.app_state.dirs.push(path);
|
||||||
|
}
|
||||||
|
self.app_state.dirs = self.app_state.dirs.iter().unique().cloned().collect();
|
||||||
self.config.dirs = self.app_state.dirs.clone();
|
self.config.dirs = self.app_state.dirs.clone();
|
||||||
self.config.save_to_file().ok();
|
self.config.save_to_file().ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn remove_dir(&mut self, path: PathBuf) {
|
pub fn open_dir(&mut self, path: &PathBuf) {
|
||||||
self.app_state.dirs.remove(&path);
|
self.app_state.current_dir = Some(path.clone());
|
||||||
if let Some(current_dir) = &self.app_state.current_dir
|
match path.read_dir() {
|
||||||
&& current_dir == &path
|
Ok(read_dir) => {
|
||||||
{
|
self.app_state.listed_files = read_dir
|
||||||
self.app_state.current_dir = None;
|
.filter_map(|res| res.ok())
|
||||||
|
.map(|entry| entry.path())
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to read directory {:?}: {}", path, e);
|
||||||
|
self.app_state.listed_files.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.config.dirs = self.app_state.dirs.clone();
|
|
||||||
self.config.save_to_file().ok();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn play_file(&mut self, path: PathBuf) {
|
pub fn play_file(&mut self, path: &Path, concurrent: bool) {
|
||||||
make_request_sync(Request::play(path.to_str().unwrap())).ok();
|
make_request_async(Request::play(&path.to_string_lossy(), concurrent));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_input(&mut self, name: String) {
|
pub fn set_input(&mut self, name: String) {
|
||||||
make_request_sync(Request::set_input(&name)).ok();
|
make_request_async(Request::set_input(&name));
|
||||||
|
|
||||||
if self.config.save_input {
|
if self.config.save_input {
|
||||||
let mut daemon_config = get_daemon_config();
|
let mut daemon_config = get_daemon_config();
|
||||||
@@ -101,9 +136,125 @@ 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 async fn run() -> Result<(), Box<dyn Error>> {
|
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.listed_files.iter().cloned().collect();
|
||||||
|
files.sort_by(|a, b| {
|
||||||
|
let a_is_dir = a.is_dir();
|
||||||
|
let b_is_dir = b.is_dir();
|
||||||
|
if a_is_dir && !b_is_dir {
|
||||||
|
Ordering::Less
|
||||||
|
} else if !a_is_dir && b_is_dir {
|
||||||
|
Ordering::Greater
|
||||||
|
} else {
|
||||||
|
a.cmp(b)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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 true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_font(font_name: &str, font_bytes: &[u8], fonts: &mut FontDefinitions) -> Result<()> {
|
||||||
|
let font_data = FontData::from_owned(font_bytes.to_vec()).tweak(FontTweak {
|
||||||
|
scale: 1.0,
|
||||||
|
hinting_override: Some(true),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
fonts
|
||||||
|
.font_data
|
||||||
|
.insert(font_name.to_owned(), font_data.into());
|
||||||
|
|
||||||
|
fonts
|
||||||
|
.families
|
||||||
|
.entry(FontFamily::Proportional)
|
||||||
|
.or_default()
|
||||||
|
.insert(0, font_name.to_owned());
|
||||||
|
fonts
|
||||||
|
.families
|
||||||
|
.entry(FontFamily::Monospace)
|
||||||
|
.or_default()
|
||||||
|
.insert(0, font_name.to_owned());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_system_fonts(fonts: &mut FontDefinitions) -> Result<()> {
|
||||||
|
let (_, en_sans) = find_for_locale("en", FontStyle::Sans);
|
||||||
|
let (_, en_serif) = find_for_locale("en", FontStyle::Serif);
|
||||||
|
let (_, ja_sans) = find_for_locale("ja", FontStyle::Sans);
|
||||||
|
let (_, ar_sans) = find_for_locale("ar", FontStyle::Sans);
|
||||||
|
|
||||||
|
let system_fonts = [en_sans, en_serif, ja_sans, ar_sans].concat();
|
||||||
|
|
||||||
|
for font in system_fonts.iter().rev() {
|
||||||
|
let font_bytes = match &font.source {
|
||||||
|
FoundFontSource::Path(path) => fs::read(path)?,
|
||||||
|
FoundFontSource::Bytes(bytes) => bytes.to_vec(),
|
||||||
|
};
|
||||||
|
|
||||||
|
add_font(&font.key, &font_bytes, fonts)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run() -> Result<()> {
|
||||||
const ICON: &[u8] = include_bytes!("../../assets/icon.png");
|
const ICON: &[u8] = include_bytes!("../../assets/icon.png");
|
||||||
|
|
||||||
let options = NativeOptions {
|
let options = NativeOptions {
|
||||||
@@ -125,10 +276,21 @@ pub async fn run() -> Result<(), Box<dyn Error>> {
|
|||||||
options,
|
options,
|
||||||
Box::new(|cc| {
|
Box::new(|cc| {
|
||||||
egui_material_icons::initialize(&cc.egui_ctx);
|
egui_material_icons::initialize(&cc.egui_ctx);
|
||||||
|
|
||||||
|
let mut fonts = FontDefinitions::default();
|
||||||
|
load_system_fonts(&mut fonts).ok();
|
||||||
|
cc.egui_ctx.set_fonts(fonts);
|
||||||
|
|
||||||
Ok(Box::new(SoundpadGui::new(&cc.egui_ctx)))
|
Ok(Box::new(SoundpadGui::new(&cc.egui_ctx)))
|
||||||
}),
|
}),
|
||||||
) {
|
) {
|
||||||
Ok(_) => Ok(()),
|
Ok(_) => {
|
||||||
Err(e) => Err(e.into()),
|
let config = get_gui_config();
|
||||||
|
if config.pause_on_exit {
|
||||||
|
make_request_sync(Request::pause(None)).ok();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => Err(anyhow!(e.to_string())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+97
-40
@@ -3,19 +3,91 @@ 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::{
|
utils::{daemon::get_daemon_config, gui::make_request_async},
|
||||||
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 update(&mut self, ctx: &Context, _frame: &mut EFrame) {
|
fn logic(&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
|
||||||
{
|
{
|
||||||
let guard = self.audio_player_state_shared.lock().unwrap();
|
self.app_state.current_dir = None;
|
||||||
|
self.app_state.listed_files.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save directories if changed
|
||||||
|
if !self.config.dirs.eq(&self.app_state.dirs) {
|
||||||
|
self.config.dirs = self.app_state.dirs.clone();
|
||||||
|
self.config.save_to_file().ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
@@ -26,52 +98,37 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
CentralPanel::default().show(ctx, |ui| {
|
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut EFrame) {
|
||||||
if !is_daemon_running().unwrap() {
|
// Draw UI
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.draw(ui).ok();
|
if self.app_state.show_hotkeys {
|
||||||
|
self.draw_hotkeys(ui);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.draw(ui);
|
||||||
});
|
});
|
||||||
|
|
||||||
if self.app_state.position_dragged {
|
// Request repaint
|
||||||
make_request_sync(Request::seek(self.app_state.position_slider_value)).ok();
|
ui.request_repaint_after_secs(1.0 / 60.0);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,432 @@
|
|||||||
|
use crate::gui::SoundpadGui;
|
||||||
|
use egui::{
|
||||||
|
Align, AtomExt, Button, CollapsingHeader, Color32, CursorIcon, Layout, RichText, ScrollArea,
|
||||||
|
Sense, TextEdit, Ui, Vec2,
|
||||||
|
};
|
||||||
|
use egui_dnd::dnd;
|
||||||
|
use egui_material_icons::icons::*;
|
||||||
|
use pwsp::types::{gui::AppState, gui::AudioPlayerState};
|
||||||
|
use rust_i18n::t;
|
||||||
|
use std::{cmp::Ordering, path::Path, path::PathBuf};
|
||||||
|
|
||||||
|
pub(crate) enum FileAction {
|
||||||
|
Play(PathBuf, bool),
|
||||||
|
StopAndPlay(u32, PathBuf, bool),
|
||||||
|
AssignHotkey(PathBuf),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SoundpadGui {
|
||||||
|
pub fn draw_body(&mut self, ui: &mut Ui) {
|
||||||
|
let left_panel_width = self
|
||||||
|
.config
|
||||||
|
.left_panel_width
|
||||||
|
.max(100.0)
|
||||||
|
.min(ui.available_width() - 100.0);
|
||||||
|
let dirs_size = Vec2::new(left_panel_width, ui.available_height() - 40.0);
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
self.draw_dirs(ui, dirs_size);
|
||||||
|
|
||||||
|
let (rect, response) = ui.allocate_at_least(
|
||||||
|
Vec2::new(ui.spacing().item_spacing.x, ui.available_height()),
|
||||||
|
Sense::click_and_drag(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ui.is_rect_visible(rect) {
|
||||||
|
let stroke = ui.visuals().widgets.noninteractive.bg_stroke;
|
||||||
|
ui.painter().vline(rect.center().x, rect.y_range(), stroke);
|
||||||
|
}
|
||||||
|
|
||||||
|
let vertical_separator_response =
|
||||||
|
response.on_hover_and_drag_cursor(CursorIcon::ResizeHorizontal);
|
||||||
|
|
||||||
|
if vertical_separator_response.dragged() {
|
||||||
|
self.config.left_panel_width += vertical_separator_response.drag_delta().x;
|
||||||
|
self.config.left_panel_width = self.config.left_panel_width.clamp(100.0, 500.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if vertical_separator_response.drag_stopped() {
|
||||||
|
self.config.save_to_file().ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
let files_size = Vec2::new(ui.available_width(), ui.available_height() - 40.0);
|
||||||
|
self.draw_files(ui, files_size);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_dirs(&mut self, ui: &mut Ui, area_size: Vec2) {
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
ui.set_min_width(area_size.x);
|
||||||
|
ui.set_min_height(area_size.y);
|
||||||
|
|
||||||
|
ScrollArea::vertical().id_salt(0).show(ui, |ui| {
|
||||||
|
ui.set_min_width(area_size.x);
|
||||||
|
|
||||||
|
let mut dirs = std::mem::take(&mut self.app_state.dirs);
|
||||||
|
let mut dir_to_open = None;
|
||||||
|
|
||||||
|
dnd(ui, "dnd_directories").show_vec(&mut dirs, |ui, item, handle, _state| {
|
||||||
|
let path = item;
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
handle.ui(ui, |ui| {
|
||||||
|
ui.label(ICON_DRAG_INDICATOR.codepoint);
|
||||||
|
});
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.map(|s| s.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| path.to_string_lossy().to_string());
|
||||||
|
|
||||||
|
let mut dir_button_text = RichText::new(name.clone());
|
||||||
|
if let Some(current_dir) = &self.app_state.current_dir
|
||||||
|
&& current_dir.eq(&*path)
|
||||||
|
{
|
||||||
|
dir_button_text = dir_button_text.color(Color32::WHITE);
|
||||||
|
}
|
||||||
|
|
||||||
|
let dir_button =
|
||||||
|
Button::new(dir_button_text.atom_max_width(area_size.x)).frame(false);
|
||||||
|
|
||||||
|
let dir_button_response = ui.add(dir_button);
|
||||||
|
if dir_button_response.clicked() {
|
||||||
|
dir_to_open = Some(path.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let delete_dir_button = Button::new(ICON_DELETE).frame(false);
|
||||||
|
let delete_dir_button_response =
|
||||||
|
ui.add_sized([18.0, 18.0], delete_dir_button);
|
||||||
|
if delete_dir_button_response.clicked() {
|
||||||
|
self.app_state.dirs_to_remove.insert(path.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context menu
|
||||||
|
dir_button_response.context_menu(|ui| {
|
||||||
|
if ui
|
||||||
|
.button(format!(
|
||||||
|
"{} {}",
|
||||||
|
ICON_OPEN_IN_NEW.codepoint,
|
||||||
|
t!("gui.context.dirs.open")
|
||||||
|
))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
dir_to_open = Some(path.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ui
|
||||||
|
.button(format!(
|
||||||
|
"{} {}",
|
||||||
|
ICON_OPEN_IN_BROWSER.codepoint,
|
||||||
|
t!("gui.context.dirs.open_in_fm")
|
||||||
|
))
|
||||||
|
.clicked()
|
||||||
|
&& let Err(e) = opener::open(&path)
|
||||||
|
{
|
||||||
|
eprintln!("Failed to open file manager: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
if ui
|
||||||
|
.button(format!(
|
||||||
|
"{} {}",
|
||||||
|
ICON_DELETE.codepoint,
|
||||||
|
t!("gui.context.dirs.remove")
|
||||||
|
))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
self.app_state.dirs_to_remove.insert(path.clone());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
self.app_state.dirs = dirs;
|
||||||
|
|
||||||
|
if let Some(path) = dir_to_open {
|
||||||
|
self.open_dir(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let add_dirs_button = Button::new(ICON_ADD).frame(false);
|
||||||
|
let add_dirs_button_response = ui.add_sized([18.0, 18.0], add_dirs_button);
|
||||||
|
if add_dirs_button_response.clicked() {
|
||||||
|
self.add_dirs();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
|
||||||
|
let play_file_button = Button::new(t!("gui.play_file_button"));
|
||||||
|
let play_file_button_response = ui.add(play_file_button);
|
||||||
|
if play_file_button_response.clicked() {
|
||||||
|
self.open_file();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_files_search_field(&mut self, ui: &mut Ui) {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let search_field_response = ui.add_sized(
|
||||||
|
[ui.available_width(), 22.0],
|
||||||
|
TextEdit::singleline(&mut self.app_state.search_query)
|
||||||
|
.hint_text(t!("gui.search_placeholder")),
|
||||||
|
);
|
||||||
|
|
||||||
|
if self.app_state.force_focus_search {
|
||||||
|
search_field_response.request_focus();
|
||||||
|
self.app_state.force_focus_search = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.app_state.search_field_id = Some(search_field_response.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_files_list(&mut self, ui: &mut Ui, area_size: Vec2) {
|
||||||
|
ScrollArea::vertical().id_salt(1).show(ui, |ui| {
|
||||||
|
ui.set_min_width(area_size.x);
|
||||||
|
ui.set_min_height(area_size.y);
|
||||||
|
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
let mut actions = Vec::new();
|
||||||
|
let files = self.get_filtered_files();
|
||||||
|
for entry_path in files {
|
||||||
|
Self::draw_tree_node(
|
||||||
|
ui,
|
||||||
|
entry_path,
|
||||||
|
&mut self.app_state,
|
||||||
|
&self.audio_player_state,
|
||||||
|
&mut actions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for action in actions {
|
||||||
|
match action {
|
||||||
|
FileAction::Play(path, concurrent) => self.play_file(&path, concurrent),
|
||||||
|
FileAction::StopAndPlay(id, path, concurrent) => {
|
||||||
|
self.stop(Some(id));
|
||||||
|
self.play_file(&path, concurrent);
|
||||||
|
}
|
||||||
|
FileAction::AssignHotkey(path) => {
|
||||||
|
self.app_state.assigning_hotkey_for_file = Some(path);
|
||||||
|
self.app_state.hotkey_capture_active = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_files(&mut self, ui: &mut Ui, area_size: Vec2) {
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
self.draw_files_search_field(ui);
|
||||||
|
ui.separator();
|
||||||
|
self.draw_files_list(ui, area_size);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_tree_node_dir(
|
||||||
|
ui: &mut Ui,
|
||||||
|
path: std::path::PathBuf,
|
||||||
|
app_state: &mut AppState,
|
||||||
|
audio_player_state: &AudioPlayerState,
|
||||||
|
actions: &mut Vec<FileAction>,
|
||||||
|
) {
|
||||||
|
let dir_name = path
|
||||||
|
.file_name()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
CollapsingHeader::new(dir_name)
|
||||||
|
.id_salt(&path)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
let children = if let Some(cached) = app_state.dir_cache.get(&path) {
|
||||||
|
cached.clone()
|
||||||
|
} else {
|
||||||
|
let mut read = Vec::new();
|
||||||
|
if let Ok(entries) = std::fs::read_dir(&path) {
|
||||||
|
for entry in entries.filter_map(|e| e.ok()) {
|
||||||
|
read.push(entry.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
read.sort_by(|a, b| {
|
||||||
|
let a_is_dir = a.is_dir();
|
||||||
|
let b_is_dir = b.is_dir();
|
||||||
|
if a_is_dir && !b_is_dir {
|
||||||
|
Ordering::Less
|
||||||
|
} else if !a_is_dir && b_is_dir {
|
||||||
|
Ordering::Greater
|
||||||
|
} else {
|
||||||
|
a.cmp(b)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app_state.dir_cache.insert(path.clone(), read.clone());
|
||||||
|
read
|
||||||
|
};
|
||||||
|
|
||||||
|
let search_query = app_state.search_query.to_lowercase();
|
||||||
|
let search_query = search_query.trim();
|
||||||
|
|
||||||
|
for child in children {
|
||||||
|
if !child.is_dir() {
|
||||||
|
if !crate::gui::SUPPORTED_EXTENSIONS.contains(
|
||||||
|
&child
|
||||||
|
.extension()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_str()
|
||||||
|
.unwrap_or_default(),
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !search_query.is_empty() {
|
||||||
|
let file_name = child
|
||||||
|
.file_name()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
if !file_name.to_lowercase().contains(search_query) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self::draw_tree_node(ui, child, app_state, audio_player_state, actions);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_tree_node_file(
|
||||||
|
ui: &mut Ui,
|
||||||
|
path: std::path::PathBuf,
|
||||||
|
app_state: &mut AppState,
|
||||||
|
audio_player_state: &AudioPlayerState,
|
||||||
|
actions: &mut Vec<FileAction>,
|
||||||
|
) {
|
||||||
|
let file_name = path
|
||||||
|
.file_name()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
// Hotkey badge
|
||||||
|
let mut hotkey_badge = None;
|
||||||
|
for slot in &app_state.hotkey_config.slots {
|
||||||
|
if slot.action.name == "play"
|
||||||
|
&& let Some(file_path_str) = slot.action.args.get("file_path")
|
||||||
|
&& Path::new(file_path_str) == path
|
||||||
|
{
|
||||||
|
if let Some(chord) = &slot.key_chord {
|
||||||
|
hotkey_badge = Some(format!("[{}]", chord));
|
||||||
|
} else {
|
||||||
|
hotkey_badge = Some(format!("[{}]", slot.slot));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(badge) = &hotkey_badge {
|
||||||
|
ui.label(
|
||||||
|
RichText::new(badge)
|
||||||
|
.small()
|
||||||
|
.monospace()
|
||||||
|
.color(Color32::from_rgb(100, 200, 100)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_button_text = RichText::new(&file_name);
|
||||||
|
|
||||||
|
let file_button = Button::new(file_button_text).frame(false).truncate();
|
||||||
|
let file_button_response = ui.add(file_button);
|
||||||
|
if file_button_response.clicked() {
|
||||||
|
ui.input(|i| {
|
||||||
|
if i.modifiers.ctrl {
|
||||||
|
actions.push(FileAction::Play(path.clone(), true));
|
||||||
|
} else if i.modifiers.shift
|
||||||
|
&& let Some(last_track) = audio_player_state.tracks.last()
|
||||||
|
{
|
||||||
|
actions.push(FileAction::StopAndPlay(last_track.id, path.clone(), true));
|
||||||
|
} else {
|
||||||
|
actions.push(FileAction::Play(path.clone(), false));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context menu
|
||||||
|
file_button_response.context_menu(|ui| {
|
||||||
|
if ui
|
||||||
|
.button(format!(
|
||||||
|
"{} {}",
|
||||||
|
ICON_BOLT.codepoint,
|
||||||
|
t!("gui.context.files.play_solo")
|
||||||
|
))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(FileAction::Play(path.clone(), false));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ui
|
||||||
|
.button(format!(
|
||||||
|
"{} {}",
|
||||||
|
ICON_ADD.codepoint,
|
||||||
|
t!("gui.context.files.add_new")
|
||||||
|
))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(FileAction::Play(path.clone(), true));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ui
|
||||||
|
.button(format!(
|
||||||
|
"{} {}",
|
||||||
|
ICON_SWAP_HORIZ.codepoint,
|
||||||
|
t!("gui.context.files.replace_last")
|
||||||
|
))
|
||||||
|
.clicked()
|
||||||
|
&& let Some(last_track) = audio_player_state.tracks.last()
|
||||||
|
{
|
||||||
|
actions.push(FileAction::StopAndPlay(last_track.id, path.clone(), true));
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
if ui
|
||||||
|
.button(format!(
|
||||||
|
"{} {}",
|
||||||
|
ICON_OPEN_IN_BROWSER.codepoint,
|
||||||
|
t!("gui.context.files.show_in_fm")
|
||||||
|
))
|
||||||
|
.clicked()
|
||||||
|
&& let Err(e) = opener::reveal(&path)
|
||||||
|
{
|
||||||
|
eprintln!("Failed to open file manager: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
if ui
|
||||||
|
.button(format!(
|
||||||
|
"{} {}",
|
||||||
|
ICON_KEYBOARD.codepoint,
|
||||||
|
t!("gui.context.files.asign_hotkey")
|
||||||
|
))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(FileAction::AssignHotkey(path.clone()));
|
||||||
|
ui.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_tree_node(
|
||||||
|
ui: &mut Ui,
|
||||||
|
path: std::path::PathBuf,
|
||||||
|
app_state: &mut AppState,
|
||||||
|
audio_player_state: &AudioPlayerState,
|
||||||
|
actions: &mut Vec<FileAction>,
|
||||||
|
) {
|
||||||
|
if path.is_dir() {
|
||||||
|
Self::draw_tree_node_dir(ui, path, app_state, audio_player_state, actions);
|
||||||
|
} else {
|
||||||
|
Self::draw_tree_node_file(ui, path, app_state, audio_player_state, actions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
use crate::gui::SoundpadGui;
|
||||||
|
use egui::{AtomExt, Button, ComboBox, Label, RichText, Slider, Ui, Vec2};
|
||||||
|
use egui_material_icons::icons::*;
|
||||||
|
use rust_i18n::t;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
impl SoundpadGui {
|
||||||
|
pub fn draw_footer(&mut self, ui: &mut Ui) {
|
||||||
|
ui.add_space(5.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
// ---------- Microphone selection ----------
|
||||||
|
let mics = &self.audio_player_state.all_inputs_sorted;
|
||||||
|
|
||||||
|
let mut selected_input = self.audio_player_state.current_input.to_owned();
|
||||||
|
let prev_input = selected_input.to_owned();
|
||||||
|
ComboBox::from_label(t!("gui.choose_mic_select"))
|
||||||
|
.height(30.0)
|
||||||
|
.selected_text(
|
||||||
|
self.audio_player_state
|
||||||
|
.all_inputs
|
||||||
|
.get(&selected_input)
|
||||||
|
.unwrap_or(&String::new()),
|
||||||
|
)
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
for (name, nick) in mics {
|
||||||
|
ui.selectable_value(&mut selected_input, name.clone(), nick);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if selected_input != prev_input {
|
||||||
|
self.set_input(selected_input);
|
||||||
|
}
|
||||||
|
// --------------------------------
|
||||||
|
|
||||||
|
// ---------- Master Volume Slider ----------
|
||||||
|
let volume_icon = Self::get_volume_icon(self.audio_player_state.volume);
|
||||||
|
let volume_label = Label::new(RichText::new(volume_icon).size(18.0));
|
||||||
|
ui.add_sized([18.0, 18.0], volume_label)
|
||||||
|
.on_hover_text(format!(
|
||||||
|
"Master Volume: {:.0}%",
|
||||||
|
self.audio_player_state.volume * 100.0
|
||||||
|
));
|
||||||
|
|
||||||
|
let should_update_volume = !self.app_state.volume_dragged
|
||||||
|
&& self
|
||||||
|
.app_state
|
||||||
|
.ignore_volume_update_until
|
||||||
|
.map(|t| Instant::now() > t)
|
||||||
|
.unwrap_or(true);
|
||||||
|
|
||||||
|
if should_update_volume {
|
||||||
|
self.app_state.volume_slider_value = self.audio_player_state.volume;
|
||||||
|
}
|
||||||
|
|
||||||
|
let volume_slider = Slider::new(&mut self.app_state.volume_slider_value, 0.0..=1.0)
|
||||||
|
.show_value(false)
|
||||||
|
.step_by(0.01);
|
||||||
|
let volume_slider_response = ui.add_sized([150.0, 18.0], volume_slider);
|
||||||
|
if volume_slider_response.drag_stopped() {
|
||||||
|
self.app_state.volume_dragged = true;
|
||||||
|
}
|
||||||
|
// ------------------------------------------
|
||||||
|
|
||||||
|
ui.add_space(ui.available_width() - 18.0 * 2.0 - ui.spacing().item_spacing.x * 2.0);
|
||||||
|
|
||||||
|
// ---------- Hotkeys button ----------
|
||||||
|
let hotkeys_button =
|
||||||
|
Button::new(ICON_KEYBOARD.atom_size(Vec2::new(18.0, 18.0))).frame(false);
|
||||||
|
let hotkeys_button_response = ui.add_sized([18.0, 18.0], hotkeys_button);
|
||||||
|
if hotkeys_button_response.clicked() {
|
||||||
|
self.app_state.show_hotkeys = true;
|
||||||
|
}
|
||||||
|
hotkeys_button_response.on_hover_text("Hotkeys (H)");
|
||||||
|
// --------------------------------
|
||||||
|
|
||||||
|
// ---------- Settings button ----------
|
||||||
|
let settings_button =
|
||||||
|
Button::new(ICON_SETTINGS.atom_size(Vec2::new(18.0, 18.0))).frame(false);
|
||||||
|
let settings_button_response = ui.add_sized([18.0, 18.0], settings_button);
|
||||||
|
if settings_button_response.clicked() {
|
||||||
|
self.app_state.show_settings = true;
|
||||||
|
}
|
||||||
|
// --------------------------------
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
use crate::gui::SoundpadGui;
|
||||||
|
use egui::{Button, CollapsingHeader, Color32, FontFamily, Label, RichText, Slider, Ui};
|
||||||
|
use egui_material_icons::icons::*;
|
||||||
|
use pwsp::types::{audio_player::TrackInfo, gui::AppState};
|
||||||
|
use pwsp::utils::gui::format_time_pair;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
pub(crate) enum TrackAction {
|
||||||
|
Pause(u32),
|
||||||
|
Resume(u32),
|
||||||
|
ToggleLoop(u32),
|
||||||
|
Stop(u32),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SoundpadGui {
|
||||||
|
pub fn draw_header(&mut self, ui: &mut Ui) {
|
||||||
|
ui.vertical_centered_justified(|ui| {
|
||||||
|
if self.audio_player_state.tracks.is_empty() {
|
||||||
|
ui.label("No tracks playing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut action = None;
|
||||||
|
|
||||||
|
for track in &self.audio_player_state.tracks {
|
||||||
|
CollapsingHeader::new(
|
||||||
|
RichText::new(
|
||||||
|
track
|
||||||
|
.path
|
||||||
|
.file_stem()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_str()
|
||||||
|
.unwrap_or_default(),
|
||||||
|
)
|
||||||
|
.color(Color32::WHITE)
|
||||||
|
.family(FontFamily::Monospace),
|
||||||
|
)
|
||||||
|
.default_open(true)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
if let Some(act) = Self::draw_track_control(ui, &mut self.app_state, track) {
|
||||||
|
action = Some(act);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.separator();
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(action) = action {
|
||||||
|
match action {
|
||||||
|
TrackAction::Pause(id) => self.pause(Some(id)),
|
||||||
|
TrackAction::Resume(id) => self.resume(Some(id)),
|
||||||
|
TrackAction::ToggleLoop(id) => self.toggle_loop(Some(id)),
|
||||||
|
TrackAction::Stop(id) => self.stop(Some(id)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_playback_controls(ui: &mut Ui, track: &TrackInfo) -> Option<TrackAction> {
|
||||||
|
let mut action = None;
|
||||||
|
|
||||||
|
let play_button = Button::new(if track.paused {
|
||||||
|
ICON_PLAY_ARROW
|
||||||
|
} else {
|
||||||
|
ICON_PAUSE
|
||||||
|
})
|
||||||
|
.corner_radius(15.0);
|
||||||
|
|
||||||
|
if ui.add_sized([30.0, 30.0], play_button).clicked() {
|
||||||
|
action = Some(if track.paused {
|
||||||
|
TrackAction::Resume(track.id)
|
||||||
|
} else {
|
||||||
|
TrackAction::Pause(track.id)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let loop_button = Button::new(
|
||||||
|
RichText::new(if track.looped {
|
||||||
|
ICON_REPEAT_ONE
|
||||||
|
} else {
|
||||||
|
ICON_REPEAT
|
||||||
|
})
|
||||||
|
.size(18.0),
|
||||||
|
)
|
||||||
|
.frame(false);
|
||||||
|
|
||||||
|
if ui.add_sized([15.0, 30.0], loop_button).clicked() {
|
||||||
|
action = Some(TrackAction::ToggleLoop(track.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
action
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_position_control(
|
||||||
|
ui: &mut Ui,
|
||||||
|
ui_state: &mut pwsp::types::gui::TrackUiState,
|
||||||
|
track: &TrackInfo,
|
||||||
|
default_slider_width: f32,
|
||||||
|
) {
|
||||||
|
let duration = track.duration.unwrap_or(1.0);
|
||||||
|
let position_slider = Slider::new(&mut ui_state.position_slider_value, 0.0..=duration)
|
||||||
|
.show_value(false)
|
||||||
|
.step_by(0.01);
|
||||||
|
|
||||||
|
let position_slider_width = ui.available_width()
|
||||||
|
- (30.0 * 3.0)
|
||||||
|
- default_slider_width
|
||||||
|
- (ui.spacing().item_spacing.x * 6.0);
|
||||||
|
|
||||||
|
ui.spacing_mut().slider_width = position_slider_width;
|
||||||
|
if ui.add_sized([30.0, 30.0], position_slider).drag_stopped() {
|
||||||
|
ui_state.position_dragged = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let time_label =
|
||||||
|
Label::new(RichText::new(format_time_pair(track.position, duration)).monospace());
|
||||||
|
ui.add_sized([30.0, 30.0], time_label);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_volume_control(
|
||||||
|
ui: &mut Ui,
|
||||||
|
ui_state: &mut pwsp::types::gui::TrackUiState,
|
||||||
|
track: &TrackInfo,
|
||||||
|
default_slider_width: f32,
|
||||||
|
) {
|
||||||
|
let volume_icon = Self::get_volume_icon(track.volume);
|
||||||
|
let volume_label = Label::new(RichText::new(volume_icon).size(18.0));
|
||||||
|
ui.add_sized([30.0, 30.0], volume_label)
|
||||||
|
.on_hover_text(format!("Volume: {:.0}%", track.volume * 100.0));
|
||||||
|
|
||||||
|
let volume_slider = Slider::new(&mut ui_state.volume_slider_value, 0.0..=1.0)
|
||||||
|
.show_value(false)
|
||||||
|
.step_by(0.01);
|
||||||
|
|
||||||
|
ui.spacing_mut().slider_width = default_slider_width - 30.0;
|
||||||
|
ui.spacing_mut().item_spacing.x = 0.0;
|
||||||
|
|
||||||
|
if ui.add_sized([30.0, 30.0], volume_slider).drag_stopped() {
|
||||||
|
ui_state.volume_dragged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_stop_control(ui: &mut Ui, track: &TrackInfo) -> Option<TrackAction> {
|
||||||
|
let stop_button = Button::new(ICON_CLOSE).frame(false);
|
||||||
|
if ui.add_sized([30.0, 30.0], stop_button).clicked() {
|
||||||
|
Some(TrackAction::Stop(track.id))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_track_control(
|
||||||
|
ui: &mut Ui,
|
||||||
|
app_state: &mut AppState,
|
||||||
|
track: &TrackInfo,
|
||||||
|
) -> Option<TrackAction> {
|
||||||
|
let ui_state = app_state.track_ui_states.entry(track.id).or_default();
|
||||||
|
|
||||||
|
let should_update_position = !ui_state.position_dragged
|
||||||
|
&& ui_state
|
||||||
|
.ignore_position_update_until
|
||||||
|
.map(|t| Instant::now() > t)
|
||||||
|
.unwrap_or(true);
|
||||||
|
|
||||||
|
if should_update_position {
|
||||||
|
ui_state.position_slider_value = track.position;
|
||||||
|
}
|
||||||
|
|
||||||
|
let should_update_volume = !ui_state.volume_dragged
|
||||||
|
&& ui_state
|
||||||
|
.ignore_volume_update_until
|
||||||
|
.map(|t| Instant::now() > t)
|
||||||
|
.unwrap_or(true);
|
||||||
|
|
||||||
|
if should_update_volume {
|
||||||
|
ui_state.volume_slider_value = track.volume;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut action = None;
|
||||||
|
|
||||||
|
ui.horizontal_top(|ui| {
|
||||||
|
if let Some(act) = Self::draw_playback_controls(ui, track) {
|
||||||
|
action = Some(act);
|
||||||
|
}
|
||||||
|
|
||||||
|
let default_slider_width = ui.spacing().slider_width;
|
||||||
|
Self::draw_position_control(ui, ui_state, track, default_slider_width);
|
||||||
|
Self::draw_volume_control(ui, ui_state, track, default_slider_width);
|
||||||
|
|
||||||
|
if let Some(act) = Self::draw_stop_control(ui, track) {
|
||||||
|
action = Some(act);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
action
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
use crate::gui::SoundpadGui;
|
||||||
|
use egui::{Color32, RichText, Ui};
|
||||||
|
use rust_i18n::t;
|
||||||
|
|
||||||
|
impl SoundpadGui {
|
||||||
|
pub fn draw_hotkey_capture(&mut self, ui: &mut Ui) {
|
||||||
|
ui.vertical_centered(|ui| {
|
||||||
|
ui.add_space(ui.available_height() / 3.0);
|
||||||
|
ui.label(
|
||||||
|
RichText::new(t!("gui.hotkeys.capture.header"))
|
||||||
|
.size(18.0)
|
||||||
|
.color(Color32::YELLOW)
|
||||||
|
.monospace(),
|
||||||
|
);
|
||||||
|
ui.add_space(10.0);
|
||||||
|
let target = if let Some(slot) = &self.app_state.assigning_hotkey_slot {
|
||||||
|
format!("{} '{}'", t!("gui.hotkeys.capture.for"), slot)
|
||||||
|
} else if let Some(path) = &self.app_state.assigning_hotkey_for_file {
|
||||||
|
format!(
|
||||||
|
"{} '{}'",
|
||||||
|
t!("gui.hotkeys.capture.for"),
|
||||||
|
path.file_name().unwrap_or_default().to_string_lossy()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
ui.label(RichText::new(target).size(16.0));
|
||||||
|
ui.add_space(10.0);
|
||||||
|
ui.label(t!("gui.hotkeys.capture.cancel"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
use crate::gui::SoundpadGui;
|
||||||
|
use egui::{Button, Color32, Label, RichText, TextEdit, Ui};
|
||||||
|
use egui_extras::{Column, TableBuilder};
|
||||||
|
use egui_material_icons::icons::*;
|
||||||
|
use pwsp::types::socket::Request;
|
||||||
|
use pwsp::utils::gui::make_request_async;
|
||||||
|
use rust_i18n::t;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub(crate) enum HotkeyAction {
|
||||||
|
Remove(String),
|
||||||
|
Capture(String),
|
||||||
|
ClearChord(String),
|
||||||
|
Play(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SoundpadGui {
|
||||||
|
pub fn draw_hotkeys(&mut self, ui: &mut Ui) {
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
ui.spacing_mut().item_spacing.y = 5.0;
|
||||||
|
|
||||||
|
self.draw_hotkeys_header(ui);
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
self.draw_hotkeys_search(ui);
|
||||||
|
ui.separator();
|
||||||
|
ui.add_space(5.0);
|
||||||
|
|
||||||
|
let action = self.draw_hotkeys_table(ui);
|
||||||
|
|
||||||
|
if let Some(action) = action {
|
||||||
|
self.handle_hotkey_action(action);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_hotkeys_header(&mut self, ui: &mut Ui) {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let back_button = Button::new(ICON_ARROW_BACK).frame(false);
|
||||||
|
if ui.add(back_button).clicked() {
|
||||||
|
self.app_state.show_hotkeys = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.vertical_centered(|ui| {
|
||||||
|
ui.label(
|
||||||
|
RichText::new(t!("gui.hotkeys.header"))
|
||||||
|
.color(Color32::WHITE)
|
||||||
|
.monospace(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_hotkeys_search(&mut self, ui: &mut Ui) {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.menu_button(
|
||||||
|
format!(
|
||||||
|
"{} {}",
|
||||||
|
ICON_ADD.codepoint,
|
||||||
|
t!("gui.hotkeys.add_command_select")
|
||||||
|
),
|
||||||
|
|ui| {
|
||||||
|
let mut selected_cmd = None;
|
||||||
|
if ui.button(t!("gui.hotkeys.toggle_pause_command")).clicked() {
|
||||||
|
selected_cmd = Some(("cmd_toggle_pause", Request::toggle_pause(None)));
|
||||||
|
}
|
||||||
|
if ui.button(t!("gui.hotkeys.stop_playback_command")).clicked() {
|
||||||
|
selected_cmd = Some(("cmd_stop", Request::stop(None)));
|
||||||
|
}
|
||||||
|
if ui
|
||||||
|
.button(t!("gui.hotkeys.pause_playback_command"))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
selected_cmd = Some(("cmd_pause", Request::pause(None)));
|
||||||
|
}
|
||||||
|
if ui
|
||||||
|
.button(t!("gui.hotkeys.resume_playback_command"))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
selected_cmd = Some(("cmd_resume", Request::resume(None)));
|
||||||
|
}
|
||||||
|
if ui.button(t!("gui.hotkeys.toggle_loop_command")).clicked() {
|
||||||
|
selected_cmd = Some(("cmd_toggle_loop", Request::toggle_loop(None)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((slot_name, req)) = selected_cmd {
|
||||||
|
make_request_async(Request::set_hotkey_action(slot_name, &req));
|
||||||
|
self.app_state
|
||||||
|
.hotkey_config
|
||||||
|
.set_slot(slot_name.to_string(), req);
|
||||||
|
self.app_state.assigning_hotkey_slot = Some(slot_name.to_string());
|
||||||
|
self.app_state.hotkey_capture_active = true;
|
||||||
|
ui.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ui.add_space(10.0);
|
||||||
|
|
||||||
|
ui.add(
|
||||||
|
TextEdit::singleline(&mut self.app_state.hotkey_search_query)
|
||||||
|
.hint_text(t!("gui.hotkeys.search_placeholder"))
|
||||||
|
.desired_width(f32::INFINITY),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_hotkeys_table(&mut self, ui: &mut Ui) -> Option<HotkeyAction> {
|
||||||
|
let conflicts = self.app_state.hotkey_config.find_conflicts();
|
||||||
|
let conflict_slots: std::collections::HashSet<&str> =
|
||||||
|
conflicts.into_iter().flat_map(|(a, b)| [a, b]).collect();
|
||||||
|
|
||||||
|
let search = self.app_state.hotkey_search_query.to_lowercase();
|
||||||
|
let mut action: Option<HotkeyAction> = None;
|
||||||
|
|
||||||
|
let slots: Vec<_> = self
|
||||||
|
.app_state
|
||||||
|
.hotkey_config
|
||||||
|
.slots
|
||||||
|
.iter()
|
||||||
|
.filter(|s| {
|
||||||
|
if search.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
s.slot.to_lowercase().contains(&search)
|
||||||
|
|| format!("{:?}", s.action).to_lowercase().contains(&search)
|
||||||
|
|| s.key_chord
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_lowercase()
|
||||||
|
.contains(&search)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let available_width = ui.available_width();
|
||||||
|
let col_width = (available_width / 4.0).max(80.0);
|
||||||
|
|
||||||
|
TableBuilder::new(ui)
|
||||||
|
.striped(true)
|
||||||
|
.column(Column::exact(col_width).clip(true)) // Slot
|
||||||
|
.column(Column::exact(col_width).clip(true)) // Sound / Action name
|
||||||
|
.column(Column::exact(col_width).clip(true)) // Key Chord
|
||||||
|
.column(Column::exact(col_width).clip(true)) // Actions
|
||||||
|
.header(30.0, |mut header| {
|
||||||
|
header.col(|ui| {
|
||||||
|
ui.label(
|
||||||
|
RichText::new(t!("gui.hotkeys.column_slot"))
|
||||||
|
.strong()
|
||||||
|
.monospace()
|
||||||
|
.color(Color32::LIGHT_GRAY),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
header.col(|ui| {
|
||||||
|
ui.label(
|
||||||
|
RichText::new(t!("gui.hotkeys.column_sound"))
|
||||||
|
.strong()
|
||||||
|
.monospace()
|
||||||
|
.color(Color32::LIGHT_GRAY),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
header.col(|ui| {
|
||||||
|
ui.label(
|
||||||
|
RichText::new(t!("gui.hotkeys.column_key_chord"))
|
||||||
|
.strong()
|
||||||
|
.monospace()
|
||||||
|
.color(Color32::LIGHT_GRAY),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
header.col(|ui| {
|
||||||
|
ui.label(
|
||||||
|
RichText::new(t!("gui.hotkeys.column_actions"))
|
||||||
|
.strong()
|
||||||
|
.monospace()
|
||||||
|
.color(Color32::LIGHT_GRAY),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.body(|mut body| {
|
||||||
|
if slots.is_empty() {
|
||||||
|
body.row(30.0, |mut row| {
|
||||||
|
row.col(|_| {});
|
||||||
|
row.col(|ui| {
|
||||||
|
ui.label(
|
||||||
|
RichText::new(t!("gui.hotkeys.no_hotkeys_configured"))
|
||||||
|
.color(Color32::GRAY),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
row.col(|_| {});
|
||||||
|
row.col(|_| {});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for slot in &slots {
|
||||||
|
body.row(30.0, |mut row| {
|
||||||
|
// Column 1: Slot
|
||||||
|
row.col(|ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if conflict_slots.contains(slot.slot.as_str()) {
|
||||||
|
ui.label(
|
||||||
|
RichText::new(ICON_WARNING.codepoint)
|
||||||
|
.color(Color32::from_rgb(255, 165, 0)),
|
||||||
|
)
|
||||||
|
.on_hover_text("Key chord conflict");
|
||||||
|
}
|
||||||
|
ui.add(
|
||||||
|
Label::new(RichText::new(&slot.slot).monospace()).truncate(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Column 2: Sound / Action name
|
||||||
|
row.col(|ui| {
|
||||||
|
let action_name = match slot.action.name.as_str() {
|
||||||
|
"play" => {
|
||||||
|
if let Some(file_path_str) = slot.action.args.get("file_path") {
|
||||||
|
Path::new(file_path_str)
|
||||||
|
.file_name()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string()
|
||||||
|
} else {
|
||||||
|
"Play".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"toggle_pause" => "Toggle Pause".to_string(),
|
||||||
|
"pause" => "Pause Playback".to_string(),
|
||||||
|
"resume" => "Resume Playback".to_string(),
|
||||||
|
"stop" => "Stop Playback".to_string(),
|
||||||
|
"toggle_loop" => "Toggle Loop".to_string(),
|
||||||
|
other => other.to_string(),
|
||||||
|
};
|
||||||
|
ui.add(Label::new(RichText::new(action_name).monospace()).truncate());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Column 3: Key Chord
|
||||||
|
row.col(|ui| {
|
||||||
|
let chord_text = slot.key_chord.as_deref().unwrap_or("(none)");
|
||||||
|
ui.add(
|
||||||
|
Label::new(RichText::new(chord_text).monospace().color(
|
||||||
|
if slot.key_chord.is_some() {
|
||||||
|
Color32::from_rgb(100, 200, 100)
|
||||||
|
} else {
|
||||||
|
Color32::GRAY
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.truncate(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Column 4: Actions
|
||||||
|
row.col(|ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui
|
||||||
|
.add(Button::new(ICON_DELETE).frame(false))
|
||||||
|
.on_hover_text("Remove slot")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
action = Some(HotkeyAction::Remove(slot.slot.clone()));
|
||||||
|
}
|
||||||
|
if ui
|
||||||
|
.add(Button::new(ICON_KEYBOARD).frame(false))
|
||||||
|
.on_hover_text("Set key chord")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
action = Some(HotkeyAction::Capture(slot.slot.clone()));
|
||||||
|
}
|
||||||
|
if slot.key_chord.is_some()
|
||||||
|
&& ui
|
||||||
|
.add(Button::new(ICON_BACKSPACE).frame(false))
|
||||||
|
.on_hover_text("Clear key chord")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
action = Some(HotkeyAction::ClearChord(slot.slot.clone()));
|
||||||
|
}
|
||||||
|
if ui
|
||||||
|
.add(Button::new(ICON_PLAY_ARROW).frame(false))
|
||||||
|
.on_hover_text("Play")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
action = Some(HotkeyAction::Play(slot.slot.clone()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
action
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_hotkey_action(&mut self, action: HotkeyAction) {
|
||||||
|
match action {
|
||||||
|
HotkeyAction::Remove(slot) => {
|
||||||
|
make_request_async(Request::clear_hotkey(&slot));
|
||||||
|
self.app_state.hotkey_config.remove_slot(&slot);
|
||||||
|
}
|
||||||
|
HotkeyAction::Capture(slot) => {
|
||||||
|
self.app_state.assigning_hotkey_slot = Some(slot);
|
||||||
|
self.app_state.hotkey_capture_active = true;
|
||||||
|
}
|
||||||
|
HotkeyAction::ClearChord(slot) => {
|
||||||
|
make_request_async(Request::clear_hotkey_key(&slot));
|
||||||
|
self.app_state.hotkey_config.set_key_chord(&slot, None);
|
||||||
|
}
|
||||||
|
HotkeyAction::Play(slot) => {
|
||||||
|
self.play_hotkey_slot(&slot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
use crate::gui::SoundpadGui;
|
||||||
|
use egui::Ui;
|
||||||
|
use egui_material_icons::icons::*;
|
||||||
|
|
||||||
|
mod body;
|
||||||
|
mod footer;
|
||||||
|
mod header;
|
||||||
|
mod hotkey_capture;
|
||||||
|
mod hotkeys;
|
||||||
|
mod settings;
|
||||||
|
mod waiting_for_daemon;
|
||||||
|
|
||||||
|
impl SoundpadGui {
|
||||||
|
pub(crate) fn get_volume_icon(volume: f32) -> &'static str {
|
||||||
|
if volume > 0.7 {
|
||||||
|
ICON_VOLUME_UP.codepoint
|
||||||
|
} else if volume <= 0.0 {
|
||||||
|
ICON_VOLUME_OFF.codepoint
|
||||||
|
} else if volume < 0.3 {
|
||||||
|
ICON_VOLUME_MUTE.codepoint
|
||||||
|
} else {
|
||||||
|
ICON_VOLUME_DOWN.codepoint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn draw(&mut self, ui: &mut Ui) {
|
||||||
|
self.draw_header(ui);
|
||||||
|
self.draw_body(ui);
|
||||||
|
ui.separator();
|
||||||
|
self.draw_footer(ui);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
use crate::gui::SoundpadGui;
|
||||||
|
use egui::{Align, Button, Color32, Layout, RichText, Ui};
|
||||||
|
use egui_material_icons::icons::ICON_ARROW_BACK;
|
||||||
|
use rust_i18n::t;
|
||||||
|
|
||||||
|
impl SoundpadGui {
|
||||||
|
pub fn draw_settings(&mut self, ui: &mut Ui) {
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
ui.spacing_mut().item_spacing.y = 5.0;
|
||||||
|
// --------- Back Button and Title ----------
|
||||||
|
ui.horizontal_top(|ui| {
|
||||||
|
let back_button = Button::new(ICON_ARROW_BACK).frame(false);
|
||||||
|
let back_button_response = ui.add(back_button);
|
||||||
|
if back_button_response.clicked() {
|
||||||
|
self.app_state.show_settings = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.add_space(ui.available_width() / 2.0 - 40.0);
|
||||||
|
|
||||||
|
ui.label(
|
||||||
|
RichText::new(t!("gui.settings.header"))
|
||||||
|
.color(Color32::WHITE)
|
||||||
|
.monospace(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
// --------------------------------
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
ui.add_space(20.0);
|
||||||
|
|
||||||
|
// --------- Checkboxes ----------
|
||||||
|
let save_volume_response = ui.checkbox(
|
||||||
|
&mut self.config.save_volume,
|
||||||
|
t!("gui.settings.remember_volume"),
|
||||||
|
);
|
||||||
|
let save_input_response =
|
||||||
|
ui.checkbox(&mut self.config.save_input, t!("gui.settings.remember_mic"));
|
||||||
|
let save_scale_response = ui.checkbox(
|
||||||
|
&mut self.config.save_scale_factor,
|
||||||
|
t!("gui.settings.remember_ui_scale"),
|
||||||
|
);
|
||||||
|
let pause_on_exit_response = ui.checkbox(
|
||||||
|
&mut self.config.pause_on_exit,
|
||||||
|
t!("gui.settings.pause_on_window_close"),
|
||||||
|
);
|
||||||
|
|
||||||
|
if save_volume_response.changed()
|
||||||
|
|| save_input_response.changed()
|
||||||
|
|| save_scale_response.changed()
|
||||||
|
|| pause_on_exit_response.changed()
|
||||||
|
{
|
||||||
|
self.config.save_to_file().ok();
|
||||||
|
}
|
||||||
|
// --------------------------------
|
||||||
|
|
||||||
|
ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
|
||||||
|
ui.label(t!(
|
||||||
|
"gui.settings.version",
|
||||||
|
version = env!("CARGO_PKG_VERSION")
|
||||||
|
));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
use crate::gui::SoundpadGui;
|
||||||
|
use egui::{RichText, Ui};
|
||||||
|
|
||||||
|
impl SoundpadGui {
|
||||||
|
pub fn draw_waiting_for_daemon(&mut self, ui: &mut Ui) {
|
||||||
|
ui.centered_and_justified(|ui| {
|
||||||
|
ui.label(
|
||||||
|
RichText::new("Waiting for PWSP daemon to start...")
|
||||||
|
.size(34.0)
|
||||||
|
.monospace(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-2
@@ -1,8 +1,14 @@
|
|||||||
mod gui;
|
mod gui;
|
||||||
|
|
||||||
use std::error::Error;
|
use anyhow::Result;
|
||||||
|
use rust_i18n::i18n;
|
||||||
|
|
||||||
|
i18n!("locales", fallback = "en");
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn Error>> {
|
async fn main() -> Result<()> {
|
||||||
|
let locale = sys_locale::get_locale().unwrap_or(String::from("en-US"));
|
||||||
|
rust_i18n::set_locale(&locale);
|
||||||
|
|
||||||
gui::run().await
|
gui::run().await
|
||||||
}
|
}
|
||||||
|
|||||||
+377
-130
@@ -1,13 +1,15 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
types::pipewire::{AudioDevice, DeviceType, Terminate},
|
types::pipewire::{DeviceType, Terminate},
|
||||||
utils::{
|
utils::{
|
||||||
daemon::get_daemon_config,
|
daemon::get_daemon_config,
|
||||||
pipewire::{create_link, get_all_devices, get_device},
|
pipewire::{create_link, get_device, link_player_to_virtual_mic},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use rodio::{Decoder, OutputStream, OutputStreamBuilder, Sink, Source};
|
use anyhow::{Result, anyhow};
|
||||||
|
use rodio::{Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, 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},
|
||||||
@@ -22,216 +24,461 @@ 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: OutputStream,
|
stream_handle: Option<MixerDeviceSink>,
|
||||||
sink: Sink,
|
pub tracks: HashMap<u32, PlayingSound>,
|
||||||
|
pub next_id: u32,
|
||||||
|
|
||||||
input_link_sender: Option<pipewire::channel::Sender<Terminate>>,
|
input_link_sender: Option<pipewire::channel::Sender<Terminate>>,
|
||||||
pub current_input_device: Option<AudioDevice>,
|
player_link_sender: Option<pipewire::channel::Sender<Terminate>>,
|
||||||
|
pub input_device_name: Option<String>,
|
||||||
|
|
||||||
pub volume: f32,
|
pub volume: f32, // Master volume
|
||||||
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> {
|
||||||
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: stream_handle,
|
stream_handle: None,
|
||||||
sink,
|
tracks: HashMap::new(),
|
||||||
|
next_id: 1,
|
||||||
|
|
||||||
input_link_sender: None,
|
input_link_sender: None,
|
||||||
current_input_device: default_input_device.clone(),
|
player_link_sender: None,
|
||||||
|
input_device_name: daemon_config.default_input_name.clone(),
|
||||||
|
|
||||||
volume: default_volume,
|
volume: default_volume,
|
||||||
duration: None,
|
|
||||||
|
|
||||||
current_file_path: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if default_input_device.is_some() {
|
if audio_player.input_device_name.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> {
|
||||||
|
if self.stream_handle.is_none() {
|
||||||
|
let mut sink = DeviceSinkBuilder::open_default_sink()?;
|
||||||
|
sink.log_on_drop(false);
|
||||||
|
self.stream_handle = Some(sink);
|
||||||
|
}
|
||||||
|
self.stream_handle
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow!("Failed to initialize stream_handle"))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
match sender.send(Terminate {}) {
|
if sender.send(Terminate {}).is_ok() {
|
||||||
Ok(_) => println!("Sent terminate signal to link thread"),
|
println!("Sent terminate signal to input link thread");
|
||||||
Err(_) => println!("Failed to send terminate signal to link thread"),
|
self.input_link_sender = None;
|
||||||
|
} else {
|
||||||
|
eprintln!("Failed to send terminate signal to input link thread");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn link_devices(&mut self) -> Result<(), Box<dyn Error>> {
|
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<()> {
|
||||||
|
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<()> {
|
||||||
self.abort_link_thread();
|
self.abort_link_thread();
|
||||||
|
|
||||||
if self.current_input_device.is_none() {
|
let input_device;
|
||||||
println!("No input device selected, skipping device linking");
|
if let Some(input_device_name) = &self.input_device_name {
|
||||||
|
if let Ok(device) = get_device(input_device_name).await {
|
||||||
|
input_device = device;
|
||||||
|
} else {
|
||||||
|
eprintln!(
|
||||||
|
"Could not find selected input device {}, skipping device linking",
|
||||||
|
input_device_name
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
eprintln!("No input device selected, skipping device linking");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let (input_devices, _) = get_all_devices().await?;
|
let daemon_input;
|
||||||
|
if let Ok(device) = get_device("pwsp-virtual-mic").await {
|
||||||
let mut pwsp_daemon_input: Option<AudioDevice> = None;
|
daemon_input = device;
|
||||||
for input_device in input_devices {
|
} else {
|
||||||
if input_device.name == "pwsp-virtual-mic" {
|
eprintln!("Could not find pwsp-virtual-mic device, skipping device linking");
|
||||||
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 pwsp_daemon_input = pwsp_daemon_input.unwrap();
|
let Some(output_fl) = input_device.output_fl.clone() else {
|
||||||
|
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) {
|
pub fn pause(&mut self, id: Option<u32>) {
|
||||||
if self.get_state() == PlayerState::Playing {
|
if let Some(id) = id {
|
||||||
self.sink.pause();
|
if let Some(sound) = self.tracks.get_mut(&id) {
|
||||||
|
sound.sink.pause();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for sound in self.tracks.values_mut() {
|
||||||
|
sound.sink.pause();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resume(&mut self) {
|
pub fn resume(&mut self, id: Option<u32>) {
|
||||||
if self.get_state() == PlayerState::Paused {
|
if let Some(id) = id {
|
||||||
self.sink.play();
|
if let Some(sound) = self.tracks.get_mut(&id) {
|
||||||
|
sound.sink.play();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for sound in self.tracks.values_mut() {
|
||||||
|
sound.sink.play();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn stop(&mut self) {
|
pub fn stop(&mut self, id: Option<u32>) {
|
||||||
self.sink.stop();
|
if let Some(id) = id {
|
||||||
|
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 {
|
||||||
self.sink.is_paused()
|
if self.tracks.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.tracks.values().all(|s| s.sink.is_paused())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_state(&mut self) -> PlayerState {
|
pub fn get_state(&self) -> PlayerState {
|
||||||
if self.sink.len() == 0 {
|
if self.tracks.is_empty() {
|
||||||
return PlayerState::Stopped;
|
return PlayerState::Stopped;
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.sink.is_paused() {
|
if self
|
||||||
|
.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::Playing
|
PlayerState::Stopped
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_volume(&mut self, volume: f32) {
|
pub fn get_volume(&mut self, id: Option<u32>) -> Option<f32> {
|
||||||
|
if let Some(id) = id {
|
||||||
|
if let Some(sound) = self.tracks.get_mut(&id) {
|
||||||
|
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;
|
||||||
self.sink.set_volume(volume);
|
for sound in self.tracks.values_mut() {
|
||||||
|
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_duration(&mut self) -> Result<f32, Box<dyn Error>> {
|
pub fn get_position(&self, id: Option<u32>) -> f32 {
|
||||||
if self.get_state() == PlayerState::Stopped {
|
if let Some(id) = id {
|
||||||
Err("Nothing is playing right now".into())
|
if let Some(sound) = self.tracks.get(&id) {
|
||||||
|
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<()> {
|
||||||
|
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 {
|
||||||
match self.duration {
|
// Seek all? Or last? Let's seek all for now if no ID provided
|
||||||
Some(duration) => Ok(duration),
|
for sound in self.tracks.values_mut() {
|
||||||
None => Err("Couldn't determine duration for current file".into()),
|
sound.sink.try_seek(Duration::from_secs_f32(position)).ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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> {
|
||||||
|
if let Some(id) = id {
|
||||||
|
if let Some(sound) = self.tracks.get(&id) {
|
||||||
|
return sound.duration.ok_or(anyhow!("Unknown duration"));
|
||||||
|
}
|
||||||
|
} else if let Some(sound) = self.tracks.values().last() {
|
||||||
|
return sound.duration.ok_or(anyhow!("Unknown duration"));
|
||||||
|
}
|
||||||
|
Err(anyhow!("No track playing"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn play(&mut self, file_path: &Path, concurrent: bool) -> Result<u32> {
|
||||||
|
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(anyhow!(err)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_current_file_path(&mut self) -> &Option<PathBuf> {
|
pub fn set_loop(&mut self, enabled: bool, id: Option<u32>) {
|
||||||
if self.get_state() == PlayerState::Stopped {
|
if let Some(id) = id {
|
||||||
self.current_file_path = None;
|
if let Some(sound) = self.tracks.get_mut(&id) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
&self.current_file_path
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_current_input_device(&mut self, name: &str) -> Result<(), Box<dyn Error>> {
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_current_input_device(&mut self, name: &str) -> Result<()> {
|
||||||
let input_device = get_device(name).await?;
|
let input_device = get_device(name).await?;
|
||||||
|
|
||||||
if input_device.device_type != DeviceType::Input {
|
if input_device.device_type != DeviceType::Input {
|
||||||
return Err("Selected device is not an input device".into());
|
return Err(anyhow!("Selected device is not an input device"));
|
||||||
}
|
}
|
||||||
|
|
||||||
self.current_input_device = Some(input_device);
|
self.input_device_name = Some(name.to_string());
|
||||||
|
|
||||||
self.link_devices().await?;
|
self.link_devices().await?;
|
||||||
|
|
||||||
|
|||||||
+531
-43
@@ -1,9 +1,17 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
types::socket::Response,
|
types::{
|
||||||
utils::{daemon::get_audio_player, pipewire::get_all_devices},
|
audio_player::{FullState, PlayerState},
|
||||||
|
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::path::PathBuf;
|
use std::{collections::HashMap, path::PathBuf};
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait Executable {
|
pub trait Executable {
|
||||||
@@ -12,35 +20,56 @@ pub trait Executable {
|
|||||||
|
|
||||||
pub struct PingCommand {}
|
pub struct PingCommand {}
|
||||||
|
|
||||||
pub struct PauseCommand {}
|
pub struct KillCommand {}
|
||||||
|
|
||||||
pub struct ResumeCommand {}
|
pub struct PauseCommand {
|
||||||
|
pub id: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct StopCommand {}
|
pub struct ResumeCommand {
|
||||||
|
pub id: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TogglePauseCommand {
|
||||||
|
pub id: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct StopCommand {
|
||||||
|
pub id: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct IsPausedCommand {}
|
pub struct 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 GetCurrentFilePathCommand {}
|
pub struct GetTracksCommand {}
|
||||||
|
|
||||||
pub struct GetCurrentInputCommand {}
|
pub struct GetCurrentInputCommand {}
|
||||||
|
|
||||||
@@ -50,6 +79,54 @@ 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 {
|
||||||
@@ -57,11 +134,21 @@ 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 = get_audio_player().await.lock().await;
|
let mut audio_player = match get_audio_player().await {
|
||||||
audio_player.pause();
|
Ok(player) => player.lock().await,
|
||||||
|
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
|
||||||
|
};
|
||||||
|
audio_player.pause(self.id);
|
||||||
Response::new(true, "Audio was paused")
|
Response::new(true, "Audio was paused")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,17 +156,64 @@ 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 = get_audio_player().await.lock().await;
|
let mut audio_player = match get_audio_player().await {
|
||||||
audio_player.resume();
|
Ok(player) => player.lock().await,
|
||||||
|
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
|
||||||
|
};
|
||||||
|
audio_player.resume(self.id);
|
||||||
Response::new(true, "Audio was resumed")
|
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 = get_audio_player().await.lock().await;
|
let mut audio_player = match get_audio_player().await {
|
||||||
audio_player.stop();
|
Ok(player) => player.lock().await,
|
||||||
|
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
|
||||||
|
};
|
||||||
|
audio_player.stop(self.id);
|
||||||
Response::new(true, "Audio was stopped")
|
Response::new(true, "Audio was stopped")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,7 +221,10 @@ 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 = get_audio_player().await.lock().await;
|
let audio_player = match get_audio_player().await {
|
||||||
|
Ok(player) => player.lock().await,
|
||||||
|
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
|
||||||
|
};
|
||||||
let is_paused = audio_player.is_paused().to_string();
|
let is_paused = audio_player.is_paused().to_string();
|
||||||
Response::new(true, is_paused)
|
Response::new(true, is_paused)
|
||||||
}
|
}
|
||||||
@@ -96,18 +233,32 @@ 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 mut audio_player = get_audio_player().await.lock().await;
|
let audio_player = match get_audio_player().await {
|
||||||
|
Ok(player) => player.lock().await,
|
||||||
|
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
|
||||||
|
};
|
||||||
let state = audio_player.get_state();
|
let state = audio_player.get_state();
|
||||||
Response::new(true, serde_json::to_string(&state).unwrap())
|
match serde_json::to_string(&state) {
|
||||||
|
Ok(json) => Response::new(true, json),
|
||||||
|
Err(err) => Response::new(false, format!("Failed to serialize state: {}", err)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Executable for GetVolumeCommand {
|
impl Executable for GetVolumeCommand {
|
||||||
async fn execute(&self) -> Response {
|
async fn execute(&self) -> Response {
|
||||||
let audio_player = get_audio_player().await.lock().await;
|
let mut audio_player = match get_audio_player().await {
|
||||||
let volume = audio_player.volume;
|
Ok(player) => player.lock().await,
|
||||||
|
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
|
||||||
|
};
|
||||||
|
let volume = audio_player.get_volume(self.id);
|
||||||
|
|
||||||
|
if let Some(volume) = volume {
|
||||||
Response::new(true, volume.to_string())
|
Response::new(true, volume.to_string())
|
||||||
|
} else {
|
||||||
|
Response::new(false, "Failed to get volume")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,8 +266,11 @@ 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 = get_audio_player().await.lock().await;
|
let mut audio_player = match get_audio_player().await {
|
||||||
audio_player.set_volume(volume);
|
Ok(player) => player.lock().await,
|
||||||
|
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
|
||||||
|
};
|
||||||
|
audio_player.set_volume(volume, self.id);
|
||||||
Response::new(true, format!("Audio volume was set to {}", volume))
|
Response::new(true, format!("Audio volume was set to {}", volume))
|
||||||
} else {
|
} else {
|
||||||
Response::new(false, "Invalid volume value")
|
Response::new(false, "Invalid volume value")
|
||||||
@@ -127,8 +281,11 @@ 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 mut audio_player = get_audio_player().await.lock().await;
|
let audio_player = match get_audio_player().await {
|
||||||
let position = audio_player.get_position();
|
Ok(player) => player.lock().await,
|
||||||
|
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
|
||||||
|
};
|
||||||
|
let position = audio_player.get_position(self.id);
|
||||||
Response::new(true, position.to_string())
|
Response::new(true, position.to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,8 +294,11 @@ 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 = get_audio_player().await.lock().await;
|
let mut audio_player = match get_audio_player().await {
|
||||||
match audio_player.seek(position) {
|
Ok(player) => player.lock().await,
|
||||||
|
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
|
||||||
|
};
|
||||||
|
match audio_player.seek(position, self.id) {
|
||||||
Ok(_) => Response::new(true, format!("Audio position was set to {}", position)),
|
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()),
|
||||||
}
|
}
|
||||||
@@ -151,8 +311,11 @@ 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 = get_audio_player().await.lock().await;
|
let mut audio_player = match get_audio_player().await {
|
||||||
match audio_player.get_duration() {
|
Ok(player) => player.lock().await,
|
||||||
|
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
|
||||||
|
};
|
||||||
|
match audio_player.get_duration(self.id) {
|
||||||
Ok(duration) => Response::new(true, duration.to_string()),
|
Ok(duration) => Response::new(true, duration.to_string()),
|
||||||
Err(err) => Response::new(false, err.to_string()),
|
Err(err) => Response::new(false, err.to_string()),
|
||||||
}
|
}
|
||||||
@@ -163,9 +326,15 @@ 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 = get_audio_player().await.lock().await;
|
let mut audio_player = match get_audio_player().await {
|
||||||
match audio_player.play(file_path).await {
|
Ok(player) => player.lock().await,
|
||||||
Ok(_) => Response::new(true, format!("Now playing {}", file_path.display())),
|
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
|
||||||
|
};
|
||||||
|
match audio_player
|
||||||
|
.play(file_path, self.concurrent.unwrap_or(false))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(id) => Response::new(true, id.to_string()),
|
||||||
Err(err) => Response::new(false, err.to_string()),
|
Err(err) => Response::new(false, err.to_string()),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -175,14 +344,16 @@ impl Executable for PlayCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Executable for GetCurrentFilePathCommand {
|
impl Executable for GetTracksCommand {
|
||||||
async fn execute(&self) -> Response {
|
async fn execute(&self) -> Response {
|
||||||
let mut audio_player = get_audio_player().await.lock().await;
|
let audio_player = match get_audio_player().await {
|
||||||
let current_file_path = audio_player.get_current_file_path();
|
Ok(player) => player.lock().await,
|
||||||
if let Some(current_file_path) = current_file_path {
|
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
|
||||||
Response::new(true, current_file_path.to_str().unwrap())
|
};
|
||||||
} else {
|
let tracks = audio_player.get_tracks();
|
||||||
Response::new(false, "No file is playing")
|
match serde_json::to_string(&tracks) {
|
||||||
|
Ok(json) => Response::new(true, json),
|
||||||
|
Err(err) => Response::new(false, format!("Failed to serialize tracks: {}", err)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,12 +361,19 @@ impl Executable for GetCurrentFilePathCommand {
|
|||||||
#[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 = get_audio_player().await.lock().await;
|
let audio_player = match get_audio_player().await {
|
||||||
if let Some(input_device) = &audio_player.current_input_device {
|
Ok(player) => player.lock().await,
|
||||||
|
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
|
||||||
|
};
|
||||||
|
if let Some(input_device_name) = &audio_player.input_device_name {
|
||||||
|
if let Ok(input_device) = get_device(input_device_name).await {
|
||||||
Response::new(
|
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")
|
||||||
}
|
}
|
||||||
@@ -205,7 +383,10 @@ 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) = get_all_devices().await.unwrap();
|
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 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" {
|
||||||
@@ -225,7 +406,10 @@ 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 = get_audio_player().await.lock().await;
|
let mut audio_player = match get_audio_player().await {
|
||||||
|
Ok(player) => player.lock().await,
|
||||||
|
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
|
||||||
|
};
|
||||||
match audio_player.set_current_input_device(name).await {
|
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()),
|
||||||
@@ -235,3 +419,307 @@ 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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+139
-14
@@ -1,19 +1,22 @@
|
|||||||
use crate::utils::config::get_config_path;
|
use crate::{types::socket::Request, utils::config::get_config_path};
|
||||||
|
use anyhow::Result;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::{collections::HashSet, error::Error, fs, path::PathBuf};
|
use std::{collections::HashMap, 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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DaemonConfig {
|
impl DaemonConfig {
|
||||||
pub fn save_to_file(&self) -> Result<(), Box<dyn Error>> {
|
pub fn save_to_file(&self) -> Result<()> {
|
||||||
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 !config_path.exists() {
|
if let Some(config_dir) = config_path.parent()
|
||||||
|
&& !config_path.exists()
|
||||||
|
{
|
||||||
fs::create_dir_all(config_dir)?;
|
fs::create_dir_all(config_dir)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,44 +25,53 @@ impl DaemonConfig {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_from_file() -> Result<DaemonConfig, Box<dyn Error>> {
|
pub fn load_from_file() -> Result<DaemonConfig> {
|
||||||
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)?;
|
||||||
Ok(serde_json::from_slice::<DaemonConfig>(&bytes)?)
|
match 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: HashSet<PathBuf>,
|
pub dirs: Vec<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: HashSet::default(),
|
dirs: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GuiConfig {
|
impl GuiConfig {
|
||||||
pub fn save_to_file(&mut self) -> Result<(), Box<dyn Error>> {
|
pub fn save_to_file(&mut self) -> Result<()> {
|
||||||
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 !config_path.exists() {
|
if let Some(config_dir) = config_path.parent()
|
||||||
|
&& !config_path.exists()
|
||||||
|
{
|
||||||
fs::create_dir_all(config_dir)?;
|
fs::create_dir_all(config_dir)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,9 +85,122 @@ impl GuiConfig {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_from_file() -> Result<GuiConfig, Box<dyn Error>> {
|
pub fn load_from_file() -> Result<GuiConfig> {
|
||||||
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)?;
|
||||||
Ok(serde_json::from_slice::<GuiConfig>(&bytes)?)
|
match serde_json::from_slice::<GuiConfig>(&bytes) {
|
||||||
|
Ok(config) => Ok(config),
|
||||||
|
Err(_) => Ok(GuiConfig::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct HotkeySlot {
|
||||||
|
pub slot: String,
|
||||||
|
pub action: Request,
|
||||||
|
pub key_chord: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct HotkeyConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub slots: Vec<HotkeySlot>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HotkeyConfig {
|
||||||
|
pub fn config_path() -> Result<PathBuf> {
|
||||||
|
Ok(get_config_path()?.join("hotkeys.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load() -> Result<HotkeyConfig> {
|
||||||
|
let path = Self::config_path()?;
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(HotkeyConfig::default());
|
||||||
|
}
|
||||||
|
let bytes = fs::read(&path)?;
|
||||||
|
match serde_json::from_slice::<HotkeyConfig>(&bytes) {
|
||||||
|
Ok(config) => Ok(config),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self) -> Result<()> {
|
||||||
|
let path = Self::config_path()?;
|
||||||
|
if let Some(dir) = path.parent()
|
||||||
|
&& !dir.exists()
|
||||||
|
{
|
||||||
|
fs::create_dir_all(dir)?;
|
||||||
|
}
|
||||||
|
let json = serde_json::to_string_pretty(self)?;
|
||||||
|
fs::write(path, json.as_bytes())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_slot(&self, slot: &str) -> Option<&HotkeySlot> {
|
||||||
|
self.slots.iter().find(|s| s.slot == slot)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_slot_mut(&mut self, slot: &str) -> Option<&mut HotkeySlot> {
|
||||||
|
self.slots.iter_mut().find(|s| s.slot == slot)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_slot(&mut self, slot: String, action: Request) {
|
||||||
|
if let Some(existing) = self.find_slot_mut(&slot) {
|
||||||
|
existing.action = action;
|
||||||
|
} else {
|
||||||
|
self.slots.push(HotkeySlot {
|
||||||
|
slot,
|
||||||
|
action,
|
||||||
|
key_chord: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_key_chord(&mut self, slot: &str, key_chord: Option<String>) -> bool {
|
||||||
|
if let Some(existing) = self.find_slot_mut(slot) {
|
||||||
|
existing.key_chord = key_chord;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_slot(&mut self, slot: &str) -> bool {
|
||||||
|
let len = self.slots.len();
|
||||||
|
self.slots.retain(|s| s.slot != slot);
|
||||||
|
self.slots.len() != len
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns pairs of slot names that share the same key chord.
|
||||||
|
pub fn find_conflicts(&self) -> Vec<(&str, &str)> {
|
||||||
|
let mut conflicts = vec![];
|
||||||
|
let mut chord_map: HashMap<&str, Vec<&str>> = HashMap::new();
|
||||||
|
|
||||||
|
for s in &self.slots {
|
||||||
|
if let Some(chord) = &s.key_chord {
|
||||||
|
chord_map.entry(chord.as_str()).or_default().push(&s.slot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for slots in chord_map.values() {
|
||||||
|
if slots.len() > 1 {
|
||||||
|
for i in 0..slots.len() {
|
||||||
|
for j in (i + 1)..slots.len() {
|
||||||
|
conflicts.push((slots[i], slots[j]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
conflicts
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find which slot(s) have the given key chord.
|
||||||
|
pub fn slots_for_chord(&self, chord: &str) -> Vec<&HotkeySlot> {
|
||||||
|
self.slots
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.key_chord.as_deref() == Some(chord))
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+49
-12
@@ -1,39 +1,76 @@
|
|||||||
use crate::types::audio_player::PlayerState;
|
use crate::types::{
|
||||||
|
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 AppState {
|
pub struct TrackUiState {
|
||||||
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: HashSet<PathBuf>,
|
pub dirs: Vec<PathBuf>,
|
||||||
|
pub dirs_to_remove: HashSet<PathBuf>,
|
||||||
|
|
||||||
|
pub listed_files: HashSet<PathBuf>,
|
||||||
|
pub listed_dirs: HashSet<PathBuf>,
|
||||||
|
pub dir_cache: HashMap<PathBuf, Vec<PathBuf>>,
|
||||||
|
|
||||||
|
pub show_hotkeys: bool,
|
||||||
|
pub hotkey_capture_active: bool,
|
||||||
|
|
||||||
|
pub hotkey_config: HotkeyConfig,
|
||||||
|
pub hotkey_search_query: String,
|
||||||
|
|
||||||
|
pub assigning_hotkey_slot: Option<String>,
|
||||||
|
pub assigning_hotkey_for_file: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone)]
|
#[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 is_paused: bool,
|
pub tracks: Vec<TrackInfo>,
|
||||||
|
|
||||||
pub volume: f32,
|
pub volume: f32, // Master volume
|
||||||
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>,
|
||||||
}
|
}
|
||||||
|
|||||||
+160
-21
@@ -1,7 +1,9 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
|
pub const MAX_MESSAGE_SIZE: usize = 128 * 1024;
|
||||||
|
|
||||||
|
#[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>,
|
||||||
@@ -24,44 +26,100 @@ impl Request {
|
|||||||
Request::new("ping", vec![])
|
Request::new("ping", vec![])
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn pause() -> Self {
|
pub fn kill() -> Self {
|
||||||
Request::new("pause", vec![])
|
Request::new("kill", vec![])
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resume() -> Self {
|
pub fn pause(id: Option<u32>) -> Self {
|
||||||
Request::new("resume", vec![])
|
let mut args = vec![];
|
||||||
|
let id_str;
|
||||||
|
if let Some(id) = id {
|
||||||
|
id_str = id.to_string();
|
||||||
|
args.push(("id", id_str.as_str()));
|
||||||
|
}
|
||||||
|
Request::new("pause", args)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn stop() -> Self {
|
pub fn resume(id: Option<u32>) -> Self {
|
||||||
Request::new("stop", vec![])
|
let mut args = vec![];
|
||||||
|
let id_str;
|
||||||
|
if let Some(id) = id {
|
||||||
|
id_str = id.to_string();
|
||||||
|
args.push(("id", id_str.as_str()));
|
||||||
|
}
|
||||||
|
Request::new("resume", args)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn play(file_path: &str) -> Self {
|
pub fn toggle_pause(id: Option<u32>) -> Self {
|
||||||
Request::new("play", vec![("file_path", file_path)])
|
let mut args = vec![];
|
||||||
|
let id_str;
|
||||||
|
if let Some(id) = id {
|
||||||
|
id_str = id.to_string();
|
||||||
|
args.push(("id", id_str.as_str()));
|
||||||
|
}
|
||||||
|
Request::new("toggle_pause", args)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop(id: Option<u32>) -> Self {
|
||||||
|
let mut args = vec![];
|
||||||
|
let id_str;
|
||||||
|
if let Some(id) = id {
|
||||||
|
id_str = id.to_string();
|
||||||
|
args.push(("id", id_str.as_str()));
|
||||||
|
}
|
||||||
|
Request::new("stop", args)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn play(file_path: &str, concurrent: bool) -> Self {
|
||||||
|
Request::new(
|
||||||
|
"play",
|
||||||
|
vec![
|
||||||
|
("file_path", file_path),
|
||||||
|
("concurrent", &concurrent.to_string()),
|
||||||
|
],
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_is_paused() -> Self {
|
pub fn get_is_paused() -> Self {
|
||||||
Request::new("is_paused", vec![])
|
Request::new("is_paused", vec![])
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_volume() -> Self {
|
pub fn get_volume(id: Option<u32>) -> Self {
|
||||||
Request::new("get_volume", vec![])
|
let mut args = vec![];
|
||||||
|
let id_str;
|
||||||
|
if let Some(id) = id {
|
||||||
|
id_str = id.to_string();
|
||||||
|
args.push(("id", id_str.as_str()));
|
||||||
|
}
|
||||||
|
Request::new("get_volume", args)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_position() -> Self {
|
pub fn get_position(id: Option<u32>) -> Self {
|
||||||
Request::new("get_position", vec![])
|
let mut args = vec![];
|
||||||
|
let id_str;
|
||||||
|
if let Some(id) = id {
|
||||||
|
id_str = id.to_string();
|
||||||
|
args.push(("id", id_str.as_str()));
|
||||||
|
}
|
||||||
|
Request::new("get_position", args)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_duration() -> Self {
|
pub fn get_duration(id: Option<u32>) -> Self {
|
||||||
Request::new("get_duration", vec![])
|
let mut args = vec![];
|
||||||
|
let id_str;
|
||||||
|
if let Some(id) = id {
|
||||||
|
id_str = id.to_string();
|
||||||
|
args.push(("id", id_str.as_str()));
|
||||||
|
}
|
||||||
|
Request::new("get_duration", args)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_state() -> Self {
|
pub fn get_state() -> Self {
|
||||||
Request::new("get_state", vec![])
|
Request::new("get_state", vec![])
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_current_file_path() -> Self {
|
pub fn get_tracks() -> Self {
|
||||||
Request::new("get_current_file_path", vec![])
|
Request::new("get_tracks", vec![])
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_input() -> Self {
|
pub fn get_input() -> Self {
|
||||||
@@ -72,17 +130,98 @@ impl Request {
|
|||||||
Request::new("get_inputs", vec![])
|
Request::new("get_inputs", vec![])
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_volume(volume: f32) -> Self {
|
pub fn set_volume(volume: f32, id: Option<u32>) -> Self {
|
||||||
Request::new("set_volume", vec![("volume", &volume.to_string())])
|
let mut args = vec![("volume".to_string(), volume.to_string())];
|
||||||
|
if let Some(id) = id {
|
||||||
|
args.push(("id".to_string(), id.to_string()));
|
||||||
|
}
|
||||||
|
Request::new("set_volume".to_string(), args)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn seek(position: f32) -> Self {
|
pub fn seek(position: f32, id: Option<u32>) -> Self {
|
||||||
Request::new("seek", vec![("position", &position.to_string())])
|
let mut args = vec![("position".to_string(), position.to_string())];
|
||||||
|
if let Some(id) = id {
|
||||||
|
args.push(("id".to_string(), id.to_string()));
|
||||||
|
}
|
||||||
|
Request::new("seek".to_string(), args)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_input(name: &str) -> Self {
|
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)]
|
||||||
|
|||||||
+167
-10
@@ -3,14 +3,18 @@ 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 {})),
|
||||||
"pause" => Some(Box::new(PauseCommand {})),
|
"kill" => Some(Box::new(KillCommand {})),
|
||||||
"resume" => Some(Box::new(ResumeCommand {})),
|
"pause" => Some(Box::new(PauseCommand { id })),
|
||||||
"stop" => Some(Box::new(StopCommand {})),
|
"resume" => Some(Box::new(ResumeCommand { id })),
|
||||||
|
"toggle_pause" => Some(Box::new(TogglePauseCommand { id })),
|
||||||
|
"stop" => Some(Box::new(StopCommand { id })),
|
||||||
"is_paused" => Some(Box::new(IsPausedCommand {})),
|
"is_paused" => Some(Box::new(IsPausedCommand {})),
|
||||||
"get_state" => Some(Box::new(GetStateCommand {})),
|
"get_state" => Some(Box::new(GetStateCommand {})),
|
||||||
"get_volume" => Some(Box::new(GetVolumeCommand {})),
|
"get_volume" => Some(Box::new(GetVolumeCommand { id })),
|
||||||
"set_volume" => {
|
"set_volume" => {
|
||||||
let volume = request
|
let volume = request
|
||||||
.args
|
.args
|
||||||
@@ -18,9 +22,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 }))
|
Some(Box::new(SetVolumeCommand { volume, id }))
|
||||||
}
|
}
|
||||||
"get_position" => Some(Box::new(GetPositionCommand {})),
|
"get_position" => Some(Box::new(GetPositionCommand { id })),
|
||||||
"seek" => {
|
"seek" => {
|
||||||
let position = request
|
let position = request
|
||||||
.args
|
.args
|
||||||
@@ -28,9 +32,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 }))
|
Some(Box::new(SeekCommand { position, id }))
|
||||||
}
|
}
|
||||||
"get_duration" => Some(Box::new(GetDurationCommand {})),
|
"get_duration" => Some(Box::new(GetDurationCommand { id })),
|
||||||
"play" => {
|
"play" => {
|
||||||
let file_path = request
|
let file_path = request
|
||||||
.args
|
.args
|
||||||
@@ -38,15 +42,168 @@ 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();
|
||||||
Some(Box::new(PlayCommand { file_path }))
|
let concurrent = request
|
||||||
|
.args
|
||||||
|
.get("concurrent")
|
||||||
|
.unwrap_or(&String::new())
|
||||||
|
.parse::<bool>()
|
||||||
|
.ok();
|
||||||
|
Some(Box::new(PlayCommand {
|
||||||
|
file_path,
|
||||||
|
concurrent,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
"get_current_file_path" => Some(Box::new(GetCurrentFilePathCommand {})),
|
"get_tracks" => Some(Box::new(GetTracksCommand {})),
|
||||||
"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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::types::socket::Request;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_set_volume_valid() {
|
||||||
|
let mut args = HashMap::new();
|
||||||
|
args.insert("volume".to_string(), "0.5".to_string());
|
||||||
|
args.insert("id".to_string(), "1".to_string());
|
||||||
|
let request = Request {
|
||||||
|
name: "set_volume".to_string(),
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cmd = parse_command(&request);
|
||||||
|
assert!(cmd.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_set_volume_missing_volume() {
|
||||||
|
let mut args = HashMap::new();
|
||||||
|
args.insert("id".to_string(), "1".to_string());
|
||||||
|
let request = Request {
|
||||||
|
name: "set_volume".to_string(),
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cmd = parse_command(&request);
|
||||||
|
assert!(cmd.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_set_volume_invalid_volume() {
|
||||||
|
let mut args = HashMap::new();
|
||||||
|
args.insert("volume".to_string(), "not-a-float".to_string());
|
||||||
|
let request = Request {
|
||||||
|
name: "set_volume".to_string(),
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cmd = parse_command(&request);
|
||||||
|
assert!(cmd.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_set_volume_missing_id() {
|
||||||
|
let mut args = HashMap::new();
|
||||||
|
args.insert("volume".to_string(), "0.5".to_string());
|
||||||
|
let request = Request {
|
||||||
|
name: "set_volume".to_string(),
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cmd = parse_command(&request);
|
||||||
|
assert!(cmd.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_set_volume_invalid_id() {
|
||||||
|
let mut args = HashMap::new();
|
||||||
|
args.insert("id".to_string(), "not-an-int".to_string());
|
||||||
|
args.insert("volume".to_string(), "0.5".to_string());
|
||||||
|
let request = Request {
|
||||||
|
name: "set_volume".to_string(),
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cmd = parse_command(&request);
|
||||||
|
assert!(cmd.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_set_volume_empty_args() {
|
||||||
|
let request = Request {
|
||||||
|
name: "set_volume".to_string(),
|
||||||
|
args: HashMap::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let cmd = parse_command(&request);
|
||||||
|
assert!(cmd.is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,7 @@
|
|||||||
use std::{error::Error, path::PathBuf};
|
use anyhow::Result;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
pub fn get_config_path() -> Result<PathBuf, Box<dyn Error>> {
|
pub fn get_config_path() -> Result<PathBuf> {
|
||||||
let config_path = dirs::config_dir().expect("Failed to obtain config dir");
|
let config_path = dirs::config_dir().expect("Failed to obtain config dir");
|
||||||
Ok(config_path.join("pwsp"))
|
Ok(config_path.join("pwsp"))
|
||||||
}
|
}
|
||||||
|
|||||||
+57
-69
@@ -1,14 +1,13 @@
|
|||||||
use crate::{
|
use crate::types::{
|
||||||
types::{
|
|
||||||
audio_player::AudioPlayer,
|
audio_player::AudioPlayer,
|
||||||
config::DaemonConfig,
|
config::DaemonConfig,
|
||||||
pipewire::AudioDevice,
|
socket::{MAX_MESSAGE_SIZE, Request, Response},
|
||||||
socket::{Request, Response},
|
|
||||||
},
|
|
||||||
utils::pipewire::{create_link, get_all_devices},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::{error::Error, fs};
|
use std::{env, error::Error, fs};
|
||||||
use tokio::{
|
use tokio::{
|
||||||
io::{AsyncReadExt, AsyncWriteExt},
|
io::{AsyncReadExt, AsyncWriteExt},
|
||||||
net::UnixStream,
|
net::UnixStream,
|
||||||
@@ -18,11 +17,14 @@ 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() -> &'static Mutex<AudioPlayer> {
|
pub async fn get_audio_player() -> Result<&'static Mutex<AudioPlayer>, String> {
|
||||||
AUDIO_PLAYER
|
AUDIO_PLAYER
|
||||||
.get_or_init(|| async {
|
.get_or_try_init(|| async {
|
||||||
println!("Initializing audio player");
|
println!("Initializing audio player");
|
||||||
Mutex::new(AudioPlayer::new().await.unwrap())
|
match AudioPlayer::new().await {
|
||||||
|
Ok(player) => Ok(Mutex::new(player)),
|
||||||
|
Err(err) => Err(err.to_string()),
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -35,81 +37,59 @@ pub fn get_daemon_config() -> DaemonConfig {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn link_player_to_virtual_mic() -> Result<(), Box<dyn Error>> {
|
fn get_current_uid() -> u32 {
|
||||||
let (input_devices, output_devices) = get_all_devices().await?;
|
rustix::process::geteuid().as_raw()
|
||||||
|
|
||||||
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_else(|| {
|
||||||
|
let uid = get_current_uid();
|
||||||
|
env::temp_dir().join(format!("pwsp-{}", uid))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_runtime_dir() -> Result<(), Box<dyn Error>> {
|
pub fn create_runtime_dir() -> Result<()> {
|
||||||
let runtime_dir = get_runtime_dir();
|
let runtime_dir = get_runtime_dir();
|
||||||
if !runtime_dir.exists() {
|
|
||||||
fs::create_dir_all(&runtime_dir)?;
|
if runtime_dir.exists() {
|
||||||
|
let meta = fs::symlink_metadata(&runtime_dir)?;
|
||||||
|
if meta.is_symlink() {
|
||||||
|
return Err(anyhow::anyhow!("Runtime directory is a symlink"));
|
||||||
|
}
|
||||||
|
let uid = get_current_uid();
|
||||||
|
if meta.uid() != uid {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Runtime directory is owned by another user"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if meta.permissions().mode() & 0o777 != 0o700 {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Runtime directory has incorrect permissions"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fs::DirBuilder::new()
|
||||||
|
.recursive(true)
|
||||||
|
.mode(0o700)
|
||||||
|
.create(&runtime_dir)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_daemon_running() -> Result<bool, Box<dyn Error>> {
|
pub fn is_daemon_running() -> Result<bool> {
|
||||||
let lock_file = fs::File::create(get_runtime_dir().join("daemon.lock"))?;
|
let lock_file = fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.write(true)
|
||||||
|
.truncate(false)
|
||||||
|
.open(get_runtime_dir().join("daemon.lock"))?;
|
||||||
match lock_file.try_lock() {
|
match lock_file.try_lock() {
|
||||||
Ok(_) => Ok(false),
|
Ok(_) => Ok(false),
|
||||||
Err(_) => Ok(true),
|
Err(_) => Ok(true),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn wait_for_daemon() -> Result<(), Box<dyn Error>> {
|
pub async fn wait_for_daemon() -> Result<()> {
|
||||||
if is_daemon_running()? {
|
if is_daemon_running()? {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -124,7 +104,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>> {
|
pub async fn make_request(request: Request) -> Result<Response, Box<dyn Error + Send + Sync>> {
|
||||||
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?;
|
||||||
|
|
||||||
@@ -146,6 +126,14 @@ 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());
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+76
-101
@@ -1,17 +1,16 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
types::{
|
types::{
|
||||||
audio_player::PlayerState,
|
audio_player::FullState,
|
||||||
config::GuiConfig,
|
config::{GuiConfig, HotkeyConfig},
|
||||||
gui::AudioPlayerState,
|
gui::AudioPlayerState,
|
||||||
socket::{Request, Response},
|
socket::{Request, Response},
|
||||||
},
|
},
|
||||||
utils::daemon::{make_request, wait_for_daemon},
|
utils::daemon::{is_daemon_running, make_request},
|
||||||
};
|
};
|
||||||
|
use anyhow::{Result, anyhow};
|
||||||
use std::{
|
use std::{
|
||||||
collections::HashMap,
|
|
||||||
error::Error,
|
|
||||||
path::PathBuf,
|
|
||||||
sync::{Arc, Mutex},
|
sync::{Arc, Mutex},
|
||||||
|
time::Instant,
|
||||||
};
|
};
|
||||||
use tokio::time::{Duration, sleep};
|
use tokio::time::{Duration, sleep};
|
||||||
|
|
||||||
@@ -23,8 +22,18 @@ 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> {
|
||||||
futures::executor::block_on(make_request(request))
|
tokio::task::block_in_place(|| {
|
||||||
|
tokio::runtime::Handle::current()
|
||||||
|
.block_on(make_request(request))
|
||||||
|
.map_err(|e| anyhow!(e))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn make_request_async(request: Request) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
make_request(request).await.ok();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn format_time_pair(position: f32, duration: f32) -> String {
|
pub fn format_time_pair(position: f32, duration: f32) -> String {
|
||||||
@@ -40,112 +49,78 @@ 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_millis(100);
|
let sleep_duration = Duration::from_secs_f32(1.0 / 60.0);
|
||||||
|
let mut last_hotkey_poll = Instant::now();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
wait_for_daemon().await.ok();
|
let is_running = is_daemon_running().unwrap_or(false);
|
||||||
|
|
||||||
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.lock().unwrap();
|
let mut guard = audio_player_state_shared
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner());
|
||||||
|
guard.is_daemon_running = false;
|
||||||
|
}
|
||||||
|
sleep(Duration::from_millis(500)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let full_state_req = Request::get_full_state();
|
||||||
|
let full_state_res = make_request(full_state_req).await.unwrap_or_default();
|
||||||
|
|
||||||
|
if full_state_res.status {
|
||||||
|
let full_state: FullState =
|
||||||
|
serde_json::from_str(&full_state_res.message).unwrap_or_default();
|
||||||
|
|
||||||
|
let mut guard = audio_player_state_shared
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner());
|
||||||
|
|
||||||
guard.state = match guard.new_state.clone() {
|
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 => state,
|
None => full_state.state,
|
||||||
};
|
};
|
||||||
guard.current_file_path = file_path;
|
guard.tracks = full_state.tracks;
|
||||||
guard.is_paused = is_paused;
|
guard.volume = full_state.volume;
|
||||||
guard.volume = match guard.new_volume {
|
guard.current_input = full_state
|
||||||
Some(new_volume) => {
|
.current_input
|
||||||
guard.new_volume = None;
|
.split(" - ")
|
||||||
new_volume
|
.next()
|
||||||
|
.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)
|
||||||
guard.duration = if duration > 0.0 { duration } else { 1.0 };
|
if last_hotkey_poll.elapsed() >= Duration::from_secs(2) {
|
||||||
guard.current_input = current_input;
|
let hotkey_res = make_request(Request::get_hotkeys())
|
||||||
guard.all_inputs = all_inputs;
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
if hotkey_res.status
|
||||||
|
&& let Ok(config) = serde_json::from_str::<HotkeyConfig>(&hotkey_res.message)
|
||||||
|
{
|
||||||
|
let mut guard = audio_player_state_shared
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner());
|
||||||
|
guard.hotkey_config = Some(config);
|
||||||
|
}
|
||||||
|
last_hotkey_poll = Instant::now();
|
||||||
}
|
}
|
||||||
|
|
||||||
sleep(sleep_duration).await;
|
sleep(sleep_duration).await;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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;
|
||||||
|
|||||||
+186
-57
@@ -1,14 +1,22 @@
|
|||||||
use crate::types::pipewire::{AudioDevice, DeviceType, Port, Terminate};
|
use crate::types::pipewire::{AudioDevice, DeviceType, Port, Terminate};
|
||||||
|
use anyhow::{Result, anyhow};
|
||||||
use pipewire::{
|
use pipewire::{
|
||||||
context::ContextRc, link::Link, main_loop::MainLoopRc, properties::properties,
|
context::ContextRc, link::Link, main_loop::MainLoopRc, properties::properties,
|
||||||
registry::GlobalObject, spa::utils::dict::DictRef,
|
registry::GlobalObject, spa::utils::dict::DictRef,
|
||||||
};
|
};
|
||||||
use std::{collections::HashMap, error::Error, thread};
|
use std::{collections::HashMap, thread};
|
||||||
use tokio::{
|
use tokio::{
|
||||||
sync::mpsc,
|
sync::mpsc,
|
||||||
time::{Duration, timeout},
|
time::{Duration, timeout},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub fn setup_pipewire_context() -> Result<(MainLoopRc, ContextRc), String> {
|
||||||
|
pipewire::init();
|
||||||
|
let main_loop = MainLoopRc::new(None).map_err(|e| e.to_string())?;
|
||||||
|
let context = ContextRc::new(&main_loop, None).map_err(|e| e.to_string())?;
|
||||||
|
Ok((main_loop, context))
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_global_object(
|
fn parse_global_object(
|
||||||
global_object: &GlobalObject<&DictRef>,
|
global_object: &GlobalObject<&DictRef>,
|
||||||
) -> (Option<AudioDevice>, Option<Port>) {
|
) -> (Option<AudioDevice>, Option<Port>) {
|
||||||
@@ -56,11 +64,13 @@ 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 node_id = props.get("node.id").unwrap().parse::<u32>().unwrap();
|
&& let (Some(node_id), Some(port_id), Some(port_name)) = (
|
||||||
let port_id = props.get("port.id").unwrap().parse::<u32>().unwrap();
|
props.get("node.id").and_then(|id| id.parse::<u32>().ok()),
|
||||||
let port_name = props.get("port.name").unwrap();
|
props.get("port.id").and_then(|id| id.parse::<u32>().ok()),
|
||||||
|
props.get("port.name"),
|
||||||
|
)
|
||||||
|
{
|
||||||
let port = Port {
|
let port = Port {
|
||||||
node_id,
|
node_id,
|
||||||
port_id,
|
port_id,
|
||||||
@@ -76,8 +86,15 @@ fn parse_global_object(
|
|||||||
async fn pw_get_global_objects_thread(
|
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>,
|
||||||
|
init_sender: tokio::sync::oneshot::Sender<Result<(), String>>,
|
||||||
) {
|
) {
|
||||||
let main_loop = MainLoopRc::new(None).expect("Failed to initialize pipewire main loop");
|
let (main_loop, context) = match setup_pipewire_context() {
|
||||||
|
Ok(res) => res,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = init_sender.send(Err(e));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Stop main loop on Terminate message
|
// Stop main loop on Terminate message
|
||||||
let _receiver = pw_receiver.attach(main_loop.loop_(), {
|
let _receiver = pw_receiver.attach(main_loop.loop_(), {
|
||||||
@@ -85,13 +102,24 @@ 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 = match context.connect(None) {
|
||||||
let core = context
|
Ok(core) => core,
|
||||||
.connect(None)
|
Err(e) => {
|
||||||
.expect("Failed to connect to pipewire context");
|
let _ = init_sender.send(Err(format!("Failed to connect to pipewire context: {}", e)));
|
||||||
let registry = core
|
return;
|
||||||
.get_registry()
|
}
|
||||||
.expect("Failed to get registry from pipewire context");
|
};
|
||||||
|
|
||||||
|
let registry = match core.get_registry() {
|
||||||
|
Ok(registry) => registry,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = init_sender.send(Err(format!(
|
||||||
|
"Failed to get registry from pipewire context: {}",
|
||||||
|
e
|
||||||
|
)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let _listener = registry
|
let _listener = registry
|
||||||
.add_listener_local()
|
.add_listener_local()
|
||||||
@@ -107,17 +135,29 @@ async fn pw_get_global_objects_thread(
|
|||||||
})
|
})
|
||||||
.register();
|
.register();
|
||||||
|
|
||||||
|
// Signal successful initialization
|
||||||
|
if init_sender.send(Ok(())).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
main_loop.run();
|
main_loop.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>), Box<dyn Error>> {
|
pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
|
||||||
// Channels to communicate with pipewire thread
|
// Channels to communicate with pipewire thread
|
||||||
let (main_sender, mut main_receiver) = mpsc::channel(10);
|
let (main_sender, mut main_receiver) = mpsc::channel(10);
|
||||||
let (pw_sender, pw_receiver) = pipewire::channel::channel();
|
let (pw_sender, pw_receiver) = pipewire::channel::channel();
|
||||||
|
let (init_sender, init_receiver) = tokio::sync::oneshot::channel();
|
||||||
|
|
||||||
// Spawn pipewire thread in background
|
// Spawn pipewire thread in background
|
||||||
let _pw_thread =
|
let _pw_thread = tokio::spawn(async move {
|
||||||
tokio::spawn(async move { pw_get_global_objects_thread(main_sender, pw_receiver).await });
|
pw_get_global_objects_thread(main_sender, pw_receiver, init_sender).await
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for initialization to complete
|
||||||
|
if let Err(e) = init_receiver.await {
|
||||||
|
return Err(anyhow!(e));
|
||||||
|
}
|
||||||
|
|
||||||
let mut input_devices: HashMap<u32, AudioDevice> = HashMap::new();
|
let mut input_devices: HashMap<u32, AudioDevice> = HashMap::new();
|
||||||
let mut output_devices: HashMap<u32, AudioDevice> = HashMap::new();
|
let mut output_devices: HashMap<u32, AudioDevice> = HashMap::new();
|
||||||
@@ -142,15 +182,12 @@ pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>), B
|
|||||||
}
|
}
|
||||||
Ok(None) | Err(_) => {
|
Ok(None) | Err(_) => {
|
||||||
// Pipewire thread is finished and we can collect our devices
|
// Pipewire thread is finished and we can collect our devices
|
||||||
pw_sender
|
let _ = pw_sender.send(Terminate {});
|
||||||
.send(Terminate {})
|
|
||||||
.expect("Failed to terminate pipewire thread");
|
|
||||||
|
|
||||||
for port in ports {
|
for port in ports {
|
||||||
let node_id = port.node_id;
|
let node_id = port.node_id;
|
||||||
|
|
||||||
if input_devices.contains_key(&node_id) {
|
if let Some(input_device) = input_devices.get_mut(&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),
|
||||||
@@ -162,10 +199,13 @@ 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 output_devices.contains_key(&node_id) {
|
} else if let Some(output_device) = output_devices.get_mut(&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),
|
||||||
@@ -190,8 +230,8 @@ pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>), B
|
|||||||
let mut output_devices: Vec<AudioDevice> =
|
let mut output_devices: Vec<AudioDevice> =
|
||||||
output_devices.values().cloned().collect();
|
output_devices.values().cloned().collect();
|
||||||
|
|
||||||
input_devices.sort_by(|a, b| a.id.cmp(&b.id));
|
input_devices.sort_by_key(|a| a.id);
|
||||||
output_devices.sort_by(|a, b| a.id.cmp(&b.id));
|
output_devices.sort_by_key(|a| a.id);
|
||||||
|
|
||||||
return Ok((input_devices, output_devices));
|
return Ok((input_devices, output_devices));
|
||||||
}
|
}
|
||||||
@@ -199,28 +239,41 @@ pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>), B
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_device(device_name: &str) -> Result<AudioDevice, Box<dyn Error>> {
|
pub async fn get_device(device_name: &str) -> Result<AudioDevice> {
|
||||||
let (mut input_devices, output_devices) = get_all_devices().await?;
|
let (input_devices, output_devices) = get_all_devices().await?;
|
||||||
input_devices.extend(output_devices);
|
|
||||||
|
|
||||||
for device in input_devices {
|
input_devices
|
||||||
if device.name == device_name {
|
.into_iter()
|
||||||
return Ok(device);
|
.chain(output_devices)
|
||||||
}
|
.find(|device| {
|
||||||
|
device.name == device_name
|
||||||
|
|| device.nick == device_name
|
||||||
|
|| device.name.contains(device_name)
|
||||||
|
|| device.nick.contains(device_name)
|
||||||
|
})
|
||||||
|
.ok_or_else(|| anyhow!("Device not found"))
|
||||||
}
|
}
|
||||||
|
|
||||||
Err("Device not found".into())
|
pub fn create_virtual_mic() -> Result<pipewire::channel::Sender<Terminate>> {
|
||||||
}
|
|
||||||
|
|
||||||
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 (init_sender, init_receiver) = std::sync::mpsc::sync_channel(0);
|
||||||
|
|
||||||
let _pw_thread = thread::spawn(move || {
|
let _pw_thread = thread::spawn(move || {
|
||||||
let main_loop = MainLoopRc::new(None).expect("Failed to initialize pipewire main loop");
|
let (main_loop, context) = match setup_pipewire_context() {
|
||||||
let context = ContextRc::new(&main_loop, None).expect("Failed to create pipewire context");
|
Ok(res) => res,
|
||||||
let core = context
|
Err(e) => {
|
||||||
.connect(None)
|
let _ = init_sender.send(Err(e));
|
||||||
.expect("Failed to connect to pipewire context");
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let core = match context.connect(None) {
|
||||||
|
Ok(core) => core,
|
||||||
|
Err(e) => {
|
||||||
|
let _ =
|
||||||
|
init_sender.send(Err(format!("Failed to connect to pipewire context: {}", e)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let props = properties!(
|
let props = properties!(
|
||||||
"factory.name" => "support.null-audio-sink",
|
"factory.name" => "support.null-audio-sink",
|
||||||
@@ -232,9 +285,13 @@ pub fn create_virtual_mic() -> Result<pipewire::channel::Sender<Terminate>, Box<
|
|||||||
"object.linger" => "false", // Destroy the node on app exit
|
"object.linger" => "false", // Destroy the node on app exit
|
||||||
);
|
);
|
||||||
|
|
||||||
let _node = core
|
let _node = match core.create_object::<pipewire::node::Node>("adapter", &props) {
|
||||||
.create_object::<pipewire::node::Node>("adapter", &props)
|
Ok(node) => node,
|
||||||
.expect("Failed to create virtual mic");
|
Err(e) => {
|
||||||
|
let _ = init_sender.send(Err(format!("Failed to create virtual mic: {}", e)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let _receiver = pw_receiver.attach(main_loop.loop_(), {
|
let _receiver = pw_receiver.attach(main_loop.loop_(), {
|
||||||
let _main_loop = main_loop.clone();
|
let _main_loop = main_loop.clone();
|
||||||
@@ -242,26 +299,83 @@ pub fn create_virtual_mic() -> Result<pipewire::channel::Sender<Terminate>, Box<
|
|||||||
});
|
});
|
||||||
|
|
||||||
println!("Virtual mic created");
|
println!("Virtual mic created");
|
||||||
|
if init_sender.send(Ok(())).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
main_loop.run();
|
main_loop.run();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if let Err(e) = init_receiver.recv()? {
|
||||||
|
return Err(anyhow!(e));
|
||||||
|
}
|
||||||
|
|
||||||
Ok(pw_sender)
|
Ok(pw_sender)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn link_player_to_virtual_mic() -> Result<pipewire::channel::Sender<Terminate>> {
|
||||||
|
let pwsp_daemon_output = match get_device("pwsp-daemon").await {
|
||||||
|
Ok(device) => device,
|
||||||
|
Err(_) => {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"Could not find alsa_playback.pwsp-daemon device, skipping device linking"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let pwsp_daemon_input = match get_device("pwsp-virtual-mic").await {
|
||||||
|
Ok(device) => device,
|
||||||
|
Err(_) => {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"Could not find pwsp-virtual-mic device, skipping device linking"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let output_fl = match pwsp_daemon_output.output_fl {
|
||||||
|
Some(port) => port,
|
||||||
|
None => return Err(anyhow!("Failed to get pwsp-daemon output_fl")),
|
||||||
|
};
|
||||||
|
let output_fr = match pwsp_daemon_output.output_fr {
|
||||||
|
Some(port) => port,
|
||||||
|
None => return Err(anyhow!("Failed to get pwsp-daemon output_fr")),
|
||||||
|
};
|
||||||
|
let input_fl = match pwsp_daemon_input.input_fl {
|
||||||
|
Some(port) => port,
|
||||||
|
None => return Err(anyhow!("Failed to get pwsp-virtual-mic input_fl")),
|
||||||
|
};
|
||||||
|
let input_fr = match pwsp_daemon_input.input_fr {
|
||||||
|
Some(port) => port,
|
||||||
|
None => return Err(anyhow!("Failed to get pwsp-virtual-mic input_fr")),
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
input_fl: Port,
|
input_fl: Port,
|
||||||
input_fr: Port,
|
input_fr: Port,
|
||||||
) -> Result<pipewire::channel::Sender<Terminate>, Box<dyn Error>> {
|
) -> Result<pipewire::channel::Sender<Terminate>> {
|
||||||
let (pw_sender, pw_receiver) = pipewire::channel::channel::<Terminate>();
|
let (pw_sender, pw_receiver) = pipewire::channel::channel::<Terminate>();
|
||||||
|
let (init_sender, init_receiver) = std::sync::mpsc::sync_channel(0);
|
||||||
|
|
||||||
let _pw_thread = thread::spawn(move || {
|
let _pw_thread = thread::spawn(move || {
|
||||||
let main_loop = MainLoopRc::new(None).expect("Failed to initialize pipewire main loop");
|
let (main_loop, context) = match setup_pipewire_context() {
|
||||||
let context = ContextRc::new(&main_loop, None).expect("Failed to create pipewire context");
|
Ok(res) => res,
|
||||||
let core = context
|
Err(e) => {
|
||||||
.connect(None)
|
let _ = init_sender.send(Err(e));
|
||||||
.expect("Failed to connect to pipewire context");
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let core = match context.connect(None) {
|
||||||
|
Ok(core) => core,
|
||||||
|
Err(e) => {
|
||||||
|
let _ =
|
||||||
|
init_sender.send(Err(format!("Failed to connect to pipewire context: {}", e)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let props_fl = properties! {
|
let props_fl = properties! {
|
||||||
"link.output.node" => format!("{}", output_fl.node_id).as_str(),
|
"link.output.node" => format!("{}", output_fl.node_id).as_str(),
|
||||||
@@ -276,12 +390,20 @@ pub fn create_link(
|
|||||||
"link.input.port" => format!("{}", input_fr.port_id).as_str(),
|
"link.input.port" => format!("{}", input_fr.port_id).as_str(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let _link_fl = core
|
let _link_fl = match core.create_object::<Link>("link-factory", &props_fl) {
|
||||||
.create_object::<Link>("link-factory", &props_fl)
|
Ok(link) => link,
|
||||||
.expect("Failed to create link FL");
|
Err(e) => {
|
||||||
let _link_fr = core
|
let _ = init_sender.send(Err(format!("Failed to create link FL: {}", e)));
|
||||||
.create_object::<Link>("link-factory", &props_fr)
|
return;
|
||||||
.expect("Failed to create link FR");
|
}
|
||||||
|
};
|
||||||
|
let _link_fr = match core.create_object::<Link>("link-factory", &props_fr) {
|
||||||
|
Ok(link) => link,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = init_sender.send(Err(format!("Failed to create link FR: {}", e)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let _receiver = pw_receiver.attach(main_loop.loop_(), {
|
let _receiver = pw_receiver.attach(main_loop.loop_(), {
|
||||||
let _main_loop = main_loop.clone();
|
let _main_loop = main_loop.clone();
|
||||||
@@ -292,8 +414,15 @@ pub fn create_link(
|
|||||||
"Link created: FL: {}-{} FR: {}-{}",
|
"Link created: FL: {}-{} FR: {}-{}",
|
||||||
output_fl.node_id, input_fl.node_id, output_fr.node_id, input_fr.node_id
|
output_fl.node_id, input_fl.node_id, output_fr.node_id, input_fr.node_id
|
||||||
);
|
);
|
||||||
|
if init_sender.send(Ok(())).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
main_loop.run();
|
main_loop.run();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if let Err(e) = init_receiver.recv()? {
|
||||||
|
return Err(anyhow!(e));
|
||||||
|
}
|
||||||
|
|
||||||
Ok(pw_sender)
|
Ok(pw_sender)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user