mirror of
https://github.com/arabianq/pipewire-soundpad.git
synced 2026-07-27 22:24:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d0b5f9f29 | ||
|
|
425c1df61e | ||
|
|
2073a410e8 | ||
|
|
cfa31200d4 | ||
|
|
389640ee0a | ||
|
|
7b87dc02dd | ||
|
|
785b835237 | ||
|
|
1616d80ee9 | ||
|
|
d97d4c60d2 | ||
|
|
0c3d7cbd35 | ||
|
|
6d7abdbf53 | ||
|
|
41bb035b2c | ||
|
|
3873bf4270 | ||
|
|
2b60b47cce | ||
|
|
65ae8589db | ||
|
|
a33d023950 | ||
|
|
5c73bc1bea | ||
|
|
42e305869a | ||
|
|
cb7f4f7f6b | ||
|
|
62821904af | ||
|
|
3a218283db | ||
|
|
02fdfb6ca8 | ||
|
|
a027838d0e | ||
|
|
1828d903a1 | ||
|
|
83ca7a5e34 | ||
|
|
f2aca5e34a | ||
|
|
6130a99c4b | ||
|
|
322b93efa1 | ||
|
|
683f4127a0 | ||
|
|
c873166e21 | ||
|
|
ec4e873071 | ||
|
|
a91252d7fb | ||
|
|
239170df45 | ||
|
|
5fef42a2e2 | ||
|
|
4f8f663f12 | ||
|
|
6ccc55a1d0 | ||
|
|
d266a3f073 | ||
|
|
d2993592be | ||
|
|
1a53ab62dc | ||
|
|
51fcd94a61 | ||
|
|
7f6a122816 | ||
|
|
7ca01ed8cb | ||
|
|
07321afb60 | ||
|
|
8534e516ad | ||
|
|
16ce233f79 | ||
|
|
c2f4658871 | ||
|
|
701bdebc88 | ||
|
|
d4ae78c7b0 | ||
|
|
9e1d50e30c | ||
|
|
85b6c17bd1 | ||
|
|
cfe478b02d | ||
|
|
0b23b3ed51 | ||
|
|
728d14006c | ||
|
|
f8bb84bbcf | ||
|
|
0dbcc771f5 | ||
|
|
b3a786a76c | ||
|
|
972e303bff | ||
|
|
c406704ce5 | ||
|
|
fd4a35e0e1 | ||
|
|
c793d09006 | ||
|
|
e4dd6d8b56 | ||
|
|
457416779a | ||
|
|
b0ac5490cc | ||
|
|
a728671b01 | ||
|
|
08153e4a1c | ||
|
|
c4699002fd | ||
|
|
dbcbf36df0 | ||
|
|
873ed9a6db | ||
|
|
601a5ce336 | ||
|
|
62a851714e | ||
|
|
cc152de59f | ||
|
|
501ff9bd64 | ||
|
|
f83e18b800 | ||
|
|
ba920473c7 | ||
|
|
dfb5985e4a | ||
|
|
52dc0e3a09 | ||
|
|
4ce16d5301 | ||
|
|
48cda995ee | ||
|
|
7950f75c07 | ||
|
|
38ad8d5bc2 | ||
|
|
347dee713c | ||
|
|
e6e6293e20 | ||
|
|
a1e53aa536 | ||
|
|
bff50b99f1 | ||
|
|
09b86dc108 | ||
|
|
30070c439b | ||
|
|
44c3a5e3a7 | ||
|
|
9a0bc92222 | ||
|
|
3f85994f83 | ||
|
|
caf8ac19a1 | ||
|
|
f563680ceb | ||
|
|
78ca2ddf68 | ||
|
|
838fc1ce29 | ||
|
|
622cf39fa2 | ||
|
|
809b1a8490 | ||
|
|
d1a5275173 | ||
|
|
e67f174a59 | ||
|
|
6545431ac2 | ||
|
|
026ef97a72 | ||
|
|
9f833cc30b | ||
|
|
410a2c7959 | ||
|
|
c173e602ad | ||
|
|
3576c634fd | ||
|
|
5747f39ace | ||
|
|
c501033834 | ||
|
|
c0a27e0c3b | ||
|
|
3c2882ef1f | ||
|
|
36aed3f55d | ||
|
|
c48a425bb0 | ||
|
|
9a5436cd35 | ||
|
|
2ce243e896 | ||
|
|
57fb3fd7a3 | ||
|
|
82b02bf520 | ||
|
|
3d4dbbe866 | ||
|
|
70bd3a889a | ||
|
|
a7b5bdd2a9 | ||
|
|
99fef4a167 | ||
|
|
ae08f7ddc3 | ||
|
|
0060d0bdee | ||
|
|
f91a49cb70 | ||
|
|
8d513ff65b | ||
|
|
226dfd91ff | ||
|
|
344ea60fa5 | ||
|
|
8411cb3528 | ||
|
|
ad8f22a359 | ||
|
|
4ec49d822b | ||
|
|
ec2fa2a478 | ||
|
|
e91465365d | ||
|
|
0476329798 | ||
|
|
6c59137639 | ||
|
|
3693a678ea | ||
|
|
5511d23c3e | ||
|
|
818cd8b50d | ||
|
|
6f7d631e28 | ||
|
|
18904052c7 | ||
|
|
6841d8d1c3 | ||
|
|
105be87222 | ||
|
|
0f8abbc443 | ||
|
|
54011e7ff1 | ||
|
|
dac9d53cef | ||
|
|
9da3799cd3 | ||
|
|
d66369884c | ||
|
|
5e47e7d6fb | ||
|
|
695c83c9e6 | ||
|
|
798a6d1887 | ||
|
|
bb18175a30 | ||
|
|
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 |
+1
-1
@@ -1 +1 @@
|
|||||||
custom: ['https://boosty.to/arabian']
|
custom: ['https://dalink.to/arabianq', 'https://boosty.to/arabian', 'https://sponsr.ru/notultranx/']
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
|
||||||
|
- package-ecosystem: "cargo"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
groups:
|
||||||
|
egui-stack:
|
||||||
|
patterns:
|
||||||
|
- "egui*"
|
||||||
|
- "eframe"
|
||||||
|
|
||||||
|
serde-stack:
|
||||||
|
patterns:
|
||||||
|
- "serde*"
|
||||||
|
|
||||||
|
tokio-stack:
|
||||||
|
patterns:
|
||||||
|
- "tokio*"
|
||||||
|
- "reqwest"
|
||||||
|
|
||||||
|
clap-stack:
|
||||||
|
patterns:
|
||||||
|
- "clap*"
|
||||||
+172
-34
@@ -12,8 +12,16 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
linux-build:
|
build-and-package:
|
||||||
runs-on: ubuntu-latest
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- arch: x64
|
||||||
|
runner: ubuntu-latest
|
||||||
|
- arch: arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
fail-fast: false
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Install apt deps (jq/zip + dev-libs)
|
- name: Install apt deps (jq/zip + dev-libs)
|
||||||
@@ -23,43 +31,55 @@ jobs:
|
|||||||
zip jq \
|
zip jq \
|
||||||
libpipewire-0.3-dev \
|
libpipewire-0.3-dev \
|
||||||
libclang-dev \
|
libclang-dev \
|
||||||
libasound2-dev
|
libasound2-dev \
|
||||||
|
libdbus-1-dev \
|
||||||
|
libssl-dev \
|
||||||
|
pkg-config
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup Rust toolchain
|
- name: Setup Rust toolchain
|
||||||
uses: actions-rs/toolchain@v1
|
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||||
with:
|
with:
|
||||||
toolchain: 1.94.1
|
toolchain: stable
|
||||||
|
cache: false
|
||||||
|
|
||||||
|
- name: Rust Cache
|
||||||
|
uses: swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
prefix-key: "v3-${{ matrix.runner }}"
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: cargo test --locked
|
||||||
|
|
||||||
|
- name: Build all binaries
|
||||||
|
run: cargo build --locked
|
||||||
|
|
||||||
- name: Extract all binary names
|
- name: Extract all binary names
|
||||||
id: cargo-meta
|
id: cargo-meta
|
||||||
run: |
|
run: |
|
||||||
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[].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
|
||||||
|
|
||||||
- name: Build all release binaries
|
- name: Package ZIP archive
|
||||||
run: cargo build --release --locked
|
|
||||||
|
|
||||||
- name: Package all binaries into one archive
|
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
COMMIT_SHA="${{ github.sha }}"
|
COMMIT_SHA="${{ github.sha }}"
|
||||||
ARCHIVE_NAME="pwsp-${COMMIT_SHA}-linux-x64.zip"
|
ARCHIVE_NAME="pwsp-${COMMIT_SHA}-linux-${{ matrix.arch }}.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
|
||||||
FILES+=("target/release/$BIN")
|
FILES+=("target/debug/$BIN")
|
||||||
done <<< "${{ steps.cargo-meta.outputs.bin_names }}"
|
done <<< "${{ steps.cargo-meta.outputs.bin_names }}"
|
||||||
|
|
||||||
if [ "${#FILES[@]}" -eq 0 ]; then
|
if [ "${#FILES[@]}" -eq 0 ]; then
|
||||||
@@ -67,53 +87,171 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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[@]}"
|
zip -j "$ARCHIVE_NAME" "${FILES[@]}"
|
||||||
|
|
||||||
- name: Upload archive as artifact
|
- name: Upload ZIP artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: archive
|
name: archive-${{ matrix.arch }}
|
||||||
path: pwsp-*.zip
|
path: pwsp-*.zip
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
- name: Install cargo-deb and create .deb
|
- name: Cache cargo-deb
|
||||||
|
id: cache-cargo-deb
|
||||||
|
uses: actions/cache@v6
|
||||||
|
with:
|
||||||
|
path: ~/.cargo/bin/cargo-deb
|
||||||
|
key: ${{ runner.os }}-${{ runner.arch }}-cargo-deb-v1
|
||||||
|
|
||||||
|
- name: Install cargo-deb
|
||||||
|
if: steps.cache-cargo-deb.outputs.cache-hit != 'true'
|
||||||
|
run: cargo install --locked cargo-deb
|
||||||
|
|
||||||
|
- name: Create .deb package
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cargo install --locked cargo-deb
|
|
||||||
export PATH="$HOME/.cargo/bin:$PATH"
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
if [ "${{ github.event_name }}" == "push" ] && [ "${{ github.ref }}" == "refs/heads/main" ]; then
|
||||||
|
BASE_VERSION=$(grep '^version =' Cargo.toml | head -1 | cut -d'"' -f2)
|
||||||
|
GIT_REV=$(git rev-parse --short HEAD)
|
||||||
|
DATE=$(date +%Y%m%d)
|
||||||
|
DEB_VER="${BASE_VERSION}+git${DATE}.${GIT_REV}"
|
||||||
|
echo "Building nightly deb version: $DEB_VER"
|
||||||
|
cargo-deb -p pwsp-gui --no-build --no-strip --profile dev --deb-version "$DEB_VER"
|
||||||
|
else
|
||||||
|
cargo-deb -p pwsp-gui --no-build --no-strip --profile dev
|
||||||
|
fi
|
||||||
|
|
||||||
cargo-deb
|
- name: Upload .deb artifact
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
- name: Upload .deb(s) as artifacts
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
with:
|
||||||
name: deb-packages
|
name: deb-packages-${{ matrix.arch }}
|
||||||
path: target/debian/*.deb
|
path: target/debian/*.deb
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
flatpak-build:
|
deploy-nightly-apt:
|
||||||
|
needs: build-and-package
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
concurrency: gh-pages-deploy
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Setup GPG
|
||||||
|
id: gpg
|
||||||
|
uses: crazy-max/ghaction-import-gpg@v7
|
||||||
|
with:
|
||||||
|
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||||
|
passphrase: ${{ secrets.GPG_PASSPHRASE }}
|
||||||
|
|
||||||
|
- name: Download DEB artifacts
|
||||||
|
uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
pattern: deb-packages-*
|
||||||
|
path: debs
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Checkout existing gh-pages
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
ref: gh-pages
|
||||||
|
path: gh-pages-branch
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Generate Nightly APT Repository
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y reprepro
|
||||||
|
|
||||||
|
mkdir -p apt-repo
|
||||||
|
if [ -d "gh-pages-branch/apt" ]; then
|
||||||
|
cp -r gh-pages-branch/apt/* apt-repo/ || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p apt-repo/conf
|
||||||
|
cat <<EOF > apt-repo/conf/distributions
|
||||||
|
Origin: arabianq
|
||||||
|
Label: PipeWire Soundpad
|
||||||
|
Codename: stable
|
||||||
|
Architectures: amd64 arm64
|
||||||
|
Components: main
|
||||||
|
Description: APT Repository for PWSP
|
||||||
|
SignWith: ${{ steps.gpg.outputs.fingerprint }}
|
||||||
|
|
||||||
|
Origin: arabianq
|
||||||
|
Label: PipeWire Soundpad
|
||||||
|
Codename: nightly
|
||||||
|
Architectures: amd64 arm64
|
||||||
|
Components: main
|
||||||
|
Description: Nightly APT Repository for PWSP
|
||||||
|
SignWith: ${{ steps.gpg.outputs.fingerprint }}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
gpg --armor --export ${{ steps.gpg.outputs.fingerprint }} > apt-repo/pubkey.gpg
|
||||||
|
|
||||||
|
cd apt-repo
|
||||||
|
for deb in ../debs/*.deb; do
|
||||||
|
if [ -f "$deb" ]; then
|
||||||
|
echo "Adding $deb to nightly APT repository..."
|
||||||
|
reprepro includedeb nightly "$deb"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Deploy APT Repo to GitHub Pages
|
||||||
|
uses: peaceiris/actions-gh-pages@v4
|
||||||
|
with:
|
||||||
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
publish_dir: ./apt-repo
|
||||||
|
destination_dir: apt
|
||||||
|
keep_files: true
|
||||||
|
|
||||||
|
flatpak-build:
|
||||||
|
if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- arch: x86_64
|
||||||
|
runner: ubuntu-latest
|
||||||
|
- arch: aarch64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
fail-fast: false
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
container:
|
container:
|
||||||
image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-25.08
|
image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-25.08
|
||||||
options: --privileged
|
options: --privileged
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Build Flatpak
|
- name: Build Flatpak
|
||||||
uses: flatpak/flatpak-github-actions/flatpak-builder@v6
|
uses: flatpak/flatpak-github-actions/flatpak-builder@v6
|
||||||
with:
|
with:
|
||||||
bundle: ru.arabianq.pwsp.flatpak
|
bundle: ru.arabianq.pwsp_${{ matrix.arch }}.flatpak
|
||||||
manifest-path: packages/flatpak/ru.arabianq.pwsp.yaml
|
manifest-path: packages/flatpak/ru.arabianq.pwsp.yaml
|
||||||
cache: true
|
cache: true
|
||||||
branch: master
|
branch: master
|
||||||
build-bundle: true
|
build-bundle: true
|
||||||
|
arch: ${{ matrix.arch }}
|
||||||
|
|
||||||
|
trigger-copr-nightly:
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Install copr-cli
|
||||||
|
run: pip install copr-cli
|
||||||
|
|
||||||
|
- name: Trigger Copr Build
|
||||||
|
env:
|
||||||
|
COPR_CONFIG: ${{ secrets.COPR_CONFIG }}
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.config
|
||||||
|
echo "$COPR_CONFIG" > ~/.config/copr
|
||||||
|
copr-cli buildscm --nowait \
|
||||||
|
--clone-url https://github.com/arabianq/pipewire-soundpad.git \
|
||||||
|
--commit ${{ github.sha }} \
|
||||||
|
--spec packages/rpm/pwsp-git.spec \
|
||||||
|
arabianq/pwsp
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
name: Dependabot Flatpak Cargo Sources
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened]
|
||||||
|
paths:
|
||||||
|
- "Cargo.lock"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update-cargo-sources:
|
||||||
|
if: github.actor == 'dependabot[bot]'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write # Needed to push back to the branch
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
ref: ${{ github.head_ref }}
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@v7
|
||||||
|
with:
|
||||||
|
python-version: "3.x"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pip install aiohttp tomlkit
|
||||||
|
|
||||||
|
- name: Generate cargo-sources.json
|
||||||
|
run: bash ./scripts/generate-sources.sh
|
||||||
|
|
||||||
|
- name: Commit and push changes
|
||||||
|
uses: stefanzweifel/git-auto-commit-action@v7
|
||||||
|
with:
|
||||||
|
commit_message: "chore(flatpak): automatically update cargo-sources.json"
|
||||||
|
file_pattern: "packages/flatpak/cargo-sources.json"
|
||||||
+120
-22
@@ -2,9 +2,9 @@ name: Flatter
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main, master ]
|
branches: [main, master]
|
||||||
release:
|
release:
|
||||||
types: [ published ]
|
types: [published]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
tag_name:
|
tag_name:
|
||||||
@@ -21,9 +21,17 @@ on:
|
|||||||
default: "stable"
|
default: "stable"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
flatter:
|
build-flatpak:
|
||||||
name: Flatter
|
name: Build Flatpak (${{ matrix.arch }})
|
||||||
runs-on: ubuntu-latest
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- arch: x86_64
|
||||||
|
runner: ubuntu-latest
|
||||||
|
- arch: aarch64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
fail-fast: false
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
container:
|
container:
|
||||||
@@ -32,13 +40,13 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.tag_name || github.ref }}
|
ref: ${{ inputs.tag_name || github.ref }}
|
||||||
|
|
||||||
- name: Setup GPG
|
- name: Setup GPG
|
||||||
id: gpg
|
id: gpg
|
||||||
uses: crazy-max/ghaction-import-gpg@v6
|
uses: crazy-max/ghaction-import-gpg@v7
|
||||||
with:
|
with:
|
||||||
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
|
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||||
passphrase: ${{ secrets.GPG_PASSPHRASE }}
|
passphrase: ${{ secrets.GPG_PASSPHRASE }}
|
||||||
@@ -56,33 +64,123 @@ jobs:
|
|||||||
|
|
||||||
- name: Modify Manifest
|
- name: Modify Manifest
|
||||||
run: |
|
run: |
|
||||||
|
sed -i '/^branch:/d' packages/flatpak/ru.arabianq.pwsp.yaml
|
||||||
|
sed -i '/^default-branch:/d' packages/flatpak/ru.arabianq.pwsp.yaml
|
||||||
echo "branch: ${{ steps.set_branch.outputs.branch }}" >> packages/flatpak/ru.arabianq.pwsp.yaml
|
echo "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
|
echo "default-branch: ${{ steps.set_branch.outputs.branch }}" >> packages/flatpak/ru.arabianq.pwsp.yaml
|
||||||
|
|
||||||
- name: Install SDK Extensions
|
- name: Install SDK Extensions
|
||||||
run: flatpak install -y flathub org.freedesktop.Sdk.Extension.rust-stable//25.08
|
run: |
|
||||||
org.freedesktop.Sdk.Extension.llvm20//25.08
|
flatpak install -y flathub org.freedesktop.Sdk.Extension.rust-stable//25.08
|
||||||
|
flatpak install -y flathub org.freedesktop.Sdk.Extension.llvm20//25.08
|
||||||
|
|
||||||
- name: Build Flatpak
|
- name: Build Flatpak
|
||||||
uses: andyholmes/flatter@main
|
uses: andyholmes/flatter@main
|
||||||
with:
|
with:
|
||||||
files: packages/flatpak/ru.arabianq.pwsp.yaml
|
files: packages/flatpak/ru.arabianq.pwsp.yaml
|
||||||
gpg-sign: ${{ steps.gpg.outputs.fingerprint }}
|
gpg-sign: ${{ steps.gpg.outputs.fingerprint }}
|
||||||
upload-bundles: false
|
upload-bundles: true
|
||||||
upload-pages-artifact: true
|
upload-pages-artifact: false
|
||||||
|
arch: ${{ matrix.arch }}
|
||||||
|
cache: true
|
||||||
|
|
||||||
deploy:
|
prepare-repo:
|
||||||
name: Deploy to GitHub Pages
|
name: Prepare OSTree Repo
|
||||||
|
needs: build-flatpak
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: flatter
|
concurrency: gh-pages-deploy
|
||||||
|
container:
|
||||||
|
image: ghcr.io/andyholmes/flatter/freedesktop:25.08
|
||||||
|
options: --privileged
|
||||||
permissions:
|
permissions:
|
||||||
pages: write
|
contents: write # Needed to upload files to GitHub Release
|
||||||
id-token: write
|
|
||||||
environment:
|
|
||||||
name: github-pages
|
|
||||||
url: ${{ steps.deployment.outputs.page_url }}
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: GitHub Pages
|
- name: Download Flatpak Bundles
|
||||||
id: deployment
|
uses: actions/download-artifact@v8
|
||||||
uses: actions/deploy-pages@v4
|
with:
|
||||||
|
path: bundles
|
||||||
|
pattern: ru.arabianq.pwsp-*
|
||||||
|
|
||||||
|
- name: Checkout existing gh-pages
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
ref: gh-pages
|
||||||
|
path: gh-pages-branch
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Setup GPG
|
||||||
|
id: gpg
|
||||||
|
uses: crazy-max/ghaction-import-gpg@v7
|
||||||
|
with:
|
||||||
|
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||||
|
passphrase: ${{ secrets.GPG_PASSPHRASE }}
|
||||||
|
|
||||||
|
- name: Update OSTree Repo and Prepare Release Bundles
|
||||||
|
run: |
|
||||||
|
mkdir -p release-bundles
|
||||||
|
|
||||||
|
# Inherit existing OSTree repository to preserve previous branches (e.g. stable vs nightly)
|
||||||
|
# Only inherit if objects/ exists, otherwise it's a corrupted/empty repo due to Git ignoring empty folders
|
||||||
|
if [ -d "gh-pages-branch/objects" ]; then
|
||||||
|
echo "Found existing OSTree repository. Copying..."
|
||||||
|
cp -r gh-pages-branch pages-repo
|
||||||
|
# Clean up the .git folder so it doesn't interfere
|
||||||
|
rm -rf pages-repo/.git
|
||||||
|
# Recreate empty OSTree directories that Git ignored when pushing previously
|
||||||
|
mkdir -p pages-repo/refs/heads pages-repo/refs/mirrors pages-repo/refs/remotes \
|
||||||
|
pages-repo/objects pages-repo/state pages-repo/extensions pages-repo/tmp
|
||||||
|
else
|
||||||
|
echo "Initializing new OSTree repository..."
|
||||||
|
mkdir -p pages-repo
|
||||||
|
ostree init --mode=archive-z2 --repo=pages-repo
|
||||||
|
fi
|
||||||
|
|
||||||
|
shopt -s nullglob
|
||||||
|
for dir in bundles/ru.arabianq.pwsp-*; do
|
||||||
|
arch=$(basename "$dir" | sed 's/ru.arabianq.pwsp-//')
|
||||||
|
|
||||||
|
# Find the flatpak file inside the dir
|
||||||
|
bundle=$(find "$dir" -name "*.flatpak" | head -n 1)
|
||||||
|
|
||||||
|
if [ -n "$bundle" ]; then
|
||||||
|
echo "Importing $bundle for architecture $arch..."
|
||||||
|
flatpak build-import-bundle pages-repo "$bundle"
|
||||||
|
|
||||||
|
# Copy with a unique name for GitHub Release
|
||||||
|
cp "$bundle" "release-bundles/ru.arabianq.pwsp-${arch}.flatpak"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Update summary and sign the repository
|
||||||
|
flatpak build-update-repo --generate-static-deltas --gpg-sign=${{ steps.gpg.outputs.fingerprint }} pages-repo
|
||||||
|
|
||||||
|
# Export GPG public key for manual import
|
||||||
|
gpg --export ${{ steps.gpg.outputs.fingerprint }} > pages-repo/pwsp.gpg
|
||||||
|
|
||||||
|
# Generate a .flatpakrepo file for easy installation
|
||||||
|
GPG_BASE64=$(gpg --export ${{ steps.gpg.outputs.fingerprint }} | base64 -w 0)
|
||||||
|
cat <<EOF | sed 's/^ *//' > pages-repo/pwsp.flatpakrepo
|
||||||
|
[Flatpak Repo]
|
||||||
|
Title=PipeWire Soundpad
|
||||||
|
Url=https://arabianq.github.io/pipewire-soundpad/
|
||||||
|
Homepage=https://github.com/arabianq/pipewire-soundpad
|
||||||
|
Comment=PipeWire Soundpad Flatpak Repository
|
||||||
|
Description=Official repository for PWSP
|
||||||
|
GPGKey=${GPG_BASE64}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Deploy Flatpak Repo to GitHub Pages
|
||||||
|
uses: peaceiris/actions-gh-pages@v4
|
||||||
|
with:
|
||||||
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
publish_dir: ./pages-repo
|
||||||
|
destination_dir: .
|
||||||
|
keep_files: true
|
||||||
|
|
||||||
|
- name: Upload bundles to GitHub Release
|
||||||
|
if: github.event_name == 'release'
|
||||||
|
uses: softprops/action-gh-release@v3
|
||||||
|
with:
|
||||||
|
files: release-bundles/*.flatpak
|
||||||
|
tag_name: ${{ github.event.release.tag_name }}
|
||||||
|
|||||||
+171
-75
@@ -23,76 +23,79 @@ jobs:
|
|||||||
id: tag
|
id: tag
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
INPUT_TAG="${{ github.event.inputs.tag || '' }}"
|
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 || '' }}"
|
EVENT_TAG="${{ github.event.release.tag_name || '' }}"
|
||||||
if [ -n "$EVENT_TAG" ]; then
|
|
||||||
echo "Using event tag: $EVENT_TAG"
|
if [ -n "$INPUT_TAG" ]; then
|
||||||
|
echo "tag=$INPUT_TAG" >> $GITHUB_OUTPUT
|
||||||
|
elif [ -n "$EVENT_TAG" ]; then
|
||||||
echo "tag=$EVENT_TAG" >> $GITHUB_OUTPUT
|
echo "tag=$EVENT_TAG" >> $GITHUB_OUTPUT
|
||||||
exit 0
|
elif [[ "${GITHUB_REF:-}" =~ ^refs/tags/(.+)$ ]]; then
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "${GITHUB_REF:-}" =~ ^refs/tags/(.+)$ ]]; then
|
|
||||||
echo "Using GITHUB_REF tag: ${BASH_REMATCH[1]}"
|
|
||||||
echo "tag=${BASH_REMATCH[1]}" >> $GITHUB_OUTPUT
|
echo "tag=${BASH_REMATCH[1]}" >> $GITHUB_OUTPUT
|
||||||
exit 0
|
else
|
||||||
fi
|
|
||||||
|
|
||||||
echo "No tag in input/event/GITHUB_REF — querying latest release via API..."
|
|
||||||
LATEST_JSON=$(curl -sSf -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" -H "Accept: application/vnd.github+json" "https://api.github.com/repos/${{ github.repository }}/releases/latest" || true)
|
LATEST_JSON=$(curl -sSf -H "Authorization: Bearer ${{ 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 "tag=$TAG_NAME" >> $GITHUB_OUTPUT
|
echo "tag=$TAG_NAME" >> $GITHUB_OUTPUT
|
||||||
exit 0
|
else
|
||||||
fi
|
|
||||||
|
|
||||||
echo "No tag found"
|
|
||||||
echo "tag=" >> $GITHUB_OUTPUT
|
echo "tag=" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Fail if no tag determined
|
- name: Fail if no tag determined
|
||||||
if: ${{ steps.tag.outputs.tag == '' }}
|
if: ${{ steps.tag.outputs.tag == '' }}
|
||||||
run: |
|
run: |
|
||||||
echo "ERROR: No tag determined. Provide a tag when running manually or ensure a release exists."
|
echo "ERROR: No tag determined."
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
linux-release:
|
build-release:
|
||||||
needs: prepare
|
needs: prepare
|
||||||
runs-on: ubuntu-latest
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- arch: x64
|
||||||
|
runner: ubuntu-latest
|
||||||
|
- arch: arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
fail-fast: false
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Install apt deps (jq/zip + dev-libs)
|
- name: Install apt deps
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y \
|
sudo apt-get install -y \
|
||||||
zip jq \
|
zip jq \
|
||||||
libpipewire-0.3-dev \
|
libpipewire-0.3-dev \
|
||||||
libclang-dev \
|
libclang-dev \
|
||||||
libasound2-dev
|
libasound2-dev \
|
||||||
|
libdbus-1-dev \
|
||||||
|
libssl-dev \
|
||||||
|
pkg-config
|
||||||
|
|
||||||
- name: Checkout code at tag
|
- name: Checkout code at tag
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
ref: ${{ needs.prepare.outputs.tag }}
|
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-rust-lang/setup-rust-toolchain@v1
|
||||||
with:
|
with:
|
||||||
toolchain: 1.94.1
|
toolchain: stable
|
||||||
|
cache: false
|
||||||
|
|
||||||
|
- name: Rust Cache
|
||||||
|
uses: swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
prefix-key: "v3-${{ matrix.runner }}"
|
||||||
|
|
||||||
- name: Extract all binary names
|
- name: Extract all binary names
|
||||||
id: cargo-meta
|
id: cargo-meta
|
||||||
run: |
|
run: |
|
||||||
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[].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
|
||||||
@@ -100,13 +103,12 @@ jobs:
|
|||||||
- name: Build all release binaries
|
- name: Build all release binaries
|
||||||
run: cargo build --release --locked
|
run: cargo build --release --locked
|
||||||
|
|
||||||
- name: Package all binaries into one archive
|
- name: Package ZIP archive
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
TAG="${{ needs.prepare.outputs.tag }}"
|
TAG="${{ needs.prepare.outputs.tag }}"
|
||||||
ARCHIVE_NAME="pwsp-${TAG}-linux-x64.zip"
|
ARCHIVE_NAME="pwsp-${TAG}-linux-${{ matrix.arch }}.zip"
|
||||||
echo "Creating archive: $ARCHIVE_NAME"
|
|
||||||
|
|
||||||
FILES=()
|
FILES=()
|
||||||
while IFS= read -r BIN; do
|
while IFS= read -r BIN; do
|
||||||
@@ -114,63 +116,157 @@ jobs:
|
|||||||
FILES+=("target/release/$BIN")
|
FILES+=("target/release/$BIN")
|
||||||
done <<< "${{ steps.cargo-meta.outputs.bin_names }}"
|
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[@]}"
|
zip -j "$ARCHIVE_NAME" "${FILES[@]}"
|
||||||
|
|
||||||
- name: Upload release archive
|
- name: Upload ZIP artifact
|
||||||
uses: softprops/action-gh-release@v2
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
name: zip-archive-${{ matrix.arch }}
|
||||||
tag_name: ${{ needs.prepare.outputs.tag }}
|
path: pwsp-*.zip
|
||||||
files: |
|
retention-days: 1
|
||||||
pwsp-*.zip
|
|
||||||
|
|
||||||
- name: Install cargo-deb and create .deb
|
- name: Cache cargo-deb
|
||||||
|
id: cache-cargo-deb
|
||||||
|
uses: actions/cache@v6
|
||||||
|
with:
|
||||||
|
path: ~/.cargo/bin/cargo-deb
|
||||||
|
key: ${{ runner.os }}-${{ runner.arch }}-cargo-deb-v1
|
||||||
|
|
||||||
|
- name: Install cargo-deb
|
||||||
|
if: steps.cache-cargo-deb.outputs.cache-hit != 'true'
|
||||||
|
run: cargo install --locked cargo-deb
|
||||||
|
|
||||||
|
- name: Create .deb package
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cargo install --locked cargo-deb
|
|
||||||
export PATH="$HOME/.cargo/bin:$PATH"
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
cargo-deb -p pwsp-gui --no-build
|
||||||
|
|
||||||
cargo-deb
|
- name: Upload DEB artifact
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: deb-package-${{ matrix.arch }}
|
||||||
|
path: target/debian/*.deb
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
- name: Upload .deb(s) to release
|
publish-release:
|
||||||
uses: softprops/action-gh-release@v2
|
needs: [prepare, build-release]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Download all artifacts
|
||||||
|
uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
path: ./dist
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Upload artifacts to Release
|
||||||
|
uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
tag_name: ${{ needs.prepare.outputs.tag }}
|
tag_name: ${{ needs.prepare.outputs.tag }}
|
||||||
files: |
|
files: |
|
||||||
target/debian/*.deb
|
./dist/pwsp-*.zip
|
||||||
|
./dist/*.deb
|
||||||
|
|
||||||
flatpak-release:
|
trigger-copr:
|
||||||
needs: prepare
|
needs: prepare
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
|
||||||
image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-25.08
|
|
||||||
options: --privileged
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Install copr-cli
|
||||||
with:
|
run: pip install copr-cli
|
||||||
ref: ${{ needs.prepare.outputs.tag }}
|
|
||||||
|
|
||||||
- name: Build Flatpak
|
- name: Trigger Copr Build
|
||||||
uses: flatpak/flatpak-github-actions/flatpak-builder@v6
|
env:
|
||||||
|
COPR_CONFIG: ${{ secrets.COPR_CONFIG }}
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.config
|
||||||
|
echo "$COPR_CONFIG" > ~/.config/copr
|
||||||
|
copr-cli buildscm --nowait \
|
||||||
|
--clone-url https://github.com/arabianq/pipewire-soundpad.git \
|
||||||
|
--commit ${{ needs.prepare.outputs.tag }} \
|
||||||
|
--spec packages/rpm/pwsp.spec \
|
||||||
|
arabianq/pwsp
|
||||||
|
|
||||||
|
build-apt-repo:
|
||||||
|
needs: [prepare, build-release]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
concurrency: gh-pages-deploy
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Setup GPG
|
||||||
|
id: gpg
|
||||||
|
uses: crazy-max/ghaction-import-gpg@v7
|
||||||
with:
|
with:
|
||||||
bundle: ru.arabianq.pwsp.flatpak
|
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||||
manifest-path: packages/flatpak/ru.arabianq.pwsp.yaml
|
passphrase: ${{ secrets.GPG_PASSPHRASE }}
|
||||||
cache: true
|
|
||||||
branch: master
|
- name: Download DEB artifacts
|
||||||
build-bundle: true
|
uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
pattern: deb-package-*
|
||||||
|
path: debs
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Checkout existing gh-pages
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
ref: gh-pages
|
||||||
|
path: gh-pages-branch
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Generate APT Repository
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y reprepro
|
||||||
|
|
||||||
|
mkdir -p apt-repo
|
||||||
|
|
||||||
|
# Restore existing repository first so we don't lose old packages
|
||||||
|
if [ -d "gh-pages-branch/apt" ]; then
|
||||||
|
cp -r gh-pages-branch/apt/* apt-repo/ || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Always rewrite the config and public key to ensure they are up-to-date
|
||||||
|
mkdir -p apt-repo/conf
|
||||||
|
|
||||||
|
cat <<EOF > apt-repo/conf/distributions
|
||||||
|
Origin: arabianq
|
||||||
|
Label: PipeWire Soundpad
|
||||||
|
Codename: stable
|
||||||
|
Architectures: amd64 arm64
|
||||||
|
Components: main
|
||||||
|
Description: APT Repository for PWSP
|
||||||
|
SignWith: ${{ steps.gpg.outputs.fingerprint }}
|
||||||
|
|
||||||
|
Origin: arabianq
|
||||||
|
Label: PipeWire Soundpad
|
||||||
|
Codename: nightly
|
||||||
|
Architectures: amd64 arm64
|
||||||
|
Components: main
|
||||||
|
Description: Nightly APT Repository for PWSP
|
||||||
|
SignWith: ${{ steps.gpg.outputs.fingerprint }}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
gpg --armor --export ${{ steps.gpg.outputs.fingerprint }} > apt-repo/pubkey.gpg
|
||||||
|
|
||||||
|
cd apt-repo
|
||||||
|
|
||||||
|
for deb in ../debs/*.deb; do
|
||||||
|
if [ -f "$deb" ]; then
|
||||||
|
echo "Adding $deb to APT repository..."
|
||||||
|
reprepro includedeb stable "$deb"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Deploy APT Repo to GitHub Pages
|
||||||
|
uses: peaceiris/actions-gh-pages@v4
|
||||||
|
with:
|
||||||
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
publish_dir: ./apt-repo
|
||||||
|
destination_dir: apt
|
||||||
|
keep_files: true
|
||||||
|
|||||||
Generated
+1305
-881
File diff suppressed because it is too large
Load Diff
+44
-63
@@ -1,18 +1,29 @@
|
|||||||
[package]
|
[workspace]
|
||||||
name = "pwsp"
|
members = [
|
||||||
version = "1.7.6"
|
"pwsp-lib",
|
||||||
|
"pwsp-daemon",
|
||||||
|
"pwsp-cli",
|
||||||
|
"pwsp-gui"
|
||||||
|
]
|
||||||
|
resolver = "2"
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "1.15.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
authors = ["arabian"]
|
authors = ["arabian"]
|
||||||
description = "PWSP lets you play audio files through your microphone. Has both CLI and GUI clients."
|
|
||||||
readme = "README.md"
|
|
||||||
homepage = "https://pwsp.arabianq.ru"
|
homepage = "https://pwsp.arabianq.ru"
|
||||||
repository = "https://github.com/arabianq/pipewire-soundpad"
|
repository = "https://github.com/arabianq/pipewire-soundpad"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
description = "PWSP lets you play audio files through your microphone. Has both CLI and GUI clients."
|
||||||
keywords = ["soundpad", "pipewire", "linux", "cli", "gui"]
|
keywords = ["soundpad", "pipewire", "linux", "cli", "gui"]
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
pwsp-lib = { path = "pwsp-lib" }
|
||||||
|
pwsp-daemon = { path = "pwsp-daemon" }
|
||||||
|
pwsp-cli = { path = "pwsp-cli" }
|
||||||
|
pwsp-gui = { path = "pwsp-gui" }
|
||||||
|
|
||||||
[dependencies]
|
tokio = { version = "1.52.3", features = ["full"] }
|
||||||
tokio = { version = "1.52.1", features = ["full"] }
|
|
||||||
async-trait = "0.1.89"
|
async-trait = "0.1.89"
|
||||||
|
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
@@ -26,46 +37,50 @@ clap = { version = "4.6.1", default-features = false, features = [
|
|||||||
"error-context",
|
"error-context",
|
||||||
"derive",
|
"derive",
|
||||||
] }
|
] }
|
||||||
dirs = "6.0.0"
|
|
||||||
itertools = "0.14.0"
|
|
||||||
|
|
||||||
rodio = { git = "https://github.com/RustAudio/rodio.git", rev = "57ad9d8a9f30398f634fbf8e4e1d53dde7243c21", default-features = false, features = [
|
dirs = "6.0.0"
|
||||||
"symphonia-all",
|
itertools = "0.15.0"
|
||||||
"symphonia-libopus",
|
|
||||||
"playback",
|
|
||||||
] }
|
|
||||||
pipewire = "0.9.2"
|
|
||||||
evdev = { version = "0.13.2", features = ["tokio"] }
|
evdev = { version = "0.13.2", features = ["tokio"] }
|
||||||
rfd = { version = "0.17.2", default-features = false, features = [
|
rfd = { version = "0.17.2", default-features = false, features = [
|
||||||
"xdg-portal",
|
"xdg-portal",
|
||||||
] }
|
] }
|
||||||
opener = { version = "0.8.4", features = ["reveal"] }
|
opener = { version = "0.8.4", features = ["reveal"] }
|
||||||
|
system-fonts = "0.1.1"
|
||||||
|
anyhow = "1.0.102"
|
||||||
|
rustix = { version = "1.1.4", features = ["process"] }
|
||||||
|
|
||||||
egui = { version = "0.34.1", default-features = false, features = [
|
rust-i18n = "4.0.0"
|
||||||
|
sys-locale = "0.3.2"
|
||||||
|
|
||||||
|
rodio = { git = "https://github.com/RustAudio/rodio.git", rev = "c5f1e94290922fe4ee963ce6f85754ea6ba36243", default-features = false, features = [
|
||||||
|
"symphonia-all",
|
||||||
|
"symphonia-libopus",
|
||||||
|
"playback",
|
||||||
|
] }
|
||||||
|
pipewire = "0.10.0"
|
||||||
|
|
||||||
|
egui = { version = "0.35.0", default-features = false, features = [
|
||||||
"default_fonts",
|
"default_fonts",
|
||||||
"rayon",
|
"rayon",
|
||||||
] }
|
] }
|
||||||
eframe = { version = "0.34.1", default-features = false, features = [
|
eframe = { version = "0.35.0", default-features = false, features = [
|
||||||
"default_fonts",
|
"default_fonts",
|
||||||
"glow",
|
"glow",
|
||||||
"x11",
|
"x11",
|
||||||
"wayland",
|
"wayland",
|
||||||
] }
|
] }
|
||||||
egui_extras = "0.34.1"
|
egui_extras = "0.35.0"
|
||||||
egui_material_icons = "0.6.0"
|
egui_material_icons = "0.7.0"
|
||||||
egui_dnd = "0.15.0"
|
egui_dnd = "0.16.0"
|
||||||
|
|
||||||
[[bin]]
|
reqwest = { version = "0.13.4", default-features = false, features = ["native-tls"] }
|
||||||
name = "pwsp-daemon"
|
percent-encoding = "2.3.2"
|
||||||
path = "src/bin/daemon.rs"
|
|
||||||
|
|
||||||
[[bin]]
|
[profile.dev]
|
||||||
name = "pwsp-cli"
|
|
||||||
path = "src/bin/cli.rs"
|
|
||||||
|
|
||||||
[[bin]]
|
[profile.dev.package."*"]
|
||||||
name = "pwsp-gui"
|
opt-level = 3
|
||||||
path = "src/main.rs"
|
debug = false
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
strip = true
|
strip = true
|
||||||
@@ -73,37 +88,3 @@ lto = true
|
|||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
opt-level = "z"
|
opt-level = "z"
|
||||||
panic = "abort"
|
panic = "abort"
|
||||||
|
|
||||||
[package.metadata.deb]
|
|
||||||
assets = [
|
|
||||||
[
|
|
||||||
"target/release/pwsp-daemon",
|
|
||||||
"usr/bin/",
|
|
||||||
"755",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"target/release/pwsp-cli",
|
|
||||||
"usr/bin/",
|
|
||||||
"755",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"target/release/pwsp-gui",
|
|
||||||
"usr/bin/",
|
|
||||||
"755",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"assets/pwsp-gui.desktop",
|
|
||||||
"usr/share/applications/pwsp.desktop",
|
|
||||||
"644",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"assets/icon.png",
|
|
||||||
"usr/share/icons/hicolor/256x256/apps/pwsp.png",
|
|
||||||
"644",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"assets/pwsp-daemon.service",
|
|
||||||
"usr/lib/systemd/user/pwsp-daemon.service",
|
|
||||||
"644",
|
|
||||||
],
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -1,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,237 +1,219 @@
|
|||||||
# **🎵 Pipewire Soundpad (PWSP)**
|
# PipeWire Soundpad (PWSP) 🎵
|
||||||
|
|
||||||
**PipeWire Soundpad (PWSP)** is a simple yet powerful **soundboard application** written in **Rust**. It provides a
|
<table border="0">
|
||||||
user-friendly graphical interface for **managing and playing audio files, directing their output directly to the virtual
|
<tr>
|
||||||
microphone.** This makes it an ideal tool for gamers, streamers, and anyone looking to inject sound effects into voice
|
<td colspan="2" align="center">
|
||||||
chats on platforms like **Discord, Zoom, or Teamspeak**.
|
<h3>Main Screen</h3>
|
||||||
|
<img src="./pwsp-gui/assets/screenshots/main.png" alt="Main UI">
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td width="50%" align="center">
|
||||||
|
<h3>Settings</h3>
|
||||||
|
<img src="./pwsp-gui/assets/screenshots/settings.png" alt="Settings">
|
||||||
|
</td>
|
||||||
|
<td width="50%" align="center">
|
||||||
|
<h3>Hotkeys</h3>
|
||||||
|
<img src="./pwsp-gui/assets/screenshots/hotkeys.png" alt="Hotkeys">
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||

|
[🇷🇺 Читать на русском](README.ru.md) | 🇬🇧 English (you are here)
|
||||||
|
|
||||||
# **🌟 Key Features**
|
[](https://github.com/arabianq/pipewire-soundpad/actions)
|
||||||
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
[](https://github.com/arabianq/pipewire-soundpad/releases/latest)
|
||||||
|
[](https://pipewire.org/)
|
||||||
|
|
||||||
* **Multi-Format Support**: Play audio files in popular formats, including _**mp3**_, _**wav**_, _**ogg**_, _**flac**_,
|
**PipeWire Soundpad (PWSP)** is a modern application that lets you play audio files directly through your microphone. Designed specifically for Linux, it talks to PipeWire natively: it creates its own virtual microphone and wires everything up itself, so you never have to build a routing setup by hand.
|
||||||
_**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.
|
|
||||||
* **Collapsible Audio Tracks**: You can collapse every audio track to save space.
|
|
||||||
* **Drag and Drop Directories**: Reorder your sound directories easily using drag and drop.
|
|
||||||
* **Automatic Device Detection**: PWSP automatically detects when an input device is connected or disconnected and handles linking/unlinking.
|
|
||||||
* **Global Hotkeys**: Assign custom keyboard shortcuts to any sound file (or action) to trigger playback instantly, even when the application is not in focus.
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# **⚙️ How It Works**
|
## ✨ Features
|
||||||
|
|
||||||
PWSP is designed with a clear separation of concerns, operating through a client-server architecture. It consists of
|
- **Native PipeWire Integration:** PWSP creates its own virtual microphone and manages the graph through the PipeWire API — no manual `pw-link` juggling, no PulseAudio modules to load.
|
||||||
three main components:
|
- **Separate Monitoring and Microphone Volumes:** Two independent streams, so you can amplify what your friends hear without deafening yourself, and pick which device the monitoring plays on.
|
||||||
|
- **Modular Architecture:** Consists of a background `daemon`, a command-line interface (`cli`), and a graphical user interface (`gui`).
|
||||||
|
- **Modern GUI:** Built with `egui`, supporting both Wayland and X11 seamlessly.
|
||||||
|
- **Localized:** Ships in 9 languages, following your system locale or one you pick yourself in the settings.
|
||||||
|
- **Global Hotkeys:** Powered by `evdev`, allowing you to play sounds from anywhere.
|
||||||
|
- **Broad Audio Support:** Powered by `rodio` and `symphonia` to support a wide range of audio formats, including Opus.
|
||||||
|
|
||||||
* **pwsp-daemon**: This is the core of the application. It runs silently in the background, managing all the
|
---
|
||||||
heavy-lifting tasks. The daemon is responsible for:
|
|
||||||
* Creating and managing virtual audio devices.
|
|
||||||
* Linking these devices within the PipeWire graph.
|
|
||||||
* Handling all audio playback.
|
|
||||||
* **UnixSocket**. This is how you interact with your sound collection, control playback, and configure settings.
|
|
||||||
* **pwsp-gui**: This is the graphical user interface. It acts as a client that communicates with pwsp-daemon via a
|
|
||||||
* **pwsp-cli**: This is the command-line interface, also acting as a client. It provides a way to control the daemon
|
|
||||||
without a GUI, allowing for scripting or quick command-based actions.
|
|
||||||
|
|
||||||
# **🚀 Installation**
|
## 🚀 Installation
|
||||||
|
|
||||||
## **Pre-built Packages**
|
We provide multiple ways to install PWSP, including stable releases and rolling "nightly" builds directly from the `main` branch.
|
||||||
|
|
||||||
You can download pre-built binaries and .deb packages from
|
### 📦 Flatpak (Recommended)
|
||||||
the [releases page](https://github.com/arabianq/pipewire-soundpad/releases).
|
|
||||||
|
|
||||||
## **Flatpak**
|
Add our official OSTree repository and install the application:
|
||||||
|
|
||||||
You can install PWSP via Flatpak from our custom repository hosted on GitHub Pages.
|
|
||||||
|
|
||||||
Add the repository:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
flatpak remote-add --user --if-not-exists arabianq-repo https://arabianq.github.io/pipewire-soundpad/index.flatpakrepo
|
# Add the repository
|
||||||
|
flatpak remote-add --if-not-exists pwsp https://arabianq.github.io/pipewire-soundpad/pwsp.flatpakrepo
|
||||||
|
|
||||||
|
# Install the Stable version
|
||||||
|
flatpak install pwsp ru.arabianq.pwsp//stable
|
||||||
|
|
||||||
|
# OR Install the Nightly version (rolling updates)
|
||||||
|
flatpak install pwsp ru.arabianq.pwsp//nightly
|
||||||
```
|
```
|
||||||
|
|
||||||
Install the stable version:
|
### 🟠 Debian / Ubuntu (APT Repository)
|
||||||
|
|
||||||
|
We maintain an official APT repository for seamless updates via `apt`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
flatpak install --user arabianq-repo ru.arabianq.pwsp//stable
|
# 1. Download the public GPG key
|
||||||
|
sudo mkdir -p /etc/apt/keyrings
|
||||||
|
wget -O- https://arabianq.github.io/pipewire-soundpad/apt/pubkey.gpg | sudo gpg --dearmor -o /etc/apt/keyrings/pwsp.gpg
|
||||||
|
|
||||||
|
# 2. Add the repository (Choose STABLE or NIGHTLY)
|
||||||
|
# For Stable:
|
||||||
|
echo "deb [signed-by=/etc/apt/keyrings/pwsp.gpg] https://arabianq.github.io/pipewire-soundpad/apt/ stable main" | sudo tee /etc/apt/sources.list.d/pwsp.list
|
||||||
|
|
||||||
|
# For Nightly:
|
||||||
|
# echo "deb [signed-by=/etc/apt/keyrings/pwsp.gpg] https://arabianq.github.io/pipewire-soundpad/apt/ nightly main" | sudo tee /etc/apt/sources.list.d/pwsp.list
|
||||||
|
|
||||||
|
# 3. Update and install
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install pwsp
|
||||||
```
|
```
|
||||||
|
|
||||||
Or install the nightly version (latest commit to `main`):
|
### 🐧 Fedora / RHEL (COPR)
|
||||||
|
|
||||||
```bash
|
Available via the Fedora COPR repository:
|
||||||
flatpak install --user arabianq-repo ru.arabianq.pwsp//nightly
|
|
||||||
```
|
|
||||||
|
|
||||||
## **Fedora Linux (and derivatives)**
|
|
||||||
|
|
||||||
If you're using Fedora, you can install PWSP from a dedicated repository using DNF.
|
|
||||||
|
|
||||||
Add the repository:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo dnf copr enable arabianq/pwsp
|
sudo dnf copr enable arabianq/pwsp
|
||||||
```
|
|
||||||
|
|
||||||
Update cache:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo dnf makecache
|
|
||||||
```
|
|
||||||
|
|
||||||
Install PWSP:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo dnf install pwsp
|
sudo dnf install pwsp
|
||||||
```
|
```
|
||||||
|
|
||||||
## **Arch Linux**
|
### 🐦 Arch Linux (AUR)
|
||||||
There is pwsp package in AUR.
|
|
||||||
You can install it using yay, paru or any other AUR helper.
|
|
||||||
```bash
|
|
||||||
paru pwsp-bin # or paru pwsp to build it locally
|
|
||||||
```
|
|
||||||
|
|
||||||
## **Installing using cargo**
|
Two packages are available: `pwsp` builds from source, `pwsp-bin` installs the prebuilt binaries.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo install pwsp
|
# Using your preferred AUR helper
|
||||||
|
paru -S pwsp # or: pwsp-bin
|
||||||
```
|
```
|
||||||
|
|
||||||
## **Building from source**
|
### ⚙️ Manual / Standalone
|
||||||
|
|
||||||
#### **Requirements**
|
You can manually download `.deb` packages or standalone `.zip` binaries from the [Releases page](https://github.com/arabianq/pipewire-soundpad/releases).
|
||||||
|
|
||||||
* **Rust**: Install [Rust](https://www.rust-lang.org/tools/install) (using rustup is recommended).
|
### 🦀 Build from Source
|
||||||
* **PipeWire**: Ensure that [PipeWire](https://pipewire.org/) is installed and running on your system.
|
|
||||||
|
|
||||||
#### **Build Instructions**
|
Make sure you have Rust and Cargo installed, along with the build dependencies. On Debian/Ubuntu:
|
||||||
|
|
||||||
Clone the repository:
|
```bash
|
||||||
|
sudo apt install libpipewire-0.3-dev libclang-dev libasound2-dev libdbus-1-dev libssl-dev pkg-config
|
||||||
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/arabianq/pipewire-soundpad.git
|
git clone https://github.com/arabianq/pipewire-soundpad.git
|
||||||
cd pipewire-soundpad
|
cd pipewire-soundpad
|
||||||
|
cargo build --release --locked
|
||||||
```
|
```
|
||||||
|
|
||||||
Build the project:
|
The binaries will be located in `target/release/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎮 Usage
|
||||||
|
|
||||||
|
### 1. Start the Daemon
|
||||||
|
|
||||||
|
PWSP operates via a background daemon that handles the audio routing.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo build --release
|
# Run the daemon
|
||||||
|
pwsp-daemon
|
||||||
```
|
```
|
||||||
|
|
||||||
Now you have three binary files inside ./target/release/:
|
_(Tip: If installed via package managers, a systemd user service is provided. You can enable it with `systemctl --user enable --now pwsp-daemon.service`)_
|
||||||
|
|
||||||
- **pwsp-gui**
|
### 2. Launch the GUI
|
||||||
- **pwsp-cli**
|
|
||||||
- **pwsp-daemon**
|
|
||||||
|
|
||||||
# **🎮 Usage**
|
Simply run the graphical interface to manage and play your sounds:
|
||||||
|
|
||||||
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 &
|
pwsp-gui
|
||||||
```
|
```
|
||||||
|
|
||||||
* **Using systemd (recommended):**
|
### 3. Use the CLI
|
||||||
If you installed PWSP using prebuilt packages, the systemd service is added automatically.
|
|
||||||
1. **Start the service:**
|
|
||||||
```bash
|
|
||||||
systemctl --user start pwsp-daemon
|
|
||||||
```
|
|
||||||
2. **Enable autostart (starts on login):**
|
|
||||||
```bash
|
|
||||||
systemctl --user enable --now pwsp-daemon
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Using the GUI**
|
You can also interact with the daemon directly via the command line. Every command is grouped under `action`, `get` or `set`:
|
||||||
|
|
||||||
1. **Add Sounds**: Click the **"+"** button and select a folder containing your audio files. The application
|
|
||||||
will automatically list all supported files.
|
|
||||||
2. **Select Microphone**: In the main application window, select your 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. You can also play single file once using **"Play File"** button.
|
|
||||||
|
|
||||||
### **Using the CLI**
|
|
||||||
|
|
||||||
The pwsp-cli tool allows you to control the daemon from the command line.
|
|
||||||
|
|
||||||
* **General Help**: To see a list of all available commands, run:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pwsp-cli --help
|
pwsp-cli action play /path/to/sound.mp3
|
||||||
|
pwsp-cli action stop
|
||||||
|
pwsp-cli get state
|
||||||
|
|
||||||
|
# Separate volumes for what you hear and what is sent to the microphone.
|
||||||
|
# Values above 1.0 amplify.
|
||||||
|
pwsp-cli set monitoring-volume 0.3
|
||||||
|
pwsp-cli set mic-volume 1.5
|
||||||
|
|
||||||
|
# Devices
|
||||||
|
pwsp-cli get inputs
|
||||||
|
pwsp-cli set input <name>
|
||||||
```
|
```
|
||||||
|
|
||||||
* **Example Commands**:
|
Run `pwsp-cli <group> --help` for the full list.
|
||||||
* **Play a file**:
|
|
||||||
|
|
||||||
```bash
|
### 🔑 Enabling Global Hotkeys
|
||||||
pwsp-cli action play <file_path>
|
|
||||||
```
|
|
||||||
|
|
||||||
* **Get the current volume**:
|
Hotkeys are read straight from the kernel via `evdev`, which means the daemon needs access to `/dev/input/event*`. On most distributions that means adding yourself to the `input` group:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pwsp-cli get volume
|
sudo usermod -aG input $USER
|
||||||
```
|
```
|
||||||
|
|
||||||
* **Set playback position to 20 seconds**:
|
Log out and back in for it to take effect. Without this, everything else works — only the global hotkeys stay silent.
|
||||||
|
|
||||||
```bash
|
### 📦 A Note for Flatpak Users
|
||||||
pwsp-cli set position 20
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Hotkeys & Controls**
|
The Flatpak ships a single entry point, so the commands above are reached through `flatpak run`:
|
||||||
|
|
||||||
#### **Keyboard Shortcuts**
|
```bash
|
||||||
|
# No arguments: starts the daemon and the GUI together
|
||||||
|
flatpak run ru.arabianq.pwsp
|
||||||
|
|
||||||
| Key | Action |
|
# The daemon on its own
|
||||||
| :----------------------- | :--------------------------------------------------- |
|
flatpak run ru.arabianq.pwsp daemon --start
|
||||||
| **Space** | Pause / Resume audio |
|
flatpak run ru.arabianq.pwsp daemon --kill
|
||||||
| **Backspace** | Stop all audio tracks |
|
|
||||||
| **Enter** | Play selected file (stops all other tracks) |
|
|
||||||
| **Ctrl + Enter** | Add selected file to playback (plays simultaneously) |
|
|
||||||
| **Shift + Enter** | Replace the last added track with the selected one |
|
|
||||||
| **I** | Open / Close settings |
|
|
||||||
| **/** | Focus search field |
|
|
||||||
| **Ctrl + ↑ / ↓** | Navigate through files |
|
|
||||||
| **Ctrl + Shift + ↑ / ↓** | Navigate through directories |
|
|
||||||
|
|
||||||
#### **Mouse Controls**
|
# Anything after "cli" is passed to pwsp-cli
|
||||||
|
flatpak run ru.arabianq.pwsp cli action play /path/to/sound.mp3
|
||||||
|
```
|
||||||
|
|
||||||
* **Left Click**: Play track (stops all other tracks).
|
---
|
||||||
* **Ctrl + Left Click**: Add track (plays simultaneously with current tracks).
|
|
||||||
* **Shift + Left Click**: Replace the last added track with the selected one.
|
|
||||||
|
|
||||||
# **🤝 Contributing**
|
## 📚 Documentation & DeepWiki
|
||||||
|
|
||||||
Contributions are welcome\! If you have ideas for improvements or find a bug, feel free to create
|
DeepWiki generates a browsable overview of the architecture from the source, and lets you ask questions about it. It is generated automatically rather than written by hand, so treat it as a map, not as a specification:
|
||||||
an [issue](https://github.com/arabianq/pipewire-soundpad/issues) or submit
|
|
||||||
a [pull request](https://github.com/arabianq/pipewire-soundpad/pulls).
|
|
||||||
|
|
||||||
# **📜 License**
|
|
||||||
|
|
||||||
This project is licensed under
|
|
||||||
the [MIT License](https://github.com/arabianq/pipewire-soundpad/blob/main/LICENSE).
|
|
||||||
|
|
||||||
# **🤖 AI Wiki**
|
|
||||||
[](https://deepwiki.com/arabianq/pipewire-soundpad)
|
[](https://deepwiki.com/arabianq/pipewire-soundpad)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
Contributions, issues, and feature requests are welcome!
|
||||||
|
|
||||||
|
1. Fork the project.
|
||||||
|
2. Create your feature branch (`git checkout -b feat/amazing-feature`).
|
||||||
|
3. Commit your changes (`git commit -m 'Add some amazing feature'`).
|
||||||
|
4. Push to the branch (`git push origin feat/amazing-feature`).
|
||||||
|
5. Open a Pull Request.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 License
|
||||||
|
|
||||||
|
Distributed under the MIT License. See `LICENSE` for more information.
|
||||||
|
|
||||||
|
_Built with ❤️ for the Linux community._
|
||||||
|
|||||||
+219
@@ -0,0 +1,219 @@
|
|||||||
|
# PipeWire Soundpad (PWSP) 🎵
|
||||||
|
|
||||||
|
<table border="0">
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" align="center">
|
||||||
|
<h3>Главная страница</h3>
|
||||||
|
<img src="./pwsp-gui/assets/screenshots/main.png" alt="Main UI">
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td width="50%" align="center">
|
||||||
|
<h3>Настройки</h3>
|
||||||
|
<img src="./pwsp-gui/assets/screenshots/settings.png" alt="Settings">
|
||||||
|
</td>
|
||||||
|
<td width="50%" align="center">
|
||||||
|
<h3>Горячие клавиши</h3>
|
||||||
|
<img src="./pwsp-gui/assets/screenshots/hotkeys.png" alt="Hotkeys">
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
🇷🇺 Русский (вы здесь) | [🇬🇧 Read in English](README.md)
|
||||||
|
|
||||||
|
[](https://github.com/arabianq/pipewire-soundpad/actions)
|
||||||
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
[](https://github.com/arabianq/pipewire-soundpad/releases/latest)
|
||||||
|
[](https://pipewire.org/)
|
||||||
|
|
||||||
|
**PipeWire Soundpad (PWSP)** — приложение, которое позволяет воспроизводить аудиофайлы прямо в ваш микрофон. Разработано специально для Linux и работает с PipeWire напрямую: создаёт собственный виртуальный микрофон и само выстраивает маршрутизацию, так что настраивать её руками не придётся.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Возможности
|
||||||
|
|
||||||
|
- **Нативная интеграция с PipeWire:** PWSP создаёт собственный виртуальный микрофон и управляет графом через PipeWire API — без ручного `pw-link` и без подгрузки модулей PulseAudio.
|
||||||
|
- **Раздельная громкость мониторинга и микрофона:** Два независимых потока: можно усилить то, что слышат собеседники, не оглушив себя, и выбрать устройство для мониторинга.
|
||||||
|
- **Модульная архитектура:** Состоит из фонового демона (`daemon`), интерфейса командной строки (`cli`) и графического интерфейса (`gui`).
|
||||||
|
- **Современный GUI:** Построен на `egui`, плавно работает как на Wayland, так и на X11.
|
||||||
|
- **Локализация:** 9 языков; используется системный или выбранный вручную в настройках.
|
||||||
|
- **Глобальные горячие клавиши:** Работают через `evdev`, позволяя воспроизводить звуки из любого окна.
|
||||||
|
- **Широкая поддержка форматов:** Использует `rodio` и `symphonia` и поддерживает большинство аудиоформатов, включая Opus.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Установка
|
||||||
|
|
||||||
|
Мы предоставляем несколько способов установки PWSP, включая стабильные релизы и постоянно обновляемые "nightly" сборки напрямую из ветки `main`.
|
||||||
|
|
||||||
|
### 📦 Flatpak (Рекомендуется)
|
||||||
|
|
||||||
|
Добавьте наш официальный OSTree репозиторий и установите приложение:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Добавьте репозиторий
|
||||||
|
flatpak remote-add --if-not-exists pwsp https://arabianq.github.io/pipewire-soundpad/pwsp.flatpakrepo
|
||||||
|
|
||||||
|
# Установите стабильную (Stable) версию
|
||||||
|
flatpak install pwsp ru.arabianq.pwsp//stable
|
||||||
|
|
||||||
|
# ИЛИ установите Nightly версию (самые свежие обновления)
|
||||||
|
flatpak install pwsp ru.arabianq.pwsp//nightly
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🟠 Debian / Ubuntu (APT Репозиторий)
|
||||||
|
|
||||||
|
Мы поддерживаем официальный APT-репозиторий для бесшовных обновлений через `apt`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Скачайте публичный GPG ключ
|
||||||
|
sudo mkdir -p /etc/apt/keyrings
|
||||||
|
wget -O- https://arabianq.github.io/pipewire-soundpad/apt/pubkey.gpg | sudo gpg --dearmor -o /etc/apt/keyrings/pwsp.gpg
|
||||||
|
|
||||||
|
# 2. Добавьте репозиторий (Выберите STABLE или NIGHTLY)
|
||||||
|
# Для Stable:
|
||||||
|
echo "deb [signed-by=/etc/apt/keyrings/pwsp.gpg] https://arabianq.github.io/pipewire-soundpad/apt/ stable main" | sudo tee /etc/apt/sources.list.d/pwsp.list
|
||||||
|
|
||||||
|
# Для Nightly:
|
||||||
|
# echo "deb [signed-by=/etc/apt/keyrings/pwsp.gpg] https://arabianq.github.io/pipewire-soundpad/apt/ nightly main" | sudo tee /etc/apt/sources.list.d/pwsp.list
|
||||||
|
|
||||||
|
# 3. Обновите индексы и установите
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install pwsp
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🐧 Fedora / RHEL (COPR)
|
||||||
|
|
||||||
|
Доступно через репозиторий Fedora COPR:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo dnf copr enable arabianq/pwsp
|
||||||
|
sudo dnf install pwsp
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🐦 Arch Linux (AUR)
|
||||||
|
|
||||||
|
Доступны два пакета: `pwsp` собирается из исходников, `pwsp-bin` ставит готовые бинарники.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Через любой удобный AUR-хелпер
|
||||||
|
paru -S pwsp # или: pwsp-bin
|
||||||
|
```
|
||||||
|
|
||||||
|
### ⚙️ Ручная установка
|
||||||
|
|
||||||
|
Вы можете вручную скачать пакеты `.deb` или готовые бинарники `.zip` на [странице релизов](https://github.com/arabianq/pipewire-soundpad/releases).
|
||||||
|
|
||||||
|
### 🦀 Сборка из исходников
|
||||||
|
|
||||||
|
Убедитесь, что у вас установлены Rust и Cargo, а также зависимости для сборки. Для Debian/Ubuntu:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install libpipewire-0.3-dev libclang-dev libasound2-dev libdbus-1-dev libssl-dev pkg-config
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/arabianq/pipewire-soundpad.git
|
||||||
|
cd pipewire-soundpad
|
||||||
|
cargo build --release --locked
|
||||||
|
```
|
||||||
|
|
||||||
|
Собранные бинарники будут находиться в папке `target/release/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎮 Использование
|
||||||
|
|
||||||
|
### 1. Запуск демона
|
||||||
|
|
||||||
|
PWSP работает через фоновый демон, который маршрутизирует аудио.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Запуск демона
|
||||||
|
pwsp-daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
_(Подсказка: Если вы установили программу через пакетный менеджер, она включает пользовательский systemd-сервис. Вы можете включить его командой: `systemctl --user enable --now pwsp-daemon.service`)_
|
||||||
|
|
||||||
|
### 2. Запуск GUI
|
||||||
|
|
||||||
|
Просто запустите графический интерфейс для управления и воспроизведения звуков:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pwsp-gui
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Использование CLI
|
||||||
|
|
||||||
|
Вы также можете взаимодействовать с демоном напрямую через командную строку. Все команды сгруппированы в `action`, `get` и `set`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pwsp-cli action play /path/to/sound.mp3
|
||||||
|
pwsp-cli action stop
|
||||||
|
pwsp-cli get state
|
||||||
|
|
||||||
|
# Раздельная громкость: что слышите вы и что уходит в микрофон.
|
||||||
|
# Значения выше 1.0 усиливают.
|
||||||
|
pwsp-cli set monitoring-volume 0.3
|
||||||
|
pwsp-cli set mic-volume 1.5
|
||||||
|
|
||||||
|
# Устройства
|
||||||
|
pwsp-cli get inputs
|
||||||
|
pwsp-cli set input <имя>
|
||||||
|
```
|
||||||
|
|
||||||
|
Полный список — `pwsp-cli <группа> --help`.
|
||||||
|
|
||||||
|
### 🔑 Включение глобальных горячих клавиш
|
||||||
|
|
||||||
|
Горячие клавиши читаются напрямую из ядра через `evdev`, поэтому демону нужен доступ к `/dev/input/event*`. В большинстве дистрибутивов для этого достаточно добавить себя в группу `input`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo usermod -aG input $USER
|
||||||
|
```
|
||||||
|
|
||||||
|
Изменение вступит в силу после перезахода в систему. Без него всё остальное работает — молчат только глобальные горячие клавиши.
|
||||||
|
|
||||||
|
### 📦 Замечание для пользователей Flatpak
|
||||||
|
|
||||||
|
У Flatpak-версии одна точка входа, поэтому команды выше вызываются через `flatpak run`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Без аргументов: запускает демон и GUI вместе
|
||||||
|
flatpak run ru.arabianq.pwsp
|
||||||
|
|
||||||
|
# Отдельно демон
|
||||||
|
flatpak run ru.arabianq.pwsp daemon --start
|
||||||
|
flatpak run ru.arabianq.pwsp daemon --kill
|
||||||
|
|
||||||
|
# Всё после "cli" передаётся в pwsp-cli
|
||||||
|
flatpak run ru.arabianq.pwsp cli action play /path/to/sound.mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Документация и DeepWiki
|
||||||
|
|
||||||
|
DeepWiki автоматически строит по исходникам обзор архитектуры и позволяет задавать по нему вопросы. Он генерируется, а не пишется руками, поэтому воспринимайте его как карту, а не как спецификацию:
|
||||||
|
|
||||||
|
[](https://deepwiki.com/arabianq/pipewire-soundpad)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤝 Вклад в проект (Contributing)
|
||||||
|
|
||||||
|
Будем рады вашей помощи, баг-репортам и идеям!
|
||||||
|
|
||||||
|
1. Сделайте Fork проекта.
|
||||||
|
2. Создайте свою ветку (`git checkout -b feat/amazing-feature`).
|
||||||
|
3. Закоммитьте изменения (`git commit -m 'Add some amazing feature'`).
|
||||||
|
4. Запушьте ветку (`git push origin feat/amazing-feature`).
|
||||||
|
5. Откройте Pull Request.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Лицензия
|
||||||
|
|
||||||
|
Распространяется под лицензией MIT. Подробнее см. в файле `LICENSE`.
|
||||||
|
|
||||||
|
_Сделано с ❤️ для сообщества Linux._
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB |
@@ -1,11 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Pipewire Soundpad Daemon
|
|
||||||
After=pipewire.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
ExecStart=/usr/bin/pwsp-daemon
|
|
||||||
Restart=no
|
|
||||||
RuntimeDirectory=pwsp
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=default.target
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 84 KiB |
+17
-14
@@ -1,17 +1,20 @@
|
|||||||
pkgbase = pwsp-bin
|
pkgbase = pwsp-bin
|
||||||
pkgdesc = Lets you play audio files through your microphone (Pre-built binaries)
|
pkgdesc = Lets you play audio files through your microphone (Pre-built binaries)
|
||||||
pkgver = 1.7.6
|
pkgver = 1.15.0
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://github.com/arabianq/pipewire-soundpad
|
url = https://github.com/arabianq/pipewire-soundpad
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
license = MIT
|
arch = aarch64
|
||||||
depends = pipewire
|
license = MIT
|
||||||
depends = alsa-lib
|
depends = pipewire
|
||||||
provides = pwsp
|
depends = alsa-lib
|
||||||
conflicts = pwsp
|
provides = pwsp
|
||||||
source = pwsp-bin-1.7.6.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.7.6/pwsp-v1.7.6-linux-x64.zip
|
conflicts = pwsp
|
||||||
source = pipewire-soundpad-1.7.6.tar.gz :: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.7.6.tar.gz
|
source = pipewire-soundpad-1.15.0.tar.gz :: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.15.0.tar.gz
|
||||||
sha256sums = SKIP
|
sha256sums = SKIP
|
||||||
sha256sums = SKIP
|
source_x86_64 = pwsp-1.15.0-x86_64.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.15.0/pwsp-v1.15.0-linux-x64.zip
|
||||||
|
sha256sums_x86_64 = SKIP
|
||||||
|
source_aarch64 = pwsp-1.15.0-aarch64.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.15.0/pwsp-v1.15.0-linux-arm64.zip
|
||||||
|
sha256sums_aarch64 = SKIP
|
||||||
|
|
||||||
pkgname = pwsp-bin
|
pkgname = pwsp-bin
|
||||||
|
|||||||
+12
-10
@@ -1,21 +1,23 @@
|
|||||||
# Maintainer: Alexander Tarasov <a.tevg@ya.ru>
|
# Maintainer: Alexander Tarasov <a.tevg@ya.ru>
|
||||||
pkgname=pwsp-bin
|
pkgname=pwsp-bin
|
||||||
_pkgname=pipewire-soundpad
|
_pkgname=pipewire-soundpad
|
||||||
pkgver=1.7.6
|
pkgver=1.15.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Lets you play audio files through your microphone (Pre-built binaries)"
|
pkgdesc="Lets you play audio files through your microphone (Pre-built binaries)"
|
||||||
arch=('x86_64')
|
arch=('x86_64' 'aarch64')
|
||||||
url="https://github.com/arabianq/pipewire-soundpad"
|
url="https://github.com/arabianq/pipewire-soundpad"
|
||||||
license=('MIT')
|
license=('MIT')
|
||||||
depends=('pipewire' 'alsa-lib')
|
depends=('pipewire' 'alsa-lib' 'openssl')
|
||||||
provides=('pwsp')
|
provides=('pwsp')
|
||||||
conflicts=('pwsp')
|
conflicts=('pwsp')
|
||||||
|
|
||||||
source=("${pkgname}-${pkgver}.zip::https://github.com/arabianq/$_pkgname/releases/download/v$pkgver/pwsp-v$pkgver-linux-x64.zip"
|
source_x86_64=("pwsp-${pkgver}-x86_64.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")
|
source_aarch64=("pwsp-${pkgver}-aarch64.zip::https://github.com/arabianq/$_pkgname/releases/download/v$pkgver/pwsp-v$pkgver-linux-arm64.zip")
|
||||||
|
source=("${_pkgname}-${pkgver}.tar.gz::https://github.com/arabianq/$_pkgname/archive/refs/tags/v$pkgver.tar.gz")
|
||||||
|
|
||||||
sha256sums=('SKIP'
|
sha256sums=('SKIP')
|
||||||
'SKIP')
|
sha256sums_x86_64=('SKIP')
|
||||||
|
sha256sums_aarch64=('SKIP')
|
||||||
|
|
||||||
package() {
|
package() {
|
||||||
_srcsrc="${srcdir}/${_pkgname}-${pkgver}"
|
_srcsrc="${srcdir}/${_pkgname}-${pkgver}"
|
||||||
@@ -24,9 +26,9 @@ package() {
|
|||||||
install -Dm755 "${srcdir}/pwsp-daemon" "${pkgdir}/usr/bin/pwsp-daemon"
|
install -Dm755 "${srcdir}/pwsp-daemon" "${pkgdir}/usr/bin/pwsp-daemon"
|
||||||
install -Dm755 "${srcdir}/pwsp-gui" "${pkgdir}/usr/bin/pwsp-gui"
|
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/pwsp-gui/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/pwsp-gui/assets/icon.png" "${pkgdir}/usr/share/icons/hicolor/256x256/apps/pwsp.png"
|
||||||
install -Dm644 "$_srcsrc/assets/pwsp-daemon.service" "${pkgdir}/usr/lib/systemd/user/pwsp-daemon.service"
|
install -Dm644 "$_srcsrc/pwsp-daemon/assets/pwsp-daemon.service" "${pkgdir}/usr/lib/systemd/user/pwsp-daemon.service"
|
||||||
|
|
||||||
install -Dm644 "$_srcsrc/LICENSE" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
install -Dm644 "$_srcsrc/LICENSE" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
pkgbase = pwsp
|
pkgbase = pwsp
|
||||||
pkgdesc = Lets you play audio files through your microphone
|
pkgdesc = Lets you play audio files through your microphone
|
||||||
pkgver = 1.7.6
|
pkgver = 1.15.0
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://github.com/arabianq/pipewire-soundpad
|
url = https://github.com/arabianq/pipewire-soundpad
|
||||||
arch = any
|
arch = x86_64
|
||||||
|
arch = aarch64
|
||||||
license = MIT
|
license = MIT
|
||||||
makedepends = clang
|
makedepends = clang
|
||||||
makedepends = rust
|
makedepends = rust
|
||||||
@@ -11,7 +12,7 @@ pkgbase = pwsp
|
|||||||
makedepends = cmake
|
makedepends = cmake
|
||||||
makedepends = pipewire
|
makedepends = pipewire
|
||||||
makedepends = alsa-lib
|
makedepends = alsa-lib
|
||||||
source = https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.7.6.tar.gz
|
source = https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.15.0.tar.gz
|
||||||
sha256sums = SKIP
|
sha256sums = SKIP
|
||||||
|
|
||||||
pkgname = pwsp
|
pkgname = pwsp
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
# Maintainer: Alexander Tarasov <a.tevg@ya.ru>
|
# Maintainer: Alexander Tarasov <a.tevg@ya.ru>
|
||||||
pkgsubn=pwsp
|
pkgsubn=pwsp
|
||||||
pkgname=pwsp
|
pkgname=pwsp
|
||||||
pkgver=1.7.6
|
pkgver=1.15.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Lets you play audio files through your microphone"
|
pkgdesc="Lets you play audio files through your microphone"
|
||||||
arch=('any')
|
arch=('x86_64' 'aarch64')
|
||||||
url="https://github.com/arabianq/pipewire-soundpad"
|
url="https://github.com/arabianq/pipewire-soundpad"
|
||||||
license=('MIT')
|
license=('MIT')
|
||||||
|
depends=('pipewire' 'alsa-lib' 'openssl')
|
||||||
makedepends=(clang rust cargo cmake pipewire alsa-lib)
|
makedepends=(clang rust cargo cmake pipewire alsa-lib)
|
||||||
source=("$url/archive/refs/tags/v$pkgver.tar.gz")
|
source=("$url/archive/refs/tags/v$pkgver.tar.gz")
|
||||||
sha256sums=('SKIP')
|
sha256sums=('SKIP')
|
||||||
@@ -40,8 +41,8 @@ package() {
|
|||||||
install -Dm755 "target/release/pwsp-daemon" "${pkgdir}/usr/bin/pwsp-daemon"
|
install -Dm755 "target/release/pwsp-daemon" "${pkgdir}/usr/bin/pwsp-daemon"
|
||||||
install -Dm755 "target/release/pwsp-gui" "${pkgdir}/usr/bin/pwsp-gui"
|
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 "pwsp-gui/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 "pwsp-gui/assets/icon.png" "${pkgdir}/usr/share/icons/hicolor/256x256/apps/pwsp.png"
|
||||||
|
|
||||||
install -Dm644 "assets/pwsp-daemon.service" "${pkgdir}/usr/lib/systemd/user/pwsp-daemon.service"
|
install -Dm644 "pwsp-daemon/assets/pwsp-daemon.service" "${pkgdir}/usr/lib/systemd/user/pwsp-daemon.service"
|
||||||
}
|
}
|
||||||
|
|||||||
+1689
-1297
File diff suppressed because one or more lines are too long
@@ -2,21 +2,26 @@
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) == 2 and sys.argv[1].startswith("soundpad://"):
|
||||||
|
subprocess.Popen(["pwsp-gui", sys.argv[1]])
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="PWSP Flatpak",
|
prog="PWSP Flatpak", add_help=True, exit_on_error=True
|
||||||
add_help=True,
|
|
||||||
exit_on_error=True
|
|
||||||
)
|
)
|
||||||
subparsers = parser.add_subparsers(dest="command")
|
subparsers = parser.add_subparsers(dest="command")
|
||||||
|
|
||||||
cli_parser = subparsers.add_parser("cli", add_help=False, prefix_chars=" ")
|
cli_parser = subparsers.add_parser("cli", add_help=False, prefix_chars=" ")
|
||||||
cli_parser.add_argument("args", nargs=argparse.REMAINDER, help="Arguments for pwsp-cli")
|
cli_parser.add_argument(
|
||||||
|
"args", nargs=argparse.REMAINDER, help="Arguments for pwsp-cli"
|
||||||
|
)
|
||||||
|
|
||||||
daemon_parser = subparsers.add_parser("daemon", add_help=True)
|
daemon_parser = subparsers.add_parser("daemon", add_help=True)
|
||||||
daemon_group = daemon_parser.add_mutually_exclusive_group(required=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("--start", action="store_true", help="Start pwsp-daemon")
|
||||||
daemon_group.add_argument("--kill", action="store_true", help="Kill pwsp-daemon")
|
daemon_group.add_argument("--kill", action="store_true", help="Kill pwsp-daemon")
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ Terminal=false
|
|||||||
Type=Application
|
Type=Application
|
||||||
Categories=AudioVideo;Audio;
|
Categories=AudioVideo;Audio;
|
||||||
Keywords=soundpad;pipewire;audio;
|
Keywords=soundpad;pipewire;audio;
|
||||||
|
MimeType=x-scheme-handler/soundpad;
|
||||||
@@ -15,7 +15,13 @@
|
|||||||
<launchable type="desktop-id">ru.arabianq.pwsp.desktop</launchable>
|
<launchable type="desktop-id">ru.arabianq.pwsp.desktop</launchable>
|
||||||
<screenshots>
|
<screenshots>
|
||||||
<screenshot type="default">
|
<screenshot type="default">
|
||||||
<image>https://raw.githubusercontent.com/arabianq/pipewire-soundpad/master/assets/screenshot.png</image>
|
<image>https://raw.githubusercontent.com/arabianq/pipewire-soundpad/main/pwsp-gui/assets/screenshots/main.png</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<image>https://raw.githubusercontent.com/arabianq/pipewire-soundpad/main/pwsp-gui/assets/screenshots/settings.png</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<image>https://raw.githubusercontent.com/arabianq/pipewire-soundpad/main/pwsp-gui/assets/screenshots/hotkeys.png</image>
|
||||||
</screenshot>
|
</screenshot>
|
||||||
</screenshots>
|
</screenshots>
|
||||||
<url type="homepage">https://pwsp.arabianq.ru</url>
|
<url type="homepage">https://pwsp.arabianq.ru</url>
|
||||||
@@ -25,7 +31,7 @@
|
|||||||
<name>arabian</name>
|
<name>arabian</name>
|
||||||
</developer>
|
</developer>
|
||||||
<releases>
|
<releases>
|
||||||
<release version="1.7.6" date="2026-04-28" />
|
<release version="1.15.0" date="2026-07-26" />
|
||||||
</releases>
|
</releases>
|
||||||
<content_rating type="oars-1.1" />
|
<content_rating type="oars-1.1" />
|
||||||
</component>
|
</component>
|
||||||
@@ -30,12 +30,13 @@ modules:
|
|||||||
- name: pwsp
|
- name: pwsp
|
||||||
buildsystem: simple
|
buildsystem: simple
|
||||||
build-commands:
|
build-commands:
|
||||||
|
- if [ -w /run/ccache ]; then mkdir -p /run/ccache/cargo-target && rm -rf target && ln -s /run/ccache/cargo-target target; fi
|
||||||
- export CARGO_HOME=$PWD/cargo && cargo build --release --offline
|
- export CARGO_HOME=$PWD/cargo && cargo build --release --offline
|
||||||
- install -Dm755 target/release/pwsp-daemon /app/bin/pwsp-daemon
|
- 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-cli /app/bin/pwsp-cli
|
||||||
- install -Dm755 target/release/pwsp-gui /app/bin/pwsp-gui
|
- install -Dm755 target/release/pwsp-gui /app/bin/pwsp-gui
|
||||||
- install -Dm755 packages/flatpak/pwsp-wrapper.py /app/bin/pwsp-wrapper.py
|
- 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 pwsp-gui/assets/icon.png /app/share/icons/hicolor/256x256/apps/ru.arabianq.pwsp.png
|
||||||
- install -Dm644 packages/flatpak/ru.arabianq.pwsp.desktop /app/share/applications/ru.arabianq.pwsp.desktop
|
- install -Dm644 packages/flatpak/ru.arabianq.pwsp.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
|
- install -Dm644 packages/flatpak/ru.arabianq.pwsp.metainfo.xml /app/share/metainfo/ru.arabianq.pwsp.metainfo.xml
|
||||||
sources:
|
sources:
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
|
||||||
|
# prevent library files from being installed
|
||||||
|
%global cargo_install_lib 0
|
||||||
|
|
||||||
|
# disable debuginfo package generation (debugsourcefiles.list is empty for Rust)
|
||||||
|
%global debug_package %{nil}
|
||||||
|
|
||||||
|
Name: pwsp-git
|
||||||
|
Version: {{{ git describe --tags --always | sed 's/^v//' | sed -E 's/-([0-9]+)-(g[0-9a-f]+)/^git.\1.\2/' }}}
|
||||||
|
Release: 1%{?dist}
|
||||||
|
Summary: Lets you play audio files through your microphone (git version)
|
||||||
|
|
||||||
|
License: MIT
|
||||||
|
|
||||||
|
URL: https://github.com/arabianq/pipewire-soundpad
|
||||||
|
VCS: {{{ git_dir_vcs }}}
|
||||||
|
Source: {{{ git_cwd_pack }}}
|
||||||
|
|
||||||
|
|
||||||
|
BuildRequires: rust
|
||||||
|
BuildRequires: cargo
|
||||||
|
BuildRequires: pipewire-devel
|
||||||
|
%if 0%{?suse_version}
|
||||||
|
BuildRequires: alsa-devel
|
||||||
|
BuildRequires: dbus-1-devel
|
||||||
|
BuildRequires: libopenssl-devel
|
||||||
|
%else
|
||||||
|
BuildRequires: alsa-lib-devel
|
||||||
|
BuildRequires: dbus-devel
|
||||||
|
BuildRequires: openssl-devel
|
||||||
|
%endif
|
||||||
|
BuildRequires: clang-devel
|
||||||
|
BuildRequires: cmake
|
||||||
|
BuildRequires: pkgconfig
|
||||||
|
%if 0%{?suse_version} && 0%{?suse_version} <= 1500
|
||||||
|
BuildRequires: gcc13-c++
|
||||||
|
%endif
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Declare compatibility and conflicts with the stable package
|
||||||
|
Provides: pwsp = %{version}-%{release}
|
||||||
|
Conflicts: pwsp
|
||||||
|
|
||||||
|
%global _description %{expand:
|
||||||
|
PWSP lets you play audio files through your microphone. Has both CLI and
|
||||||
|
GUI clients. This is the latest development (git) version.}
|
||||||
|
|
||||||
|
%description %{_description}
|
||||||
|
|
||||||
|
%prep
|
||||||
|
{{{ git_cwd_setup_macro }}}
|
||||||
|
|
||||||
|
|
||||||
|
%build
|
||||||
|
%if 0%{?suse_version} && 0%{?suse_version} <= 1500
|
||||||
|
export CC=gcc-13
|
||||||
|
export CXX=g++-13
|
||||||
|
%endif
|
||||||
|
cargo build --release --locked
|
||||||
|
|
||||||
|
|
||||||
|
%install
|
||||||
|
install -Dm755 target/release/pwsp-cli %{buildroot}%{_bindir}/pwsp-cli
|
||||||
|
install -Dm755 target/release/pwsp-daemon %{buildroot}%{_bindir}/pwsp-daemon
|
||||||
|
install -Dm755 target/release/pwsp-gui %{buildroot}%{_bindir}/pwsp-gui
|
||||||
|
|
||||||
|
install -Dm644 pwsp-gui/assets/pwsp-gui.desktop %{buildroot}%{_datadir}/applications/pwsp.desktop
|
||||||
|
install -Dm644 pwsp-gui/assets/icon.png %{buildroot}%{_datadir}/icons/hicolor/256x256/apps/pwsp.png
|
||||||
|
|
||||||
|
install -Dm644 pwsp-daemon/assets/pwsp-daemon.service %{buildroot}/usr/lib/systemd/user/pwsp-daemon.service
|
||||||
|
|
||||||
|
%post
|
||||||
|
%systemd_user_post pwsp-daemon.service
|
||||||
|
|
||||||
|
%preun
|
||||||
|
%systemd_user_preun pwsp-daemon.service
|
||||||
|
|
||||||
|
%postun
|
||||||
|
%systemd_user_postun_with_restart pwsp-daemon.service
|
||||||
|
|
||||||
|
%files
|
||||||
|
%license LICENSE
|
||||||
|
%doc README.md
|
||||||
|
%{_bindir}/pwsp-cli
|
||||||
|
%{_bindir}/pwsp-daemon
|
||||||
|
%{_bindir}/pwsp-gui
|
||||||
|
%{_datadir}/applications/pwsp.desktop
|
||||||
|
%{_datadir}/icons/hicolor/256x256/apps/pwsp.png
|
||||||
|
/usr/lib/systemd/user/pwsp-daemon.service
|
||||||
|
|
||||||
|
%changelog
|
||||||
|
{{{ git_dir_changelog }}}
|
||||||
|
|
||||||
+43
-6
@@ -1,10 +1,19 @@
|
|||||||
%bcond check 1
|
|
||||||
|
|
||||||
# prevent library files from being installed
|
# prevent library files from being installed
|
||||||
%global cargo_install_lib 0
|
%global cargo_install_lib 0
|
||||||
|
|
||||||
|
# Fallback macros for systems without rpmautospec (e.g. openSUSE)
|
||||||
|
%{!?autorelease: %global autorelease 1}
|
||||||
|
%{!?autochangelog: %global autochangelog \
|
||||||
|
* Tue Jun 02 2026 Arabian <arabianq@github> - %{version}-%{release}\
|
||||||
|
- Release build}
|
||||||
|
|
||||||
|
|
||||||
|
# disable debuginfo package generation (debugsourcefiles.list is empty for Rust)
|
||||||
|
%global debug_package %{nil}
|
||||||
|
|
||||||
|
|
||||||
Name: pwsp
|
Name: pwsp
|
||||||
Version: 1.7.6
|
Version: 1.15.0
|
||||||
Release: %autorelease
|
Release: %autorelease
|
||||||
Summary: Lets you play audio files through your microphone
|
Summary: Lets you play audio files through your microphone
|
||||||
|
|
||||||
@@ -16,9 +25,23 @@ Source: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags
|
|||||||
BuildRequires: rust
|
BuildRequires: rust
|
||||||
BuildRequires: cargo
|
BuildRequires: cargo
|
||||||
BuildRequires: pipewire-devel
|
BuildRequires: pipewire-devel
|
||||||
|
%if 0%{?suse_version}
|
||||||
|
BuildRequires: alsa-devel
|
||||||
|
BuildRequires: dbus-1-devel
|
||||||
|
BuildRequires: libopenssl-devel
|
||||||
|
%else
|
||||||
BuildRequires: alsa-lib-devel
|
BuildRequires: alsa-lib-devel
|
||||||
|
BuildRequires: dbus-devel
|
||||||
|
BuildRequires: openssl-devel
|
||||||
|
%endif
|
||||||
BuildRequires: clang-devel
|
BuildRequires: clang-devel
|
||||||
BuildRequires: cmake
|
BuildRequires: cmake
|
||||||
|
BuildRequires: pkgconfig
|
||||||
|
%if 0%{?suse_version} && 0%{?suse_version} <= 1500
|
||||||
|
BuildRequires: gcc13-c++
|
||||||
|
%endif
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
%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
|
||||||
@@ -30,17 +53,31 @@ GUI clients.}
|
|||||||
%autosetup -n pipewire-soundpad-%{version} -p1
|
%autosetup -n pipewire-soundpad-%{version} -p1
|
||||||
|
|
||||||
%build
|
%build
|
||||||
|
%if 0%{?suse_version} && 0%{?suse_version} <= 1500
|
||||||
|
export CC=gcc-13
|
||||||
|
export CXX=g++-13
|
||||||
|
%endif
|
||||||
cargo build --release --locked
|
cargo build --release --locked
|
||||||
|
|
||||||
|
|
||||||
%install
|
%install
|
||||||
install -Dm755 target/release/pwsp-cli %{buildroot}%{_bindir}/pwsp-cli
|
install -Dm755 target/release/pwsp-cli %{buildroot}%{_bindir}/pwsp-cli
|
||||||
install -Dm755 target/release/pwsp-daemon %{buildroot}%{_bindir}/pwsp-daemon
|
install -Dm755 target/release/pwsp-daemon %{buildroot}%{_bindir}/pwsp-daemon
|
||||||
install -Dm755 target/release/pwsp-gui %{buildroot}%{_bindir}/pwsp-gui
|
install -Dm755 target/release/pwsp-gui %{buildroot}%{_bindir}/pwsp-gui
|
||||||
|
|
||||||
install -Dm644 assets/pwsp-gui.desktop %{buildroot}%{_datadir}/applications/pwsp.desktop
|
install -Dm644 pwsp-gui/assets/pwsp-gui.desktop %{buildroot}%{_datadir}/applications/pwsp.desktop
|
||||||
install -Dm644 assets/icon.png %{buildroot}%{_datadir}/icons/hicolor/256x256/apps/pwsp.png
|
install -Dm644 pwsp-gui/assets/icon.png %{buildroot}%{_datadir}/icons/hicolor/256x256/apps/pwsp.png
|
||||||
|
|
||||||
install -Dm644 assets/pwsp-daemon.service %{buildroot}/usr/lib/systemd/user/pwsp-daemon.service
|
install -Dm644 pwsp-daemon/assets/pwsp-daemon.service %{buildroot}/usr/lib/systemd/user/pwsp-daemon.service
|
||||||
|
|
||||||
|
%post
|
||||||
|
%systemd_user_post pwsp-daemon.service
|
||||||
|
|
||||||
|
%preun
|
||||||
|
%systemd_user_preun pwsp-daemon.service
|
||||||
|
|
||||||
|
%postun
|
||||||
|
%systemd_user_postun_with_restart pwsp-daemon.service
|
||||||
|
|
||||||
%files
|
%files
|
||||||
%license LICENSE
|
%license LICENSE
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "pwsp-cli"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
pwsp-lib.workspace = true
|
||||||
|
|
||||||
|
tokio.workspace = true
|
||||||
|
|
||||||
|
anyhow.workspace = true
|
||||||
|
clap.workspace = true
|
||||||
|
|
||||||
|
serde_json.workspace = true
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
|
use anyhow::{Result, anyhow};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use pwsp::{
|
use pwsp_lib::{
|
||||||
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, toggle-pause, stop, play)
|
/// Tell the player to do something: play, stop, pause, manage hotkeys
|
||||||
Action {
|
Action {
|
||||||
#[clap(subcommand)]
|
#[clap(subcommand)]
|
||||||
action: Actions,
|
action: Actions,
|
||||||
},
|
},
|
||||||
/// Get information from the player (is paused, volume, position, duration, state, current-file-path, input, inputs)
|
/// Read player state: volumes, position, tracks, devices, hotkeys
|
||||||
Get {
|
Get {
|
||||||
#[clap(subcommand)]
|
#[clap(subcommand)]
|
||||||
parameter: GetCommands,
|
parameter: GetCommands,
|
||||||
},
|
},
|
||||||
/// Set information in the player (volume, position, input)
|
/// Change player settings: volumes, position, devices, loop, hotkeys
|
||||||
Set {
|
Set {
|
||||||
#[clap(subcommand)]
|
#[clap(subcommand)]
|
||||||
parameter: SetCommands,
|
parameter: SetCommands,
|
||||||
@@ -68,6 +69,8 @@ enum Actions {
|
|||||||
#[clap(short, long)]
|
#[clap(short, long)]
|
||||||
id: Option<u32>,
|
id: Option<u32>,
|
||||||
},
|
},
|
||||||
|
/// Write the daemon's current settings to disk
|
||||||
|
SaveDaemonConfig,
|
||||||
/// Play a sound by hotkey slot name
|
/// Play a sound by hotkey slot name
|
||||||
PlayHotkey { slot: String },
|
PlayHotkey { slot: String },
|
||||||
/// Remove the hotkey slot
|
/// Remove the hotkey slot
|
||||||
@@ -85,6 +88,12 @@ enum GetCommands {
|
|||||||
#[clap(short, long)]
|
#[clap(short, long)]
|
||||||
id: Option<u32>,
|
id: Option<u32>,
|
||||||
},
|
},
|
||||||
|
/// Volume of what you hear locally
|
||||||
|
MonitoringVolume,
|
||||||
|
/// Volume of what is sent to the virtual microphone
|
||||||
|
MicVolume,
|
||||||
|
/// Volume multiplier for all tracks
|
||||||
|
VolumeMultiplier,
|
||||||
/// Playback position (in seconds)
|
/// Playback position (in seconds)
|
||||||
Position {
|
Position {
|
||||||
#[clap(short, long)]
|
#[clap(short, long)]
|
||||||
@@ -103,8 +112,14 @@ enum GetCommands {
|
|||||||
Input,
|
Input,
|
||||||
/// All audio inputs
|
/// All audio inputs
|
||||||
Inputs,
|
Inputs,
|
||||||
|
/// Current audio output used for monitoring
|
||||||
|
Output,
|
||||||
|
/// All audio outputs
|
||||||
|
Outputs,
|
||||||
/// Version of the daemon
|
/// Version of the daemon
|
||||||
DaemonVersion,
|
DaemonVersion,
|
||||||
|
/// Daemon configuration
|
||||||
|
DaemonConfig,
|
||||||
/// Full player state
|
/// Full player state
|
||||||
FullState,
|
FullState,
|
||||||
/// All hotkey slots
|
/// All hotkey slots
|
||||||
@@ -113,12 +128,18 @@ enum GetCommands {
|
|||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
enum SetCommands {
|
enum SetCommands {
|
||||||
/// Playback volume
|
/// Playback volume. Without --id it sets both the monitoring and mic volumes
|
||||||
Volume {
|
Volume {
|
||||||
volume: f32,
|
volume: f32,
|
||||||
#[clap(short, long)]
|
#[clap(short, long)]
|
||||||
id: Option<u32>,
|
id: Option<u32>,
|
||||||
},
|
},
|
||||||
|
/// Volume of what you hear locally. Values above 1.0 amplify
|
||||||
|
MonitoringVolume { volume: f32 },
|
||||||
|
/// Volume of what is sent to the virtual microphone. Values above 1.0 amplify
|
||||||
|
MicVolume { volume: f32 },
|
||||||
|
/// Volume multiplier for all tracks
|
||||||
|
VolumeMultiplier { volume: f32 },
|
||||||
/// Playback position (in seconds)
|
/// Playback position (in seconds)
|
||||||
Position {
|
Position {
|
||||||
position: f32,
|
position: f32,
|
||||||
@@ -127,6 +148,8 @@ enum SetCommands {
|
|||||||
},
|
},
|
||||||
/// Audio input id (see pwsp-cli get inputs)
|
/// Audio input id (see pwsp-cli get inputs)
|
||||||
Input { name: String },
|
Input { name: String },
|
||||||
|
/// Audio output used for monitoring (see pwsp-cli get outputs)
|
||||||
|
Output { name: String },
|
||||||
/// Enable or disable loop (true or false)
|
/// Enable or disable loop (true or false)
|
||||||
Loop {
|
Loop {
|
||||||
enabled: String,
|
enabled: String,
|
||||||
@@ -146,7 +169,7 @@ enum SetCommands {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[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?;
|
||||||
@@ -164,6 +187,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
concurrent,
|
concurrent,
|
||||||
} => Request::play(&file_path.to_string_lossy(), concurrent),
|
} => Request::play(&file_path.to_string_lossy(), concurrent),
|
||||||
Actions::ToggleLoop { id } => Request::toggle_loop(id),
|
Actions::ToggleLoop { id } => Request::toggle_loop(id),
|
||||||
|
Actions::SaveDaemonConfig => Request::save_daemon_config(),
|
||||||
Actions::PlayHotkey { slot } => Request::play_hotkey(&slot),
|
Actions::PlayHotkey { slot } => Request::play_hotkey(&slot),
|
||||||
Actions::ClearHotkey { slot } => Request::clear_hotkey(&slot),
|
Actions::ClearHotkey { slot } => Request::clear_hotkey(&slot),
|
||||||
Actions::ClearHotkeyKey { slot } => Request::clear_hotkey_key(&slot),
|
Actions::ClearHotkeyKey { slot } => Request::clear_hotkey_key(&slot),
|
||||||
@@ -171,20 +195,30 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
Commands::Get { parameter } => match parameter {
|
Commands::Get { parameter } => match parameter {
|
||||||
GetCommands::IsPaused => Request::get_is_paused(),
|
GetCommands::IsPaused => Request::get_is_paused(),
|
||||||
GetCommands::Volume { id } => Request::get_volume(id),
|
GetCommands::Volume { id } => Request::get_volume(id),
|
||||||
|
GetCommands::MonitoringVolume => Request::get_monitoring_volume(),
|
||||||
|
GetCommands::MicVolume => Request::get_mic_volume(),
|
||||||
|
GetCommands::VolumeMultiplier => Request::get_volume_multiplier(),
|
||||||
GetCommands::Position { id } => Request::get_position(id),
|
GetCommands::Position { id } => Request::get_position(id),
|
||||||
GetCommands::Duration { id } => Request::get_duration(id),
|
GetCommands::Duration { id } => Request::get_duration(id),
|
||||||
GetCommands::State => Request::get_state(),
|
GetCommands::State => Request::get_state(),
|
||||||
GetCommands::Tracks => Request::get_tracks(),
|
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::Output => Request::get_output(),
|
||||||
|
GetCommands::Outputs => Request::get_outputs(),
|
||||||
GetCommands::DaemonVersion => Request::get_daemon_version(),
|
GetCommands::DaemonVersion => Request::get_daemon_version(),
|
||||||
|
GetCommands::DaemonConfig => Request::get_daemon_config(),
|
||||||
GetCommands::FullState => Request::get_full_state(),
|
GetCommands::FullState => Request::get_full_state(),
|
||||||
GetCommands::Hotkeys => Request::get_hotkeys(),
|
GetCommands::Hotkeys => Request::get_hotkeys(),
|
||||||
},
|
},
|
||||||
Commands::Set { parameter } => match parameter {
|
Commands::Set { parameter } => match parameter {
|
||||||
SetCommands::Volume { volume, id } => Request::set_volume(volume, id),
|
SetCommands::Volume { volume, id } => Request::set_volume(volume, id),
|
||||||
|
SetCommands::MonitoringVolume { volume } => Request::set_monitoring_volume(volume),
|
||||||
|
SetCommands::MicVolume { volume } => Request::set_mic_volume(volume),
|
||||||
|
SetCommands::VolumeMultiplier { volume } => Request::set_volume_multiplier(volume),
|
||||||
SetCommands::Position { position, id } => Request::seek(position, id),
|
SetCommands::Position { position, id } => Request::seek(position, id),
|
||||||
SetCommands::Input { name } => Request::set_input(&name),
|
SetCommands::Input { name } => Request::set_input(&name),
|
||||||
|
SetCommands::Output { name } => Request::set_output(&name),
|
||||||
SetCommands::Loop { enabled, id } => Request::set_loop(&enabled, id),
|
SetCommands::Loop { enabled, id } => Request::set_loop(&enabled, id),
|
||||||
SetCommands::Hotkey { slot, file_path } => {
|
SetCommands::Hotkey { slot, file_path } => {
|
||||||
Request::set_hotkey(&slot, &file_path.to_string_lossy())
|
Request::set_hotkey(&slot, &file_path.to_string_lossy())
|
||||||
@@ -204,9 +238,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
let response = make_request(request)
|
let response = make_request(request).await.map_err(|e| anyhow!(e))?;
|
||||||
.await
|
|
||||||
.map_err(|e| e as Box<dyn Error>)?;
|
|
||||||
println!("{} : {}", response.status, response.message);
|
println!("{} : {}", response.status, response.message);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "pwsp-daemon"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
pwsp-lib.workspace = true
|
||||||
|
|
||||||
|
tokio.workspace = true
|
||||||
|
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
||||||
|
clap.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
|
||||||
|
pipewire.workspace = true
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Pipewire Soundpad Daemon
|
||||||
|
Requires=pipewire.service
|
||||||
|
After=pipewire.service
|
||||||
|
PartOf=graphical-session.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/bin/pwsp-daemon
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
RuntimeDirectory=pwsp
|
||||||
|
SyslogIdentifier=pwsp-daemon
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
use anyhow::{Result, anyhow};
|
||||||
|
use pwsp_lib::{
|
||||||
|
types::socket::{MAX_MESSAGE_SIZE, Request, Response},
|
||||||
|
utils::{
|
||||||
|
commands::parse_command,
|
||||||
|
daemon::{
|
||||||
|
create_runtime_dir, get_audio_player, get_daemon_config, get_runtime_dir,
|
||||||
|
is_daemon_running,
|
||||||
|
},
|
||||||
|
global_hotkeys::start_global_hotkey_listener,
|
||||||
|
pipewire::create_virtual_mic,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
use std::{fs, time::Duration};
|
||||||
|
use tokio::{
|
||||||
|
io::{AsyncReadExt, AsyncWriteExt},
|
||||||
|
net::{UnixListener, UnixStream},
|
||||||
|
time::sleep,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
create_runtime_dir()?;
|
||||||
|
|
||||||
|
if is_daemon_running()? {
|
||||||
|
return Err(anyhow!("Another instance is already running."));
|
||||||
|
}
|
||||||
|
|
||||||
|
get_daemon_config(); // Initialize daemon config
|
||||||
|
|
||||||
|
// Virtual mic object must be kept alive by some variable until daemon exits
|
||||||
|
let _virtual_mic = create_virtual_mic().await?;
|
||||||
|
|
||||||
|
if let Err(err) = get_audio_player().await {
|
||||||
|
eprintln!("Failed to initialize audio player: {}", err);
|
||||||
|
} // Initialize audio player
|
||||||
|
|
||||||
|
tokio::spawn(async {
|
||||||
|
start_global_hotkey_listener().await;
|
||||||
|
});
|
||||||
|
|
||||||
|
let runtime_dir = get_runtime_dir();
|
||||||
|
|
||||||
|
let lock_file = fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.write(true)
|
||||||
|
.truncate(false)
|
||||||
|
.open(runtime_dir.join("daemon.lock"))?;
|
||||||
|
lock_file.lock()?;
|
||||||
|
|
||||||
|
let socket_path = runtime_dir.join("daemon.sock");
|
||||||
|
if let Err(e) = fs::remove_file(&socket_path)
|
||||||
|
&& e.kind() != std::io::ErrorKind::NotFound
|
||||||
|
{
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let listener = UnixListener::bind(&socket_path)?;
|
||||||
|
fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600))?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Daemon started. Listening on {}",
|
||||||
|
socket_path.to_str().unwrap_or_default()
|
||||||
|
);
|
||||||
|
|
||||||
|
let commands_loop_handle = tokio::spawn(async {
|
||||||
|
commands_loop(listener).await.ok();
|
||||||
|
});
|
||||||
|
|
||||||
|
let player_loop_handle = tokio::spawn(async {
|
||||||
|
player_loop().await;
|
||||||
|
});
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = commands_loop_handle => {
|
||||||
|
eprint!("Commands loop was finished, stopping program...");
|
||||||
|
}
|
||||||
|
_ = player_loop_handle => {
|
||||||
|
eprint!("Audio Player loop was finished, stopping program...");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn commands_loop(listener: UnixListener) -> Result<()> {
|
||||||
|
loop {
|
||||||
|
let (stream, _addr) = listener.accept().await?;
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
handle_connection(stream).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_connection(mut stream: UnixStream) {
|
||||||
|
// ---------- Read request (start) ----------
|
||||||
|
let mut len_bytes = [0u8; 4];
|
||||||
|
if stream.read_exact(&mut len_bytes).await.is_err() {
|
||||||
|
eprintln!("Failed to read message length from client!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let request_len = u32::from_le_bytes(len_bytes) as usize;
|
||||||
|
|
||||||
|
if request_len > MAX_MESSAGE_SIZE {
|
||||||
|
eprintln!(
|
||||||
|
"Failed to read message from client: request too large ({} bytes)!",
|
||||||
|
request_len
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
if (&mut stream)
|
||||||
|
.take(request_len as u64)
|
||||||
|
.read_to_end(&mut buffer)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
|| buffer.len() != request_len
|
||||||
|
{
|
||||||
|
eprintln!("Failed to read message from client!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let request: Request = match serde_json::from_slice(&buffer) {
|
||||||
|
Ok(req) => req,
|
||||||
|
Err(err) => {
|
||||||
|
let response = Response::new(false, format!("Failed to parse request: {}", err));
|
||||||
|
let response_data = match serde_json::to_vec(&response) {
|
||||||
|
Ok(data) => data,
|
||||||
|
Err(_) => return, // Should not happen with this simple Response
|
||||||
|
};
|
||||||
|
let response_len = response_data.len() as u32;
|
||||||
|
let _ = stream.write_all(&response_len.to_le_bytes()).await;
|
||||||
|
let _ = stream.write_all(&response_data).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// ---------- Read request (end) ----------
|
||||||
|
|
||||||
|
// ---------- Generate response (start) ----------
|
||||||
|
let command = parse_command(&request);
|
||||||
|
let response: Response;
|
||||||
|
if let Some(command) = command {
|
||||||
|
response = command.execute().await;
|
||||||
|
} else {
|
||||||
|
response = Response::new(false, "Unknown command");
|
||||||
|
}
|
||||||
|
// ---------- Generate response (end) ----------
|
||||||
|
|
||||||
|
// ---------- Send response (start) ----------
|
||||||
|
let response_data = match serde_json::to_vec(&response) {
|
||||||
|
Ok(data) => data,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("Failed to serialize response: {}", err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let response_len = response_data.len() as u32;
|
||||||
|
|
||||||
|
if stream.write_all(&response_len.to_le_bytes()).await.is_err() {
|
||||||
|
eprintln!("Failed to write response length to client!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if stream.write_all(&response_data).await.is_err() {
|
||||||
|
eprintln!("Failed to write response to client!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// ---------- Send response (end) ----------
|
||||||
|
|
||||||
|
if response.status && response.message.eq("killed") {
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn player_loop() {
|
||||||
|
let mut device_check_counter: u32 = 0;
|
||||||
|
loop {
|
||||||
|
let is_idle = match get_audio_player().await {
|
||||||
|
Ok(player_mutex) => {
|
||||||
|
let mut audio_player = player_mutex.lock().await;
|
||||||
|
let check_devices = device_check_counter == 0;
|
||||||
|
audio_player.update(check_devices).await;
|
||||||
|
audio_player.tracks.is_empty()
|
||||||
|
}
|
||||||
|
Err(_err) => true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if is_idle {
|
||||||
|
device_check_counter = 0;
|
||||||
|
sleep(Duration::from_secs(2)).await;
|
||||||
|
} else {
|
||||||
|
// Check devices every ~5 seconds (50 * 100ms) while playing
|
||||||
|
device_check_counter = (device_check_counter + 1) % 50;
|
||||||
|
sleep(Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
[package]
|
||||||
|
name = "pwsp-gui"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
description.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
pwsp-lib.workspace = true
|
||||||
|
|
||||||
|
tokio.workspace = true
|
||||||
|
|
||||||
|
opener.workspace = true
|
||||||
|
rfd.workspace = true
|
||||||
|
itertools.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
dirs.workspace = true
|
||||||
|
|
||||||
|
egui.workspace = true
|
||||||
|
eframe.workspace = true
|
||||||
|
egui_extras.workspace = true
|
||||||
|
egui_material_icons.workspace = true
|
||||||
|
egui_dnd.workspace = true
|
||||||
|
|
||||||
|
system-fonts.workspace = true
|
||||||
|
rust-i18n.workspace = true
|
||||||
|
sys-locale.workspace = true
|
||||||
|
|
||||||
|
reqwest.workspace = true
|
||||||
|
percent-encoding.workspace = true
|
||||||
|
|
||||||
|
[package.metadata.deb]
|
||||||
|
name = "pwsp"
|
||||||
|
section = "sound"
|
||||||
|
priority = "optional"
|
||||||
|
maintainer-scripts = "maintainer-scripts"
|
||||||
|
assets = [
|
||||||
|
[
|
||||||
|
"target/release/pwsp-daemon",
|
||||||
|
"usr/bin/",
|
||||||
|
"755",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"target/release/pwsp-cli",
|
||||||
|
"usr/bin/",
|
||||||
|
"755",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"target/release/pwsp-gui",
|
||||||
|
"usr/bin/",
|
||||||
|
"755",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"assets/pwsp-gui.desktop",
|
||||||
|
"usr/share/applications/pwsp.desktop",
|
||||||
|
"644",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"assets/icon.png",
|
||||||
|
"usr/share/icons/hicolor/256x256/apps/pwsp.png",
|
||||||
|
"644",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"../pwsp-daemon/assets/pwsp-daemon.service",
|
||||||
|
"usr/lib/systemd/user/pwsp-daemon.service",
|
||||||
|
"644",
|
||||||
|
],
|
||||||
|
]
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
@@ -1,8 +1,9 @@
|
|||||||
[Desktop Entry]
|
[Desktop Entry]
|
||||||
Name=PWSP (Soundpad)
|
Name=PWSP (Soundpad)
|
||||||
Comment=Let's you play audio files through you microphone
|
Comment=Let's you play audio files through you microphone
|
||||||
Exec=pwsp-gui %u
|
Exec=/usr/bin/pwsp-gui %u
|
||||||
Icon=pwsp
|
Icon=pwsp
|
||||||
Terminal=false
|
Terminal=false
|
||||||
Type=Application
|
Type=Application
|
||||||
Categories=Audio
|
Categories=Audio
|
||||||
|
MimeType=x-scheme-handler/soundpad;
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
@@ -0,0 +1,585 @@
|
|||||||
|
_version = 2
|
||||||
|
|
||||||
|
# ----------------
|
||||||
|
# Main page
|
||||||
|
# ----------------
|
||||||
|
|
||||||
|
[gui.play_file_button]
|
||||||
|
en = "Play file"
|
||||||
|
ru = "Выбрать файл"
|
||||||
|
es = "Reproducir archivo"
|
||||||
|
fr = "Lire le fichier"
|
||||||
|
zh = "播放文件"
|
||||||
|
ar = "تشغيل الملف"
|
||||||
|
kz = "Файлды ойнату"
|
||||||
|
he = "נגן קובץ"
|
||||||
|
pt-BR = "Reproduzir arquivo"
|
||||||
|
|
||||||
|
[gui.choose_mic_select]
|
||||||
|
en = "Select microphone"
|
||||||
|
ru = "Выбрать микрофон"
|
||||||
|
es = "Seleccionar micrófono"
|
||||||
|
fr = "Sélectionner le microphone"
|
||||||
|
zh = "选择麦克风"
|
||||||
|
ar = "اختر الميكروفون"
|
||||||
|
kz = "Микрофонды таңдау"
|
||||||
|
he = "בחר מיקרופון"
|
||||||
|
pt-BR = "Selecionar microfone"
|
||||||
|
|
||||||
|
[gui.search_placeholder]
|
||||||
|
en = "Search files..."
|
||||||
|
ru = "Поиск файлов..."
|
||||||
|
es = "Buscar archivos..."
|
||||||
|
fr = "Rechercher des fichiers..."
|
||||||
|
zh = "搜索文件..."
|
||||||
|
ar = "البحث عن ملفات..."
|
||||||
|
kz = "Файлдарды іздеу..."
|
||||||
|
he = "חפש קבצים..."
|
||||||
|
pt-BR = "Buscar arquivos..."
|
||||||
|
|
||||||
|
[gui.context.dirs.open]
|
||||||
|
en = "Open"
|
||||||
|
ru = "Открыть"
|
||||||
|
es = "Abrir"
|
||||||
|
fr = "Ouvrir"
|
||||||
|
zh = "打开"
|
||||||
|
ar = "فتح"
|
||||||
|
kz = "Ашу"
|
||||||
|
he = "פתח"
|
||||||
|
pt-BR = "Abrir"
|
||||||
|
|
||||||
|
[gui.context.dirs.open_in_fm]
|
||||||
|
en = "Open in File Manager"
|
||||||
|
ru = "Открыть в менеджере файлов"
|
||||||
|
es = "Abrir en el gestor de archivos"
|
||||||
|
fr = "Ouvrir dans le gestionnaire de fichiers"
|
||||||
|
zh = "在文件管理器中打开"
|
||||||
|
ar = "فتح في مدير الملفات"
|
||||||
|
kz = "Файл менеджерінде ашу"
|
||||||
|
he = "פתח במנהל הקבצים"
|
||||||
|
pt-BR = "Abrir no gestor de arquivos"
|
||||||
|
|
||||||
|
[gui.context.dirs.remove]
|
||||||
|
en = "Remove"
|
||||||
|
ru = "Удалить"
|
||||||
|
es = "Eliminar"
|
||||||
|
fr = "Supprimer"
|
||||||
|
zh = "移除"
|
||||||
|
ar = "إزالة"
|
||||||
|
kz = "Жою"
|
||||||
|
he = "הסר"
|
||||||
|
pt-BR = "Remover"
|
||||||
|
|
||||||
|
[gui.context.dirs.sort_by]
|
||||||
|
en = "Sort by"
|
||||||
|
ru = "Сортировка"
|
||||||
|
es = "Ordenar por"
|
||||||
|
fr = "Trier par"
|
||||||
|
zh = "排序方式"
|
||||||
|
ar = "ترتيب حسب"
|
||||||
|
kz = "Сұрыптау"
|
||||||
|
he = "מיין לפי"
|
||||||
|
pt-BR = "Ordenar por"
|
||||||
|
|
||||||
|
[gui.sort.alpha_asc]
|
||||||
|
en = "Alphabetical (A-Z)"
|
||||||
|
ru = "По алфавиту (А-Я)"
|
||||||
|
es = "Alfabético (A-Z)"
|
||||||
|
fr = "Alphabétique (A-Z)"
|
||||||
|
zh = "字母顺序 (A-Z)"
|
||||||
|
ar = "أبجدي (A-Z)"
|
||||||
|
kz = "Әліпби бойынша (А-Я)"
|
||||||
|
he = "אלפביתי (A-Z)"
|
||||||
|
pt-BR = "Alfabético (A-Z)"
|
||||||
|
|
||||||
|
[gui.sort.alpha_desc]
|
||||||
|
en = "Alphabetical (Z-A)"
|
||||||
|
ru = "По алфавиту (Я-А)"
|
||||||
|
es = "Alfabético (Z-A)"
|
||||||
|
fr = "Alphabétique (Z-A)"
|
||||||
|
zh = "字母顺序 (Z-A)"
|
||||||
|
ar = "أبجدي (Z-A)"
|
||||||
|
kz = "Әліпби бойынша (Я-А)"
|
||||||
|
he = "אלפביתי (Z-A)"
|
||||||
|
pt-BR = "Alfabético (Z-A)"
|
||||||
|
|
||||||
|
[gui.sort.date_newest]
|
||||||
|
en = "Date modified (Newest first)"
|
||||||
|
ru = "Дата изменения (Сначала новые)"
|
||||||
|
es = "Fecha de modificación (Más nuevo primero)"
|
||||||
|
fr = "Date de modification (Plus récent en premier)"
|
||||||
|
zh = "修改日期 (最新优先)"
|
||||||
|
ar = "تاريخ التعديل (الأحدث أولاً)"
|
||||||
|
kz = "Өзгертілген күні (Жаңалары бірінші)"
|
||||||
|
he = "תאריך שינוי (החדש ביותר ראשון)"
|
||||||
|
pt-BR = "Data de modificação (Mais novo primeiro)"
|
||||||
|
|
||||||
|
[gui.sort.date_oldest]
|
||||||
|
en = "Date modified (Oldest first)"
|
||||||
|
ru = "Дата изменения (Сначала старые)"
|
||||||
|
es = "Fecha de modificación (Más antiguo primero)"
|
||||||
|
fr = "Date de modification (Plus ancien en premier)"
|
||||||
|
zh = "修改日期 (最旧优先)"
|
||||||
|
ar = "تاريخ التعديل (الأقدم أولاً)"
|
||||||
|
kz = "Өзгертілген күні (Ескілері бірінші)"
|
||||||
|
he = "תאריך שינוי (הישן ביותר ראשון)"
|
||||||
|
pt-BR = "Data de modificação (Mais antigo primeiro)"
|
||||||
|
|
||||||
|
[gui.context.files.play_solo]
|
||||||
|
en = "Play Solo"
|
||||||
|
ru = "Играть"
|
||||||
|
es = "Reproducir solo"
|
||||||
|
fr = "Jouer en solo"
|
||||||
|
zh = "单独播放"
|
||||||
|
ar = "تشغيل منفرد"
|
||||||
|
kz = "Жалғыз ойнату"
|
||||||
|
he = "נגן סולו"
|
||||||
|
pt-BR = "Reproduzir"
|
||||||
|
|
||||||
|
[gui.context.files.add_new]
|
||||||
|
en = "Add New"
|
||||||
|
ru = "Добавить"
|
||||||
|
es = "Añadir nuevo"
|
||||||
|
fr = "Ajouter un nouveau"
|
||||||
|
zh = "添加新项"
|
||||||
|
ar = "إضافة جديد"
|
||||||
|
kz = "Жаңасын қосу"
|
||||||
|
he = "הוסף חדש"
|
||||||
|
pt-BR = "Adicionar"
|
||||||
|
|
||||||
|
[gui.context.files.replace_last]
|
||||||
|
en = "Replace Last"
|
||||||
|
ru = "Заменить Последний"
|
||||||
|
es = "Reemplazar último"
|
||||||
|
fr = "Remplacer le dernier"
|
||||||
|
zh = "替换上一个"
|
||||||
|
ar = "استبدال الأخير"
|
||||||
|
kz = "Соңғысын ауыстыру"
|
||||||
|
he = "החלף אחרון"
|
||||||
|
pt-BR = "Substituir"
|
||||||
|
|
||||||
|
[gui.context.files.show_in_fm]
|
||||||
|
en = "Show in File Manager"
|
||||||
|
ru = "Открыть в менеджере файлов"
|
||||||
|
es = "Mostrar en el gestor de archivos"
|
||||||
|
fr = "Afficher dans le gestionnaire de fichiers"
|
||||||
|
zh = "在文件管理器中显示"
|
||||||
|
ar = "عرض في مدير الملفات"
|
||||||
|
kz = "Файл менеджерінде көрсету"
|
||||||
|
he = "הצג במנהל הקבצים"
|
||||||
|
pt-BR = "Mostrar no gestor de arquivos"
|
||||||
|
|
||||||
|
[gui.context.files.asign_hotkey]
|
||||||
|
en = "Assign Hotkey"
|
||||||
|
ru = "Назначить Горячую Клавишу"
|
||||||
|
es = "Asignar atajo"
|
||||||
|
fr = "Assigner un raccourci"
|
||||||
|
zh = "分配快捷键"
|
||||||
|
ar = "تعيين مفتاح اختصار"
|
||||||
|
kz = "Ыстық пернені тағайындау"
|
||||||
|
he = "הקצה מקש קיצור"
|
||||||
|
pt-BR = "Definir tecla de atalho"
|
||||||
|
|
||||||
|
[gui.context.files.copy_cli_command]
|
||||||
|
en = "Copy PWSP-CLI command"
|
||||||
|
ru = "Скопировать команду для PWSP-CLI"
|
||||||
|
es = "Copiar comando de PWSP-CLI"
|
||||||
|
fr = "Copier la commande PWSP-CLI"
|
||||||
|
zh = "复制 PWSP-CLI 命令"
|
||||||
|
ar = "نسخ أمر PWSP-CLI"
|
||||||
|
kz = "PWSP-CLI командасын көшіру"
|
||||||
|
he = "העתק פקודת PWSP-CLI"
|
||||||
|
pt-BR = "Copiar comando PWSP-CLI"
|
||||||
|
|
||||||
|
# ----------------
|
||||||
|
# Settings
|
||||||
|
# ----------------
|
||||||
|
|
||||||
|
[gui.settings.header]
|
||||||
|
en = "Settings"
|
||||||
|
ru = "Настройки"
|
||||||
|
es = "Ajustes"
|
||||||
|
fr = "Paramètres"
|
||||||
|
zh = "设置"
|
||||||
|
ar = "الإعدادات"
|
||||||
|
kz = "Баптаулар"
|
||||||
|
he = "הגדרות"
|
||||||
|
pt-BR = "Configurações"
|
||||||
|
|
||||||
|
[gui.settings.remember_volume]
|
||||||
|
en = "Always remember volume"
|
||||||
|
ru = "Всегда запоминать громкость"
|
||||||
|
es = "Recordar siempre el volumen"
|
||||||
|
fr = "Toujours se souvenir du volume"
|
||||||
|
zh = "始终记住音量"
|
||||||
|
ar = "تذكر مستوى الصوت دائمًا"
|
||||||
|
kz = "Әрқашан дыбыс деңгейін есте сақтау"
|
||||||
|
he = "זכור תמיד עוצמת קול"
|
||||||
|
pt-BR = "Lembrar volume"
|
||||||
|
|
||||||
|
[gui.settings.remember_volume_multiplier]
|
||||||
|
en = "Always remember volume multiplier"
|
||||||
|
ru = "Всегда запоминать множитель громкости"
|
||||||
|
es = "Recordar siempre el multiplicador de volumen"
|
||||||
|
fr = "Toujours se souvenir du multiplicateur de volume"
|
||||||
|
zh = "始终记住音量乘数"
|
||||||
|
ar = "تذكر ضرب الصوت دائمًا"
|
||||||
|
kz = "Әрқашан дыбыс деңгейін есте сақтау"
|
||||||
|
he = "זכור תמיד עוצמת קול"
|
||||||
|
pt-BR = "Lembrar volume"
|
||||||
|
|
||||||
|
[gui.settings.volume_multiplier]
|
||||||
|
en = "Volume multiplier"
|
||||||
|
ru = "Множитель громкости"
|
||||||
|
es = "Multiplicador de volumen"
|
||||||
|
fr = "Multiplicateur de volume"
|
||||||
|
zh = "音量乘数"
|
||||||
|
ar = "ضرب الصوت"
|
||||||
|
kz = "Дыбыс деңгейі"
|
||||||
|
he = "עוצמת קול"
|
||||||
|
pt-BR = "Volume"
|
||||||
|
|
||||||
|
[gui.settings.remember_mic]
|
||||||
|
en = "Always remember microphone"
|
||||||
|
ru = "Всегда запоминать микрофон"
|
||||||
|
es = "Recordar siempre el micrófono"
|
||||||
|
fr = "Toujours se souvenir du microphone"
|
||||||
|
zh = "始终记住麦克风"
|
||||||
|
ar = "تذكر الميكروفون دائمًا"
|
||||||
|
kz = "Әрқашан микрофонды есте сақтау"
|
||||||
|
he = "זכור תמיד מיקרופון"
|
||||||
|
pt-BR = "Lembrar microfone"
|
||||||
|
|
||||||
|
[gui.settings.remember_ui_scale]
|
||||||
|
en = "Always remember UI scale factor"
|
||||||
|
ru = "Всегда запоминать масштаб интерфейса"
|
||||||
|
es = "Recordar siempre la escala de la interfaz"
|
||||||
|
fr = "Toujours se souvenir de l'échelle de l'interface"
|
||||||
|
zh = "始终记住界面缩放比例"
|
||||||
|
ar = "تذكر عامل تكبير الواجهة دائمًا"
|
||||||
|
kz = "Әрқашан интерфейс масштабын есте сақтау"
|
||||||
|
he = "זכור תמיד קנה מידה של ממשק משתמש"
|
||||||
|
pt-BR = "Lembrar fator de escala da interface"
|
||||||
|
|
||||||
|
[gui.settings.pause_on_window_close]
|
||||||
|
en = "Pause audio playback when the window is closed"
|
||||||
|
ru = "Останавливать воспроизведение при закрытии окна"
|
||||||
|
es = "Pausar la reproducción de audio al cerrar la ventana"
|
||||||
|
fr = "Mettre en pause la lecture audio à la fermeture de la fenêtre"
|
||||||
|
zh = "关闭窗口时暂停音频播放"
|
||||||
|
ar = "إيقاف الصوت مؤقتًا عند إغلاق النافذة"
|
||||||
|
kz = "Терезе жабылған кезде дыбысты ойнатуды кідірту"
|
||||||
|
he = "השהה השמעת שמע כאשר החלון נסגר"
|
||||||
|
pt-BR = "Pausar reprodução de aúdio ao fechar a janela"
|
||||||
|
|
||||||
|
[gui.settings.version]
|
||||||
|
en = "GUI version: %{version}"
|
||||||
|
ru = "Версия GUI: %{version}"
|
||||||
|
es = "Versión de la GUI: %{version}"
|
||||||
|
fr = "Version de l'interface : %{version}"
|
||||||
|
zh = "GUI 版本: %{version}"
|
||||||
|
ar = "إصدار الواجهة: %{version}"
|
||||||
|
kz = "GUI нұсқасы: %{version}"
|
||||||
|
he = "גרסת ממשק משתמש: %{version}"
|
||||||
|
pt-BR = "Versão da GUI: %{version}"
|
||||||
|
|
||||||
|
[gui.settings.theme.label]
|
||||||
|
en = "Color Scheme"
|
||||||
|
ru = "Цветовая схема"
|
||||||
|
es = "Esquema de color"
|
||||||
|
fr = "Schéma de couleurs"
|
||||||
|
zh = "配色方案"
|
||||||
|
ar = "نظام الألوان"
|
||||||
|
kz = "Түс схемасы"
|
||||||
|
he = "ערכת צבעים"
|
||||||
|
pt-BR = "Esquema de cores"
|
||||||
|
|
||||||
|
[gui.settings.theme.system]
|
||||||
|
en = "System"
|
||||||
|
ru = "Системная"
|
||||||
|
es = "Sistema"
|
||||||
|
fr = "Système"
|
||||||
|
zh = "系统"
|
||||||
|
ar = "النظام"
|
||||||
|
kz = "Жүйе"
|
||||||
|
he = "מערכת"
|
||||||
|
pt-BR = "Sistema"
|
||||||
|
|
||||||
|
[gui.settings.theme.light]
|
||||||
|
en = "Light"
|
||||||
|
ru = "Светлая"
|
||||||
|
es = "Claro"
|
||||||
|
fr = "Clair"
|
||||||
|
zh = "浅色"
|
||||||
|
ar = "فاتح"
|
||||||
|
kz = "Жарық"
|
||||||
|
he = "בהיר"
|
||||||
|
pt-BR = "Claro"
|
||||||
|
|
||||||
|
[gui.settings.theme.dark]
|
||||||
|
en = "Dark"
|
||||||
|
ru = "Тёмная"
|
||||||
|
es = "Oscuro"
|
||||||
|
fr = "Sombre"
|
||||||
|
zh = "暗色"
|
||||||
|
ar = "داكن"
|
||||||
|
kz = "Қараңғы"
|
||||||
|
he = "כהה"
|
||||||
|
pt-BR = "Escuro"
|
||||||
|
|
||||||
|
# ----------------
|
||||||
|
# Hotkeys
|
||||||
|
# ----------------
|
||||||
|
|
||||||
|
[gui.hotkeys.header]
|
||||||
|
en = "Hotkeys"
|
||||||
|
ru = "Горячие клавиши"
|
||||||
|
es = "Atajos de teclado"
|
||||||
|
fr = "Raccourcis clavier"
|
||||||
|
zh = "快捷键"
|
||||||
|
ar = "اختصارات لوحة المفاتيح"
|
||||||
|
kz = "Ыстық пернелер"
|
||||||
|
he = "מקשי קיצור"
|
||||||
|
pt-BR = "Atalhos"
|
||||||
|
|
||||||
|
[gui.hotkeys.search_placeholder]
|
||||||
|
en = "Search hotkeys..."
|
||||||
|
ru = "Поиск горячих клавиш..."
|
||||||
|
es = "Buscar atajos..."
|
||||||
|
fr = "Rechercher des raccourcis..."
|
||||||
|
zh = "搜索快捷键..."
|
||||||
|
ar = "البحث عن الاختصارات..."
|
||||||
|
kz = "Ыстық пернелерді іздеу..."
|
||||||
|
he = "חפש מקשי קיצור..."
|
||||||
|
pt-BR = "Buscar atalhos..."
|
||||||
|
|
||||||
|
[gui.search_truncated_warning]
|
||||||
|
en = "... and %{count} more results"
|
||||||
|
ru = "... и ещё %{count} результатов"
|
||||||
|
es = "... y %{count} resultados más"
|
||||||
|
fr = "... et %{count} résultats de plus"
|
||||||
|
zh = "... 还有 %{count} 个结果"
|
||||||
|
ar = "... و %{count} نتائج أخرى"
|
||||||
|
kz = "... және тағы %{count} нәтиже"
|
||||||
|
he = "... ועוד %{count} תוצאות"
|
||||||
|
pt-BR = "... e mais %{count} resultados"
|
||||||
|
|
||||||
|
[gui.hotkeys.add_command_select]
|
||||||
|
en = "Add Command"
|
||||||
|
ru = "Добавить команду"
|
||||||
|
es = "Añadir comando"
|
||||||
|
fr = "Ajouter une commande"
|
||||||
|
zh = "添加命令"
|
||||||
|
ar = "إضافة أمر"
|
||||||
|
kz = "Команда қосу"
|
||||||
|
he = "הוסף פקודה"
|
||||||
|
pt-BR = "Adicionar comando"
|
||||||
|
|
||||||
|
[gui.hotkeys.toggle_pause_command]
|
||||||
|
en = "Toggle Pause"
|
||||||
|
ru = "Переключить паузу"
|
||||||
|
es = "Alternar pausa"
|
||||||
|
fr = "Basculer la pause"
|
||||||
|
zh = "切换暂停"
|
||||||
|
ar = "تبديل الإيقاف المؤقت"
|
||||||
|
kz = "Кідіртуді ауыстыру"
|
||||||
|
he = "הפעל/השהה"
|
||||||
|
pt-BR = "Alternar reprodução"
|
||||||
|
|
||||||
|
[gui.hotkeys.stop_playback_command]
|
||||||
|
en = "Stop Playback"
|
||||||
|
ru = "Остановить воспроизведение"
|
||||||
|
es = "Detener reproducción"
|
||||||
|
fr = "Arrêter la lecture"
|
||||||
|
zh = "停止播放"
|
||||||
|
ar = "إيقاف التشغيل"
|
||||||
|
kz = "Ойнатуды тоқтату"
|
||||||
|
he = "עצור השמעה"
|
||||||
|
pt-BR = "Parar reprodução"
|
||||||
|
|
||||||
|
[gui.hotkeys.pause_playback_command]
|
||||||
|
en = "Pause Playback"
|
||||||
|
ru = "Поставить воспроизведение на паузу"
|
||||||
|
es = "Pausar reproducción"
|
||||||
|
fr = "Mettre en pause la lecture"
|
||||||
|
zh = "暂停播放"
|
||||||
|
ar = "إيقاف التشغيل مؤقتاً"
|
||||||
|
kz = "Ойнатуды кідірту"
|
||||||
|
he = "השהה השמעה"
|
||||||
|
pt-BR = "Pausar reprodução"
|
||||||
|
|
||||||
|
[gui.hotkeys.resume_playback_command]
|
||||||
|
en = "Resume Playback"
|
||||||
|
ru = "Продолжить воспроизведение"
|
||||||
|
es = "Reanudar reproducción"
|
||||||
|
fr = "Reprendre la lecture"
|
||||||
|
zh = "恢复播放"
|
||||||
|
ar = "استئناف التشغيل"
|
||||||
|
kz = "Ойнатуды жалғастыру"
|
||||||
|
he = "המשך השמעה"
|
||||||
|
pt-BR = "Resumir reprodução"
|
||||||
|
|
||||||
|
[gui.hotkeys.toggle_loop_command]
|
||||||
|
en = "Toggle Loop"
|
||||||
|
ru = "Переключить зацикливание"
|
||||||
|
es = "Alternar bucle"
|
||||||
|
fr = "Basculer la boucle"
|
||||||
|
zh = "切换循环"
|
||||||
|
ar = "تبديل التكرار"
|
||||||
|
kz = "Қайталауды ауыстыру"
|
||||||
|
he = "הפעל/כבה לולאה"
|
||||||
|
pt-BR = "Alternar loop"
|
||||||
|
|
||||||
|
[gui.hotkeys.column_slot]
|
||||||
|
en = "Slot"
|
||||||
|
ru = "Слот"
|
||||||
|
es = "Ranura"
|
||||||
|
fr = "Emplacement"
|
||||||
|
zh = "插槽"
|
||||||
|
ar = "الخانة"
|
||||||
|
kz = "Ұяшық"
|
||||||
|
he = "משבצת"
|
||||||
|
pt-BR = "Slot"
|
||||||
|
|
||||||
|
[gui.hotkeys.column_sound]
|
||||||
|
en = "Sound"
|
||||||
|
ru = "Звук"
|
||||||
|
es = "Sonido"
|
||||||
|
fr = "Son"
|
||||||
|
zh = "声音"
|
||||||
|
ar = "الصوت"
|
||||||
|
kz = "Дыбыс"
|
||||||
|
he = "צליל"
|
||||||
|
pt-BR = "Som"
|
||||||
|
|
||||||
|
[gui.hotkeys.column_key_chord]
|
||||||
|
en = "Key Chord"
|
||||||
|
ru = "Клавиша"
|
||||||
|
es = "Combinación de teclas"
|
||||||
|
fr = "Combinaison de touches"
|
||||||
|
zh = "组合键"
|
||||||
|
ar = "تركيبة المفاتيح"
|
||||||
|
kz = "Пернелер тіркесімі"
|
||||||
|
he = "צירוף מקשים"
|
||||||
|
pt-BR = "Combinação de teclas"
|
||||||
|
|
||||||
|
[gui.hotkeys.column_actions]
|
||||||
|
en = "Actions"
|
||||||
|
ru = "Действия"
|
||||||
|
es = "Acciones"
|
||||||
|
fr = "Actions"
|
||||||
|
zh = "操作"
|
||||||
|
ar = "الإجراءات"
|
||||||
|
kz = "Әрекеттер"
|
||||||
|
he = "פעולות"
|
||||||
|
pt-BR = "Ações"
|
||||||
|
|
||||||
|
[gui.hotkeys.no_hotkeys_configured]
|
||||||
|
en = "No hotkeys configured"
|
||||||
|
ru = "Горячие клавиши не настроены"
|
||||||
|
es = "No hay atajos configurados"
|
||||||
|
fr = "Aucun raccourci configuré"
|
||||||
|
zh = "未配置快捷键"
|
||||||
|
ar = "لا توجد اختصارات معينة"
|
||||||
|
kz = "Ыстық пернелер бапталмаған"
|
||||||
|
he = "לא הוגדרו מקשי קיצור"
|
||||||
|
pt-BR = "Nenhum atalho configurado"
|
||||||
|
|
||||||
|
[gui.hotkeys.capture.header]
|
||||||
|
en = "Press a key combination (e.g. Ctrl+Alt+1)"
|
||||||
|
ru = "Нажмите сочетание клавиш (например, Ctrl+Alt+1)"
|
||||||
|
es = "Presione una combinación de teclas (ej. Ctrl+Alt+1)"
|
||||||
|
fr = "Appuyez sur une combinaison de touches (ex. Ctrl+Alt+1)"
|
||||||
|
zh = "按下一个组合键 (例如 Ctrl+Alt+1)"
|
||||||
|
ar = "اضغط على تركيبة مفاتيح (مثلاً Ctrl+Alt+1)"
|
||||||
|
kz = "Пернелер тіркесімін басыңыз (мысалы, Ctrl+Alt+1)"
|
||||||
|
he = "לחץ על צירוף מקשים (למשל Ctrl+Alt+1)"
|
||||||
|
pt-BR = "Pressione uma combinação de tecla (ex: Ctrl+Alt+1)"
|
||||||
|
|
||||||
|
[gui.hotkeys.capture.for]
|
||||||
|
en = "for"
|
||||||
|
ru = "для"
|
||||||
|
es = "para"
|
||||||
|
fr = "pour"
|
||||||
|
zh = "用于"
|
||||||
|
ar = "لـ"
|
||||||
|
kz = "үшін"
|
||||||
|
he = "עבור"
|
||||||
|
pt-BR = "para"
|
||||||
|
|
||||||
|
[gui.hotkeys.capture.cancel]
|
||||||
|
en = "Press Escape to cancel"
|
||||||
|
ru = "Нажмите Escape для отмены"
|
||||||
|
es = "Presione Escape para cancelar"
|
||||||
|
fr = "Appuyez sur Échap pour annuler"
|
||||||
|
zh = "按 Escape 取消"
|
||||||
|
ar = "اضغط Esc للإلغاء"
|
||||||
|
kz = "Болдырмау үшін Escape пернесін басыңыз"
|
||||||
|
he = "לחץ על Escape לביטול"
|
||||||
|
pt-BR = "Pressione Esc para cancelar"
|
||||||
|
|
||||||
|
[gui.choose_output_select]
|
||||||
|
en = "Select output"
|
||||||
|
ru = "Выбрать вывод"
|
||||||
|
es = "Seleccionar salida"
|
||||||
|
fr = "Sélectionner la sortie"
|
||||||
|
zh = "选择输出"
|
||||||
|
ar = "اختر المخرج"
|
||||||
|
kz = "Шығысты таңдау"
|
||||||
|
he = "בחר פלט"
|
||||||
|
pt-BR = "Selecionar saída"
|
||||||
|
|
||||||
|
[gui.default_output]
|
||||||
|
en = "System default"
|
||||||
|
ru = "Системный по умолчанию"
|
||||||
|
es = "Predeterminado del sistema"
|
||||||
|
fr = "Défaut du système"
|
||||||
|
zh = "系统默认"
|
||||||
|
ar = "افتراضي النظام"
|
||||||
|
kz = "Жүйелік әдепкі"
|
||||||
|
he = "ברירת מחדל של המערכת"
|
||||||
|
pt-BR = "Padrão do sistema"
|
||||||
|
|
||||||
|
[gui.monitoring_volume]
|
||||||
|
en = "Monitoring volume"
|
||||||
|
ru = "Громкость мониторинга"
|
||||||
|
es = "Volumen de monitorización"
|
||||||
|
fr = "Volume d'écoute"
|
||||||
|
zh = "监听音量"
|
||||||
|
ar = "مستوى صوت المراقبة"
|
||||||
|
kz = "Мониторинг дыбысы"
|
||||||
|
he = "עוצמת ניטור"
|
||||||
|
pt-BR = "Volume de monitoramento"
|
||||||
|
|
||||||
|
[gui.mic_volume]
|
||||||
|
en = "Microphone volume"
|
||||||
|
ru = "Громкость микрофона"
|
||||||
|
es = "Volumen del micrófono"
|
||||||
|
fr = "Volume du microphone"
|
||||||
|
zh = "麦克风音量"
|
||||||
|
ar = "مستوى صوت الميكروفون"
|
||||||
|
kz = "Микрофон дыбысы"
|
||||||
|
he = "עוצמת מיקרופון"
|
||||||
|
pt-BR = "Volume do microfone"
|
||||||
|
|
||||||
|
[gui.settings.language.label]
|
||||||
|
en = "Language"
|
||||||
|
ru = "Язык"
|
||||||
|
es = "Idioma"
|
||||||
|
fr = "Langue"
|
||||||
|
zh = "语言"
|
||||||
|
ar = "اللغة"
|
||||||
|
kz = "Тіл"
|
||||||
|
he = "שפה"
|
||||||
|
pt-BR = "Idioma"
|
||||||
|
|
||||||
|
[gui.settings.language.system]
|
||||||
|
en = "System"
|
||||||
|
ru = "Системный"
|
||||||
|
es = "Sistema"
|
||||||
|
fr = "Système"
|
||||||
|
zh = "系统"
|
||||||
|
ar = "النظام"
|
||||||
|
kz = "Жүйе"
|
||||||
|
he = "מערכת"
|
||||||
|
pt-BR = "Sistema"
|
||||||
Executable
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ "$1" = "configure" ]; then
|
||||||
|
if [ -x "/usr/bin/systemctl" ]; then
|
||||||
|
systemctl --global enable pwsp-daemon.service || true
|
||||||
|
|
||||||
|
for uid_dir in /run/user/*; do
|
||||||
|
if [ -d "$uid_dir/systemd" ]; then
|
||||||
|
uid=$(basename "$uid_dir")
|
||||||
|
if [ "$uid" != "0" ]; then
|
||||||
|
username=$(id -un "$uid" 2>/dev/null) || continue
|
||||||
|
su -s /bin/sh -c "XDG_RUNTIME_DIR=$uid_dir systemctl --user daemon-reload" "$username" || true
|
||||||
|
su -s /bin/sh -c "XDG_RUNTIME_DIR=$uid_dir systemctl --user try-restart pwsp-daemon.service" "$username" || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
Executable
+18
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ "$1" = "remove" ]; then
|
||||||
|
if [ -x "/usr/bin/systemctl" ]; then
|
||||||
|
for uid_dir in /run/user/*; do
|
||||||
|
if [ -d "$uid_dir/systemd" ]; then
|
||||||
|
uid=$(basename "$uid_dir")
|
||||||
|
if [ "$uid" != "0" ]; then
|
||||||
|
username=$(id -un "$uid" 2>/dev/null) || continue
|
||||||
|
su -s /bin/sh -c "XDG_RUNTIME_DIR=$uid_dir systemctl --user daemon-reload" "$username" || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
Executable
+20
@@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ "$1" = "remove" ] || [ "$1" = "deconfigure" ]; then
|
||||||
|
if [ -x "/usr/bin/systemctl" ]; then
|
||||||
|
systemctl --global disable pwsp-daemon.service || true
|
||||||
|
|
||||||
|
for uid_dir in /run/user/*; do
|
||||||
|
if [ -d "$uid_dir/systemd" ]; then
|
||||||
|
uid=$(basename "$uid_dir")
|
||||||
|
if [ "$uid" != "0" ]; then
|
||||||
|
username=$(id -un "$uid" 2>/dev/null) || continue
|
||||||
|
su -s /bin/sh -c "XDG_RUNTIME_DIR=$uid_dir systemctl --user stop pwsp-daemon.service" "$username" || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
use crate::gui::SoundpadGui;
|
use crate::gui::SoundpadGui;
|
||||||
use egui::{Context, Id, Key, Modifiers};
|
use egui::{Context, Id, Key, Modifiers};
|
||||||
use pwsp::types::socket::Request;
|
use pwsp_lib::types::socket::Request;
|
||||||
use pwsp::utils::gui::make_request_async;
|
use pwsp_lib::utils::gui::make_request_async;
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
/// Convert an egui Key + Modifiers to a normalized chord string like "Ctrl+Shift+A".
|
/// Convert an egui Key + Modifiers to a normalized chord string like "Ctrl+Shift+A".
|
||||||
fn chord_from_event(modifiers: &Modifiers, key: &Key) -> Option<String> {
|
fn chord_from_event(modifiers: &Modifiers, key: &Key) -> Option<String> {
|
||||||
@@ -187,82 +185,15 @@ impl SoundpadGui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Focus search field
|
// Focus search field
|
||||||
if self.key_pressed(ctx, Key::Slash) {
|
if modifiers.ctrl && self.key_pressed(ctx, Key::F) {
|
||||||
if search_focused {
|
self.app_state.force_focus_search = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unfocus search field
|
||||||
|
if search_focused && self.key_pressed(ctx, Key::Escape) {
|
||||||
ctx.memory_mut(|m| {
|
ctx.memory_mut(|m| {
|
||||||
m.request_focus(Id::NULL);
|
m.request_focus(Id::NULL);
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
self.app_state.force_focus_search = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Play selected file on Enter
|
|
||||||
if self.key_pressed(ctx, Key::Enter)
|
|
||||||
&& let Some(path) = self.app_state.selected_file.clone()
|
|
||||||
{
|
|
||||||
if modifiers.ctrl {
|
|
||||||
self.play_file(&path, true);
|
|
||||||
} else if modifiers.shift
|
|
||||||
&& let Some(last_track) = self.audio_player_state.tracks.last()
|
|
||||||
{
|
|
||||||
self.stop(Some(last_track.id));
|
|
||||||
self.play_file(&path, true);
|
|
||||||
} else {
|
|
||||||
self.play_file(&path, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Iterate through dirs and files with Ctrl + Up/Down
|
|
||||||
let arrow_up_pressed = self.key_pressed(ctx, Key::ArrowUp);
|
|
||||||
let arrow_down_pressed = self.key_pressed(ctx, Key::ArrowDown);
|
|
||||||
if modifiers.ctrl && (arrow_up_pressed || arrow_down_pressed) {
|
|
||||||
if modifiers.shift && !self.app_state.dirs.is_empty() {
|
|
||||||
let mut dirs: Vec<PathBuf> = self.app_state.dirs.to_vec();
|
|
||||||
dirs.sort();
|
|
||||||
|
|
||||||
let current_dir_index = self
|
|
||||||
.app_state
|
|
||||||
.current_dir
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|cd| dirs.iter().position(|x| x == cd));
|
|
||||||
|
|
||||||
let new_dir_index =
|
|
||||||
match (current_dir_index, arrow_up_pressed, arrow_down_pressed) {
|
|
||||||
(Some(i), true, false) => (i + dirs.len() - 1) % dirs.len(),
|
|
||||||
(Some(i), false, true) => (i + 1) % dirs.len(),
|
|
||||||
(Some(i), true, true) => i,
|
|
||||||
(None, true, _) => dirs.len() - 1,
|
|
||||||
(None, false, true) => 0,
|
|
||||||
_ => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
self.open_dir(&dirs[new_dir_index]);
|
|
||||||
} else if self.app_state.current_dir.is_some() {
|
|
||||||
let files = self.get_filtered_files();
|
|
||||||
|
|
||||||
if files.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let current_files_index = self
|
|
||||||
.app_state
|
|
||||||
.selected_file
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|f| files.iter().position(|x| x == f));
|
|
||||||
|
|
||||||
let new_files_index =
|
|
||||||
match (current_files_index, arrow_up_pressed, arrow_down_pressed) {
|
|
||||||
(Some(i), true, false) => (i + files.len() - 1) % files.len(),
|
|
||||||
(Some(i), false, true) => (i + 1) % files.len(),
|
|
||||||
(Some(i), true, true) => i,
|
|
||||||
(None, true, _) => files.len() - 1,
|
|
||||||
(None, false, true) => 0,
|
|
||||||
_ => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
self.app_state.selected_file = Some(files[new_files_index].clone());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for hotkey chord triggers
|
// Check for hotkey chord triggers
|
||||||
@@ -287,3 +218,70 @@ impl SoundpadGui {
|
|||||||
// });
|
// });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use egui::{Key, Modifiers};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_chord_from_event() {
|
||||||
|
// Valid modifier + key
|
||||||
|
let mut mods = Modifiers::NONE;
|
||||||
|
mods.ctrl = true;
|
||||||
|
let chord = chord_from_event(&mods, &Key::A);
|
||||||
|
assert_eq!(chord, Some("Ctrl+A".to_string()));
|
||||||
|
|
||||||
|
// Multiple modifiers
|
||||||
|
mods.shift = true;
|
||||||
|
let chord = chord_from_event(&mods, &Key::F1);
|
||||||
|
assert_eq!(chord, Some("Ctrl+Shift+F1".to_string()));
|
||||||
|
|
||||||
|
// Missing modifiers (requires at least one modifier)
|
||||||
|
let no_mods = Modifiers::NONE;
|
||||||
|
let chord = chord_from_event(&no_mods, &Key::A);
|
||||||
|
assert_eq!(chord, None);
|
||||||
|
|
||||||
|
// Invalid keys (e.g. Escape or Enter shouldn't be accepted by chord_from_event)
|
||||||
|
mods.shift = false;
|
||||||
|
let chord = chord_from_event(&mods, &Key::Escape);
|
||||||
|
assert_eq!(chord, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_chord() {
|
||||||
|
// Valid Ctrl+A
|
||||||
|
let res = parse_chord("Ctrl+A");
|
||||||
|
assert!(res.is_some());
|
||||||
|
let (mods, key) = res.unwrap();
|
||||||
|
assert!(mods.ctrl);
|
||||||
|
assert!(!mods.alt);
|
||||||
|
assert!(!mods.shift);
|
||||||
|
assert_eq!(key, Key::A);
|
||||||
|
|
||||||
|
// Valid Ctrl+Shift+F12
|
||||||
|
let res = parse_chord("Ctrl+Shift+F12");
|
||||||
|
assert!(res.is_some());
|
||||||
|
let (mods, key) = res.unwrap();
|
||||||
|
assert!(mods.ctrl);
|
||||||
|
assert!(mods.shift);
|
||||||
|
assert!(!mods.alt);
|
||||||
|
assert_eq!(key, Key::F12);
|
||||||
|
|
||||||
|
// Valid Ctrl+Alt+Shift+Super+B
|
||||||
|
let res = parse_chord("Ctrl+Alt+Shift+Super+B");
|
||||||
|
assert!(res.is_some());
|
||||||
|
let (mods, key) = res.unwrap();
|
||||||
|
assert!(mods.ctrl);
|
||||||
|
assert!(mods.alt);
|
||||||
|
assert!(mods.shift);
|
||||||
|
assert!(mods.command); // Super maps to command in egui Modifiers
|
||||||
|
assert_eq!(key, Key::B);
|
||||||
|
|
||||||
|
// Invalid keys/chords
|
||||||
|
assert!(parse_chord("").is_none());
|
||||||
|
assert!(parse_chord("Ctrl+").is_none());
|
||||||
|
assert!(parse_chord("Ctrl+Escape").is_none());
|
||||||
|
assert!(parse_chord("Invalid+A").is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,501 @@
|
|||||||
|
mod input;
|
||||||
|
mod update;
|
||||||
|
mod views;
|
||||||
|
|
||||||
|
use anyhow::{Result, anyhow};
|
||||||
|
use eframe::{NativeOptions, icon_data::from_png_bytes, run_native};
|
||||||
|
use egui::{Context, FontData, FontDefinitions, FontFamily, FontTweak, Vec2, ViewportBuilder};
|
||||||
|
use itertools::Itertools;
|
||||||
|
use pwsp_lib::{
|
||||||
|
types::{
|
||||||
|
audio_player::PlayerState,
|
||||||
|
config::{GuiConfig, HotkeyConfig},
|
||||||
|
gui::{AppState, AudioPlayerState},
|
||||||
|
socket::Request,
|
||||||
|
},
|
||||||
|
utils::gui::{
|
||||||
|
get_daemon_config, get_gui_config, make_request_async, make_request_sync,
|
||||||
|
start_app_state_thread, update_daemon_config,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use rfd::FileDialog;
|
||||||
|
use std::{
|
||||||
|
cmp::Ordering,
|
||||||
|
collections::{HashMap, HashSet},
|
||||||
|
fs,
|
||||||
|
hash::{DefaultHasher, Hash, Hasher},
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
thread,
|
||||||
|
};
|
||||||
|
use system_fonts::{FontStyle, FoundFontSource, find_for_locale};
|
||||||
|
|
||||||
|
const SUPPORTED_EXTENSIONS: [&str; 13] = [
|
||||||
|
"mp3", "wav", "ogg", "flac", "mp4", "m4a", "aac", "mov", "mkv", "mka", "webm", "avi", "opus",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn get_cache_file_for(path: &Path) -> PathBuf {
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
path.hash(&mut hasher);
|
||||||
|
let hash = hasher.finish();
|
||||||
|
dirs::cache_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
||||||
|
.join("pwsp")
|
||||||
|
.join(format!("{:x}.json", hash))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SoundpadGui {
|
||||||
|
pub app_state: AppState,
|
||||||
|
pub config: GuiConfig,
|
||||||
|
pub audio_player_state: AudioPlayerState,
|
||||||
|
pub audio_player_state_shared: Arc<Mutex<AudioPlayerState>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SoundpadGui {
|
||||||
|
fn new(ctx: &Context) -> Self {
|
||||||
|
let audio_player_state = Arc::new(Mutex::new(AudioPlayerState::default()));
|
||||||
|
start_app_state_thread(audio_player_state.clone());
|
||||||
|
|
||||||
|
let config = get_gui_config();
|
||||||
|
|
||||||
|
ctx.set_zoom_factor(config.scale_factor);
|
||||||
|
|
||||||
|
let mut soundpad_gui = SoundpadGui {
|
||||||
|
app_state: AppState::default(),
|
||||||
|
config: config.clone(),
|
||||||
|
audio_player_state: AudioPlayerState::default(),
|
||||||
|
audio_player_state_shared: audio_player_state.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
soundpad_gui.app_state.dirs = config.dirs;
|
||||||
|
soundpad_gui.app_state.hotkey_config = HotkeyConfig::load().unwrap_or_default();
|
||||||
|
|
||||||
|
soundpad_gui
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn play_toggle(&mut self) {
|
||||||
|
let (new_state, request) = {
|
||||||
|
let guard = self
|
||||||
|
.audio_player_state_shared
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner());
|
||||||
|
match guard.state {
|
||||||
|
PlayerState::Playing => (Some(PlayerState::Paused), Some(Request::pause(None))),
|
||||||
|
PlayerState::Paused => (Some(PlayerState::Playing), Some(Request::resume(None))),
|
||||||
|
PlayerState::Stopped => (None, None),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(req) = request {
|
||||||
|
make_request_async(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(state) = new_state {
|
||||||
|
let mut guard = self
|
||||||
|
.audio_player_state_shared
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner());
|
||||||
|
guard.new_state = Some(state.clone());
|
||||||
|
guard.state = state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open_file(&mut self) {
|
||||||
|
let file_dialog = FileDialog::new().add_filter("Audio File", &SUPPORTED_EXTENSIONS);
|
||||||
|
if let Some(path) = file_dialog.pick_file() {
|
||||||
|
self.play_file(&path, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_dirs(&mut self) {
|
||||||
|
let file_dialog = FileDialog::new();
|
||||||
|
if let Some(paths) = file_dialog.pick_folders() {
|
||||||
|
for path in paths {
|
||||||
|
self.app_state.dirs.push(path);
|
||||||
|
}
|
||||||
|
self.app_state.dirs = self.app_state.dirs.iter().unique().cloned().collect();
|
||||||
|
self.config.dirs = self.app_state.dirs.clone();
|
||||||
|
self.config.save_to_file().ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open_dir(&mut self, path: &PathBuf, ctx: Option<Context>, force_rescan: bool) {
|
||||||
|
self.app_state.current_dir = Some(path.clone());
|
||||||
|
|
||||||
|
if !self.app_state.dir_cache.contains_key(path) || force_rescan {
|
||||||
|
match fs::read_dir(path) {
|
||||||
|
Ok(read_dir) => {
|
||||||
|
let files = read_dir
|
||||||
|
.filter_map(|res| res.ok())
|
||||||
|
.map(|entry| entry.path())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
self.app_state.listed_files = files.iter().cloned().collect();
|
||||||
|
self.app_state.dir_cache.insert(path.clone(), files);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to read directory {:?}: {}", path, e);
|
||||||
|
self.app_state.listed_files.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.app_state.listed_files = self
|
||||||
|
.app_state
|
||||||
|
.dir_cache
|
||||||
|
.get(path)
|
||||||
|
.unwrap()
|
||||||
|
.clone()
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_scanning = self.app_state.scanning_dirs.lock().unwrap().contains(path);
|
||||||
|
let is_cached = self.app_state.recursive_files_cache.contains_key(path);
|
||||||
|
let has_scanned_this_session = self.app_state.scanned_this_session.contains(path);
|
||||||
|
|
||||||
|
let needs_scan = force_rescan || !has_scanned_this_session || !is_cached;
|
||||||
|
|
||||||
|
if !is_scanning && needs_scan {
|
||||||
|
self.app_state
|
||||||
|
.scanning_dirs
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(path.clone());
|
||||||
|
self.app_state.scanned_this_session.insert(path.clone());
|
||||||
|
|
||||||
|
let finished_scans = self.app_state.finished_scans.clone();
|
||||||
|
let scanning_dirs = self.app_state.scanning_dirs.clone();
|
||||||
|
let path_clone = path.clone();
|
||||||
|
|
||||||
|
thread::spawn(move || {
|
||||||
|
let cache_file = get_cache_file_for(&path_clone);
|
||||||
|
|
||||||
|
// 1. Try to load from disk cache if we don't have it in memory yet
|
||||||
|
if !is_cached
|
||||||
|
&& let Ok(data) = fs::read_to_string(&cache_file)
|
||||||
|
&& let Ok((all_files, dir_updates)) = serde_json::from_str::<(
|
||||||
|
Vec<PathBuf>,
|
||||||
|
HashMap<PathBuf, Vec<PathBuf>>,
|
||||||
|
)>(&data)
|
||||||
|
{
|
||||||
|
finished_scans.lock().unwrap().push((
|
||||||
|
path_clone.clone(),
|
||||||
|
all_files,
|
||||||
|
dir_updates,
|
||||||
|
));
|
||||||
|
if let Some(ctx) = ctx.as_ref() {
|
||||||
|
ctx.request_repaint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Scan recursively
|
||||||
|
let mut all_files = Vec::new();
|
||||||
|
let mut dir_updates = HashMap::new();
|
||||||
|
let mut dirs_to_visit = vec![path_clone.clone()];
|
||||||
|
|
||||||
|
while let Some(dir) = dirs_to_visit.pop() {
|
||||||
|
if let Ok(entries) = fs::read_dir(&dir) {
|
||||||
|
let mut children = Vec::new();
|
||||||
|
for entry in entries.filter_map(|e| e.ok()) {
|
||||||
|
let p = entry.path();
|
||||||
|
if p.is_dir() {
|
||||||
|
dirs_to_visit.push(p.clone());
|
||||||
|
children.push(p);
|
||||||
|
} else if crate::gui::SUPPORTED_EXTENSIONS.contains(
|
||||||
|
&p.extension()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_str()
|
||||||
|
.unwrap_or_default(),
|
||||||
|
) {
|
||||||
|
all_files.push(p.clone());
|
||||||
|
children.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dir_updates.insert(dir, children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Save to disk cache
|
||||||
|
if let Some(parent) = cache_file.parent() {
|
||||||
|
fs::create_dir_all(parent).ok();
|
||||||
|
}
|
||||||
|
if let Ok(json) = serde_json::to_string(&(all_files.clone(), dir_updates.clone())) {
|
||||||
|
fs::write(&cache_file, json).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Send to UI
|
||||||
|
finished_scans
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push((path_clone.clone(), all_files, dir_updates));
|
||||||
|
scanning_dirs.lock().unwrap().remove(&path_clone);
|
||||||
|
if let Some(ctx) = ctx {
|
||||||
|
ctx.request_repaint();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn play_file(&mut self, path: &Path, concurrent: bool) {
|
||||||
|
make_request_async(Request::play(&path.to_string_lossy(), concurrent));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_input(&mut self, name: String) {
|
||||||
|
make_request_async(Request::set_input(&name));
|
||||||
|
|
||||||
|
if self.config.save_input
|
||||||
|
&& let Ok(mut daemon_config) = get_daemon_config()
|
||||||
|
{
|
||||||
|
daemon_config.default_input_name = Some(name);
|
||||||
|
update_daemon_config(&daemon_config).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_output(&mut self, name: String) {
|
||||||
|
make_request_async(Request::set_output(&name));
|
||||||
|
|
||||||
|
if self.config.save_input
|
||||||
|
&& let Ok(mut daemon_config) = get_daemon_config()
|
||||||
|
{
|
||||||
|
// Empty means "follow the system default", which is stored as no device.
|
||||||
|
daemon_config.default_output_name = Some(name).filter(|n| !n.is_empty());
|
||||||
|
update_daemon_config(&daemon_config).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_loop(&mut self, id: Option<u32>) {
|
||||||
|
make_request_async(Request::toggle_loop(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pause(&mut self, id: Option<u32>) {
|
||||||
|
make_request_async(Request::pause(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resume(&mut self, id: Option<u32>) {
|
||||||
|
make_request_async(Request::resume(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop(&mut self, id: Option<u32>) {
|
||||||
|
make_request_async(Request::stop(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn play_hotkey_slot(&mut self, slot: &str) {
|
||||||
|
make_request_async(Request::play_hotkey(slot));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_filtered_files(
|
||||||
|
&self,
|
||||||
|
matching_dirs: &HashSet<PathBuf>,
|
||||||
|
matching_files: &HashSet<PathBuf>,
|
||||||
|
) -> Vec<PathBuf> {
|
||||||
|
let search_query = self.app_state.search_query.to_lowercase();
|
||||||
|
let search_query = search_query.trim();
|
||||||
|
|
||||||
|
let mut files: Vec<PathBuf> = self.app_state.listed_files.iter().cloned().collect();
|
||||||
|
let sort_order = self
|
||||||
|
.app_state
|
||||||
|
.current_dir
|
||||||
|
.as_ref()
|
||||||
|
.map(|d| self.config.get_sort_order(d))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
files.sort_by(|a, b| {
|
||||||
|
let a_is_dir = a.is_dir();
|
||||||
|
let b_is_dir = b.is_dir();
|
||||||
|
if a_is_dir && !b_is_dir {
|
||||||
|
Ordering::Less
|
||||||
|
} else if !a_is_dir && b_is_dir {
|
||||||
|
Ordering::Greater
|
||||||
|
} else {
|
||||||
|
sort_order.compare(a, b)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let is_cached = self
|
||||||
|
.app_state
|
||||||
|
.current_dir
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|d| self.app_state.recursive_files_cache.contains_key(d));
|
||||||
|
|
||||||
|
if !search_query.is_empty() && is_cached {
|
||||||
|
files
|
||||||
|
.into_iter()
|
||||||
|
.filter(|p| {
|
||||||
|
if p.is_dir() {
|
||||||
|
matching_dirs.contains(p)
|
||||||
|
} else {
|
||||||
|
matching_files.contains(p)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
files
|
||||||
|
.into_iter()
|
||||||
|
.filter(|entry_path| {
|
||||||
|
if !entry_path.is_dir()
|
||||||
|
&& !SUPPORTED_EXTENSIONS.contains(
|
||||||
|
&entry_path
|
||||||
|
.extension()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_str()
|
||||||
|
.unwrap_or_default(),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !search_query.is_empty() {
|
||||||
|
let file_name =
|
||||||
|
entry_path.file_name().unwrap_or_default().to_string_lossy();
|
||||||
|
if !file_name.to_lowercase().contains(search_query) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_font(font_name: &str, font_bytes: &[u8], fonts: &mut FontDefinitions) -> Result<()> {
|
||||||
|
let font_data = FontData::from_owned(font_bytes.to_vec()).tweak(FontTweak {
|
||||||
|
scale: 1.0,
|
||||||
|
hinting: Some(true),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
fonts
|
||||||
|
.font_data
|
||||||
|
.insert(font_name.to_owned(), font_data.into());
|
||||||
|
|
||||||
|
fonts
|
||||||
|
.families
|
||||||
|
.entry(FontFamily::Proportional)
|
||||||
|
.or_default()
|
||||||
|
.insert(0, font_name.to_owned());
|
||||||
|
fonts
|
||||||
|
.families
|
||||||
|
.entry(FontFamily::Monospace)
|
||||||
|
.or_default()
|
||||||
|
.insert(0, font_name.to_owned());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_system_fonts(fonts: &mut FontDefinitions) -> Result<()> {
|
||||||
|
let (_, en_sans) = find_for_locale("en", FontStyle::Sans);
|
||||||
|
let (_, en_serif) = find_for_locale("en", FontStyle::Serif);
|
||||||
|
let (_, ja_sans) = find_for_locale("ja", FontStyle::Sans);
|
||||||
|
let (_, ar_sans) = find_for_locale("ar", FontStyle::Sans);
|
||||||
|
|
||||||
|
let system_fonts = [en_sans, en_serif, ja_sans, ar_sans].concat();
|
||||||
|
|
||||||
|
for font in system_fonts.iter().rev() {
|
||||||
|
let font_bytes = match &font.source {
|
||||||
|
FoundFontSource::Path(path) => fs::read(path)?,
|
||||||
|
FoundFontSource::Bytes(bytes) => bytes.to_vec(),
|
||||||
|
};
|
||||||
|
|
||||||
|
add_font(&font.key, &font_bytes, fonts)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run() -> Result<()> {
|
||||||
|
const ICON: &[u8] = include_bytes!("../../assets/icon.png");
|
||||||
|
|
||||||
|
let options = NativeOptions {
|
||||||
|
centered: true,
|
||||||
|
|
||||||
|
viewport: ViewportBuilder::default()
|
||||||
|
.with_app_id("ru.arabianq.pwsp")
|
||||||
|
.with_inner_size(Vec2::new(1200.0, 800.0))
|
||||||
|
.with_min_inner_size(Vec2::new(800.0, 600.0))
|
||||||
|
.with_icon(from_png_bytes(ICON)?),
|
||||||
|
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
match run_native(
|
||||||
|
"Pipewire Soundpad",
|
||||||
|
options,
|
||||||
|
Box::new(|cc| {
|
||||||
|
egui_material_icons::initialize(&cc.egui_ctx);
|
||||||
|
|
||||||
|
let mut fonts = FontDefinitions::default();
|
||||||
|
load_system_fonts(&mut fonts).ok();
|
||||||
|
cc.egui_ctx.set_fonts(fonts);
|
||||||
|
|
||||||
|
Ok(Box::new(SoundpadGui::new(&cc.egui_ctx)))
|
||||||
|
}),
|
||||||
|
) {
|
||||||
|
Ok(_) => {
|
||||||
|
let config = get_gui_config();
|
||||||
|
if config.pause_on_exit {
|
||||||
|
make_request_sync(Request::pause(None)).ok();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => Err(anyhow!(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_filtered_files() {
|
||||||
|
let mut gui = SoundpadGui {
|
||||||
|
app_state: AppState::default(),
|
||||||
|
config: GuiConfig::default(),
|
||||||
|
audio_player_state: AudioPlayerState::default(),
|
||||||
|
audio_player_state_shared: Arc::new(Mutex::new(AudioPlayerState::default())),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create some dummy paths
|
||||||
|
// We will mock path properties using standard Rust PathBuf
|
||||||
|
let dir_a = PathBuf::from("a_dir");
|
||||||
|
let file_b = PathBuf::from("b_file.mp3");
|
||||||
|
let file_c = PathBuf::from("c_file.wav");
|
||||||
|
let file_txt = PathBuf::from("invalid.txt");
|
||||||
|
|
||||||
|
gui.app_state.listed_files.insert(dir_a.clone());
|
||||||
|
gui.app_state.listed_files.insert(file_b.clone());
|
||||||
|
gui.app_state.listed_files.insert(file_c.clone());
|
||||||
|
gui.app_state.listed_files.insert(file_txt.clone());
|
||||||
|
|
||||||
|
// Note: is_dir() check in get_filtered_files relies on physical filesystem properties.
|
||||||
|
// On the real OS filesystem, these paths don't exist, so they are treated as files.
|
||||||
|
// Unsupported extensions (like .txt) will be filtered out.
|
||||||
|
// So we expect only file_b and file_c, sorted alphabetically.
|
||||||
|
let empty_dirs = HashSet::new();
|
||||||
|
let empty_files = HashSet::new();
|
||||||
|
let filtered = gui.get_filtered_files(&empty_dirs, &empty_files);
|
||||||
|
assert_eq!(filtered.len(), 2);
|
||||||
|
assert_eq!(filtered[0], file_b);
|
||||||
|
assert_eq!(filtered[1], file_c);
|
||||||
|
|
||||||
|
// Test search query
|
||||||
|
gui.app_state.search_query = "c_fi".to_string();
|
||||||
|
let filtered_search = gui.get_filtered_files(&empty_dirs, &empty_files);
|
||||||
|
assert_eq!(filtered_search.len(), 1);
|
||||||
|
assert_eq!(filtered_search[0], file_c);
|
||||||
|
|
||||||
|
// Test sort order descending
|
||||||
|
gui.app_state.current_dir = Some(PathBuf::from("dummy_dir"));
|
||||||
|
gui.config.dirs_settings.insert(
|
||||||
|
PathBuf::from("dummy_dir"),
|
||||||
|
pwsp_lib::types::config::DirSettings {
|
||||||
|
sort_order: pwsp_lib::types::config::SortOrder::AlphabeticalDesc,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
gui.app_state.search_query = String::new();
|
||||||
|
let filtered_desc = gui.get_filtered_files(&empty_dirs, &empty_files);
|
||||||
|
assert_eq!(filtered_desc.len(), 2);
|
||||||
|
assert_eq!(filtered_desc[0], file_c);
|
||||||
|
assert_eq!(filtered_desc[1], file_b);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
use crate::gui::SoundpadGui;
|
||||||
|
use eframe::{App, Frame as EFrame};
|
||||||
|
use egui::{CentralPanel, Context, ThemePreference};
|
||||||
|
use pwsp_lib::{
|
||||||
|
types::{
|
||||||
|
config::{DaemonConfig, PreferredTheme},
|
||||||
|
socket::Request,
|
||||||
|
},
|
||||||
|
utils::gui::{get_daemon_config, make_request_async, update_daemon_config},
|
||||||
|
};
|
||||||
|
|
||||||
|
impl SoundpadGui {
|
||||||
|
/// Persists a master volume to the daemon config when the user asked us to remember it.
|
||||||
|
fn remember_volume(&self, apply: impl FnOnce(&mut DaemonConfig)) {
|
||||||
|
if !self.config.save_volume {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Ok(mut daemon_config) = get_daemon_config() {
|
||||||
|
apply(&mut daemon_config);
|
||||||
|
update_daemon_config(&daemon_config).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App for SoundpadGui {
|
||||||
|
fn logic(&mut self, ctx: &Context, _frame: &mut EFrame) {
|
||||||
|
// Update theme
|
||||||
|
let current_theme = match ctx.options(|r| r.theme_preference) {
|
||||||
|
ThemePreference::System => PreferredTheme::System,
|
||||||
|
ThemePreference::Light => PreferredTheme::Light,
|
||||||
|
ThemePreference::Dark => PreferredTheme::Dark,
|
||||||
|
};
|
||||||
|
|
||||||
|
if !self.config.preferred_theme.eq(¤t_theme) {
|
||||||
|
ctx.options_mut(|w| {
|
||||||
|
w.theme_preference = match self.config.preferred_theme {
|
||||||
|
PreferredTheme::System => ThemePreference::System,
|
||||||
|
PreferredTheme::Light => ThemePreference::Light,
|
||||||
|
PreferredTheme::Dark => ThemePreference::Dark,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove directories
|
||||||
|
for path in self.app_state.dirs_to_remove.drain() {
|
||||||
|
self.app_state.dirs.retain(|x| x != &path);
|
||||||
|
if let Some(current_dir) = &self.app_state.current_dir
|
||||||
|
&& current_dir == &path
|
||||||
|
{
|
||||||
|
self.app_state.current_dir = None;
|
||||||
|
self.app_state.listed_files.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save directories if changed
|
||||||
|
if !self.config.dirs.eq(&self.app_state.dirs) {
|
||||||
|
self.config.dirs = self.app_state.dirs.clone();
|
||||||
|
self.config.save_to_file().ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-track seek and volume requests
|
||||||
|
for (id, ui_state) in &mut self.app_state.track_ui_states {
|
||||||
|
if let Some(position) = ui_state.position.take_pending() {
|
||||||
|
make_request_async(Request::seek(position, Some(*id)));
|
||||||
|
}
|
||||||
|
if let Some(volume) = ui_state.volume.take_pending() {
|
||||||
|
make_request_async(Request::set_volume(volume, Some(*id)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Master volumes
|
||||||
|
if let Some(volume) = self.app_state.monitoring_volume.take_pending() {
|
||||||
|
make_request_async(Request::set_monitoring_volume(volume));
|
||||||
|
self.remember_volume(|config| config.default_monitoring_volume = Some(volume));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(volume) = self.app_state.mic_volume.take_pending() {
|
||||||
|
make_request_async(Request::set_mic_volume(volume));
|
||||||
|
self.remember_volume(|config| config.default_mic_volume = Some(volume));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(multiplier) = self.app_state.volume_multiplier.take_pending() {
|
||||||
|
make_request_async(Request::set_volume_multiplier(multiplier));
|
||||||
|
|
||||||
|
if self.config.save_volume_multiplier
|
||||||
|
&& let Ok(mut daemon_config) = get_daemon_config()
|
||||||
|
{
|
||||||
|
daemon_config.default_volume_multiplier = Some(multiplier);
|
||||||
|
update_daemon_config(&daemon_config).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync audio player state
|
||||||
|
{
|
||||||
|
let mut guard = self
|
||||||
|
.audio_player_state_shared
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner());
|
||||||
|
if let Some(config) = guard.hotkey_config.take() {
|
||||||
|
self.app_state.hotkey_config = config;
|
||||||
|
}
|
||||||
|
self.audio_player_state = guard.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle scale factor changes
|
||||||
|
let old_scale_factor = self.config.scale_factor;
|
||||||
|
let new_scale_factor = ctx.zoom_factor().clamp(0.5, 2.0);
|
||||||
|
|
||||||
|
ctx.set_zoom_factor(new_scale_factor);
|
||||||
|
self.config.scale_factor = new_scale_factor;
|
||||||
|
|
||||||
|
if new_scale_factor != old_scale_factor && self.config.save_scale_factor {
|
||||||
|
self.config.save_to_file().ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle input
|
||||||
|
self.handle_input(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut EFrame) {
|
||||||
|
// Draw UI
|
||||||
|
CentralPanel::default().show(ui, |ui| {
|
||||||
|
if !self.audio_player_state.is_daemon_running {
|
||||||
|
self.draw_waiting_for_daemon(ui);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.app_state.hotkey_capture_active {
|
||||||
|
self.draw_hotkey_capture(ui);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.app_state.show_settings {
|
||||||
|
self.draw_settings(ui);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.app_state.show_hotkeys {
|
||||||
|
self.draw_hotkeys(ui);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.draw(ui);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request repaint
|
||||||
|
ui.request_repaint_after_secs(1.0 / 60.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,683 @@
|
|||||||
|
use crate::gui::SoundpadGui;
|
||||||
|
use egui::{
|
||||||
|
Align, AtomExt, Button, CollapsingHeader, Color32, CursorIcon, Layout, RichText, ScrollArea,
|
||||||
|
Sense, TextEdit, Ui, Vec2,
|
||||||
|
};
|
||||||
|
use egui_dnd::dnd;
|
||||||
|
use egui_material_icons::icons::*;
|
||||||
|
use pwsp_lib::types::{
|
||||||
|
config::{GuiConfig, SortOrder},
|
||||||
|
gui::{AppState, AudioPlayerState},
|
||||||
|
};
|
||||||
|
use rust_i18n::t;
|
||||||
|
use std::{cmp::Ordering, collections::HashSet, path::Path, path::PathBuf};
|
||||||
|
|
||||||
|
pub(crate) enum FileAction {
|
||||||
|
Play(PathBuf, bool),
|
||||||
|
StopAndPlay(u32, PathBuf, bool),
|
||||||
|
AssignHotkey(PathBuf),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SoundpadGui {
|
||||||
|
pub fn draw_body(&mut self, ui: &mut Ui) {
|
||||||
|
let left_panel_width = self
|
||||||
|
.config
|
||||||
|
.left_panel_width
|
||||||
|
.max(100.0)
|
||||||
|
.min(ui.available_width() - 100.0);
|
||||||
|
let dirs_size = Vec2::new(left_panel_width, ui.available_height() - 40.0);
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
self.draw_dirs(ui, dirs_size);
|
||||||
|
|
||||||
|
let (rect, response) = ui.allocate_at_least(
|
||||||
|
Vec2::new(ui.spacing().item_spacing.x, ui.available_height()),
|
||||||
|
Sense::click_and_drag(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ui.is_rect_visible(rect) {
|
||||||
|
let stroke = ui.visuals().widgets.noninteractive.bg_stroke;
|
||||||
|
ui.painter().vline(rect.center().x, rect.y_range(), stroke);
|
||||||
|
}
|
||||||
|
|
||||||
|
let vertical_separator_response =
|
||||||
|
response.on_hover_and_drag_cursor(CursorIcon::ResizeHorizontal);
|
||||||
|
|
||||||
|
if vertical_separator_response.dragged() {
|
||||||
|
self.config.left_panel_width += vertical_separator_response.drag_delta().x;
|
||||||
|
self.config.left_panel_width = self.config.left_panel_width.clamp(100.0, 500.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if vertical_separator_response.drag_stopped() {
|
||||||
|
self.config.save_to_file().ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
let files_size = Vec2::new(ui.available_width(), ui.available_height() - 40.0);
|
||||||
|
self.draw_files(ui, files_size);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_dirs(&mut self, ui: &mut Ui, area_size: Vec2) {
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
ui.set_min_width(area_size.x);
|
||||||
|
ui.set_min_height(area_size.y);
|
||||||
|
|
||||||
|
ScrollArea::vertical().id_salt(0).show(ui, |ui| {
|
||||||
|
ui.set_min_width(area_size.x);
|
||||||
|
|
||||||
|
let mut dirs = std::mem::take(&mut self.app_state.dirs);
|
||||||
|
let mut dir_to_open = None;
|
||||||
|
|
||||||
|
dnd(ui, "dnd_directories").show_vec(&mut dirs, |ui, item, handle, _state| {
|
||||||
|
let path = item;
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
handle.ui(ui, |ui| {
|
||||||
|
ui.label(ICON_DRAG_INDICATOR.codepoint);
|
||||||
|
});
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.map(|s| s.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| path.to_string_lossy().to_string());
|
||||||
|
|
||||||
|
let mut dir_button =
|
||||||
|
Button::new(RichText::new(name.clone()).atom_max_width(area_size.x))
|
||||||
|
.frame(false);
|
||||||
|
|
||||||
|
if let Some(current_dir) = &self.app_state.current_dir
|
||||||
|
&& current_dir.eq(&*path)
|
||||||
|
{
|
||||||
|
dir_button = dir_button.selected(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
let dir_button_response = ui.add(dir_button);
|
||||||
|
if dir_button_response.clicked() {
|
||||||
|
dir_to_open = Some(path.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let delete_dir_button = Button::new(ICON_DELETE).frame(false);
|
||||||
|
let delete_dir_button_response =
|
||||||
|
ui.add_sized([18.0, 18.0], delete_dir_button);
|
||||||
|
if delete_dir_button_response.clicked() {
|
||||||
|
self.app_state.dirs_to_remove.insert(path.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context menu
|
||||||
|
dir_button_response.context_menu(|ui| {
|
||||||
|
if ui
|
||||||
|
.button(format!(
|
||||||
|
"{} {}",
|
||||||
|
ICON_OPEN_IN_NEW.codepoint,
|
||||||
|
t!("gui.context.dirs.open")
|
||||||
|
))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
dir_to_open = Some(path.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ui
|
||||||
|
.button(format!(
|
||||||
|
"{} {}",
|
||||||
|
ICON_OPEN_IN_BROWSER.codepoint,
|
||||||
|
t!("gui.context.dirs.open_in_fm")
|
||||||
|
))
|
||||||
|
.clicked()
|
||||||
|
&& let Err(e) = opener::open(&path)
|
||||||
|
{
|
||||||
|
eprintln!("Failed to open file manager: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
if ui
|
||||||
|
.button(format!(
|
||||||
|
"{} {}",
|
||||||
|
ICON_DELETE.codepoint,
|
||||||
|
t!("gui.context.dirs.remove")
|
||||||
|
))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
self.app_state.dirs_to_remove.insert(path.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
ui.label(t!("gui.context.dirs.sort_by"));
|
||||||
|
|
||||||
|
let current_order = self
|
||||||
|
.config
|
||||||
|
.dirs_settings
|
||||||
|
.get(path)
|
||||||
|
.map(|s| s.sort_order)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let mut new_order = None;
|
||||||
|
|
||||||
|
if ui
|
||||||
|
.radio(
|
||||||
|
current_order == SortOrder::AlphabeticalAsc,
|
||||||
|
t!("gui.sort.alpha_asc"),
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
new_order = Some(SortOrder::AlphabeticalAsc);
|
||||||
|
}
|
||||||
|
if ui
|
||||||
|
.radio(
|
||||||
|
current_order == SortOrder::AlphabeticalDesc,
|
||||||
|
t!("gui.sort.alpha_desc"),
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
new_order = Some(SortOrder::AlphabeticalDesc);
|
||||||
|
}
|
||||||
|
if ui
|
||||||
|
.radio(
|
||||||
|
current_order == SortOrder::DateModifiedNewest,
|
||||||
|
t!("gui.sort.date_newest"),
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
new_order = Some(SortOrder::DateModifiedNewest);
|
||||||
|
}
|
||||||
|
if ui
|
||||||
|
.radio(
|
||||||
|
current_order == SortOrder::DateModifiedOldest,
|
||||||
|
t!("gui.sort.date_oldest"),
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
new_order = Some(SortOrder::DateModifiedOldest);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(order) = new_order {
|
||||||
|
self.config
|
||||||
|
.dirs_settings
|
||||||
|
.entry(path.clone())
|
||||||
|
.or_default()
|
||||||
|
.sort_order = order;
|
||||||
|
self.config.save_to_file().ok();
|
||||||
|
self.app_state.dir_cache.remove(path);
|
||||||
|
self.open_dir(path, Some(ui.ctx().clone()), false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
self.app_state.dirs = dirs;
|
||||||
|
|
||||||
|
if let Some(path) = dir_to_open {
|
||||||
|
self.open_dir(&path, Some(ui.ctx().clone()), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let add_dirs_button = Button::new(ICON_ADD).frame(false);
|
||||||
|
let add_dirs_button_response = ui.add_sized([18.0, 18.0], add_dirs_button);
|
||||||
|
if add_dirs_button_response.clicked() {
|
||||||
|
self.add_dirs();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
|
||||||
|
let play_file_button = Button::new(t!("gui.play_file_button"));
|
||||||
|
let play_file_button_response = ui.add(play_file_button);
|
||||||
|
if play_file_button_response.clicked() {
|
||||||
|
self.open_file();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_files_search_field(&mut self, ui: &mut Ui) {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let is_scanning = self
|
||||||
|
.app_state
|
||||||
|
.current_dir
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|d| self.app_state.scanning_dirs.lock().unwrap().contains(d));
|
||||||
|
let spinner_width = if is_scanning { 20.0 } else { 0.0 };
|
||||||
|
|
||||||
|
let refresh_button = Button::new(ICON_REFRESH).frame(false);
|
||||||
|
let refresh_width = 24.0;
|
||||||
|
|
||||||
|
let search_field_response = ui.add_sized(
|
||||||
|
[
|
||||||
|
f32::max(
|
||||||
|
0.0,
|
||||||
|
ui.available_width() - spinner_width - refresh_width - 8.0,
|
||||||
|
),
|
||||||
|
22.0,
|
||||||
|
],
|
||||||
|
TextEdit::singleline(&mut self.app_state.search_query)
|
||||||
|
.hint_text(t!("gui.search_placeholder")),
|
||||||
|
);
|
||||||
|
|
||||||
|
if is_scanning {
|
||||||
|
ui.spinner();
|
||||||
|
}
|
||||||
|
|
||||||
|
let refresh_response = ui
|
||||||
|
.add_sized([refresh_width, 22.0], refresh_button)
|
||||||
|
.on_hover_text("Refresh");
|
||||||
|
if refresh_response.clicked()
|
||||||
|
&& let Some(dir) = self.app_state.current_dir.clone()
|
||||||
|
{
|
||||||
|
self.open_dir(&dir, Some(ui.ctx().clone()), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.app_state.force_focus_search {
|
||||||
|
search_field_response.request_focus();
|
||||||
|
self.app_state.force_focus_search = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.app_state.search_field_id = Some(search_field_response.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_files_list(&mut self, ui: &mut Ui, area_size: Vec2) {
|
||||||
|
ScrollArea::vertical().id_salt(1).show(ui, |ui| {
|
||||||
|
ui.set_min_width(area_size.x);
|
||||||
|
ui.set_min_height(area_size.y);
|
||||||
|
|
||||||
|
let finished = {
|
||||||
|
let mut scans = self.app_state.finished_scans.lock().unwrap();
|
||||||
|
std::mem::take(&mut *scans)
|
||||||
|
};
|
||||||
|
for (top_dir, all_files, dir_updates) in finished {
|
||||||
|
self.app_state
|
||||||
|
.recursive_files_cache
|
||||||
|
.insert(top_dir, all_files);
|
||||||
|
self.app_state.dir_cache.extend(dir_updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
let mut actions = Vec::new();
|
||||||
|
|
||||||
|
let search_query = self.app_state.search_query.to_lowercase();
|
||||||
|
let search_query = search_query.trim();
|
||||||
|
|
||||||
|
let mut matching_dirs = std::collections::HashSet::new();
|
||||||
|
let mut matching_files = std::collections::HashSet::new();
|
||||||
|
|
||||||
|
if !search_query.is_empty()
|
||||||
|
&& let Some(current_dir) = &self.app_state.current_dir
|
||||||
|
&& let Some(all_files) = self.app_state.recursive_files_cache.get(current_dir)
|
||||||
|
{
|
||||||
|
for file in all_files {
|
||||||
|
if let Ok(rel_path) = file.strip_prefix(current_dir) {
|
||||||
|
let rel_path_str =
|
||||||
|
rel_path.to_string_lossy().to_lowercase().replace("\\", "/");
|
||||||
|
if rel_path_str.contains(search_query) {
|
||||||
|
matching_files.insert(file.clone());
|
||||||
|
let mut p = file.parent();
|
||||||
|
while let Some(parent) = p {
|
||||||
|
if parent == current_dir {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
matching_dirs.insert(parent.to_path_buf());
|
||||||
|
p = parent.parent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let max_results = 300;
|
||||||
|
let is_truncated = !search_query.is_empty() && matching_files.len() > max_results;
|
||||||
|
|
||||||
|
let files = self.get_filtered_files(&matching_dirs, &matching_files);
|
||||||
|
let mut rendered_count = 0;
|
||||||
|
for entry_path in files {
|
||||||
|
Self::draw_tree_node(
|
||||||
|
ui,
|
||||||
|
entry_path,
|
||||||
|
&self.config,
|
||||||
|
&mut self.app_state,
|
||||||
|
&self.audio_player_state,
|
||||||
|
&mut actions,
|
||||||
|
&matching_dirs,
|
||||||
|
&matching_files,
|
||||||
|
search_query,
|
||||||
|
&mut rendered_count,
|
||||||
|
max_results,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_truncated {
|
||||||
|
ui.add_space(8.0);
|
||||||
|
let text = t!(
|
||||||
|
"gui.search_truncated_warning",
|
||||||
|
count = matching_files.len() - max_results
|
||||||
|
);
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(text).color(egui::Color32::from_rgb(200, 150, 50)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for action in actions {
|
||||||
|
match action {
|
||||||
|
FileAction::Play(path, concurrent) => self.play_file(&path, concurrent),
|
||||||
|
FileAction::StopAndPlay(id, path, concurrent) => {
|
||||||
|
self.stop(Some(id));
|
||||||
|
self.play_file(&path, concurrent);
|
||||||
|
}
|
||||||
|
FileAction::AssignHotkey(path) => {
|
||||||
|
self.app_state.assigning_hotkey_for_file = Some(path);
|
||||||
|
self.app_state.hotkey_capture_active = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_files(&mut self, ui: &mut Ui, area_size: Vec2) {
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
self.draw_files_search_field(ui);
|
||||||
|
ui.separator();
|
||||||
|
self.draw_files_list(ui, area_size);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn draw_tree_node_dir(
|
||||||
|
ui: &mut Ui,
|
||||||
|
path: std::path::PathBuf,
|
||||||
|
config: &GuiConfig,
|
||||||
|
app_state: &mut AppState,
|
||||||
|
audio_player_state: &AudioPlayerState,
|
||||||
|
actions: &mut Vec<FileAction>,
|
||||||
|
matching_dirs: &std::collections::HashSet<PathBuf>,
|
||||||
|
matching_files: &std::collections::HashSet<PathBuf>,
|
||||||
|
search_query: &str,
|
||||||
|
rendered_count: &mut usize,
|
||||||
|
max_results: usize,
|
||||||
|
) {
|
||||||
|
if *rendered_count >= max_results && !search_query.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dir_name = path
|
||||||
|
.file_name()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let id_salt = if search_query.is_empty() {
|
||||||
|
format!("normal_{:?}", path)
|
||||||
|
} else {
|
||||||
|
format!("search_{:?}", path)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut header = CollapsingHeader::new(dir_name).id_salt(id_salt);
|
||||||
|
|
||||||
|
let is_cached = app_state
|
||||||
|
.current_dir
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|d| app_state.recursive_files_cache.contains_key(d));
|
||||||
|
if !search_query.is_empty() && is_cached {
|
||||||
|
header = header.default_open(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
header.show(ui, |ui| {
|
||||||
|
let children = if let Some(cached) = app_state.dir_cache.get(&path) {
|
||||||
|
cached.clone()
|
||||||
|
} else {
|
||||||
|
let mut read = Vec::new();
|
||||||
|
if let Ok(entries) = std::fs::read_dir(&path) {
|
||||||
|
for entry in entries.filter_map(|e| e.ok()) {
|
||||||
|
let child_path = entry.path();
|
||||||
|
if !child_path.is_dir()
|
||||||
|
&& !crate::gui::SUPPORTED_EXTENSIONS.contains(
|
||||||
|
&child_path
|
||||||
|
.extension()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_str()
|
||||||
|
.unwrap_or_default(),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
read.push(child_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let sort_order = config.get_sort_order(&path);
|
||||||
|
read.sort_by(|a, b| {
|
||||||
|
let a_is_dir = a.is_dir();
|
||||||
|
let b_is_dir = b.is_dir();
|
||||||
|
if a_is_dir && !b_is_dir {
|
||||||
|
Ordering::Less
|
||||||
|
} else if !a_is_dir && b_is_dir {
|
||||||
|
Ordering::Greater
|
||||||
|
} else {
|
||||||
|
sort_order.compare(a, b)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app_state.dir_cache.insert(path.clone(), read.clone());
|
||||||
|
read
|
||||||
|
};
|
||||||
|
|
||||||
|
for child in children {
|
||||||
|
if *rendered_count >= max_results && !search_query.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if !search_query.is_empty() && is_cached {
|
||||||
|
if child.is_dir() && !matching_dirs.contains(&child) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !child.is_dir() && !matching_files.contains(&child) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else if !search_query.is_empty() && !child.is_dir() {
|
||||||
|
let file_name = child
|
||||||
|
.file_name()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_lowercase();
|
||||||
|
if !file_name.contains(search_query) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self::draw_tree_node(
|
||||||
|
ui,
|
||||||
|
child,
|
||||||
|
config,
|
||||||
|
app_state,
|
||||||
|
audio_player_state,
|
||||||
|
actions,
|
||||||
|
matching_dirs,
|
||||||
|
matching_files,
|
||||||
|
search_query,
|
||||||
|
rendered_count,
|
||||||
|
max_results,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_tree_node_file(
|
||||||
|
ui: &mut Ui,
|
||||||
|
path: std::path::PathBuf,
|
||||||
|
app_state: &mut AppState,
|
||||||
|
audio_player_state: &AudioPlayerState,
|
||||||
|
actions: &mut Vec<FileAction>,
|
||||||
|
rendered_count: &mut usize,
|
||||||
|
) {
|
||||||
|
*rendered_count += 1;
|
||||||
|
|
||||||
|
let file_name = path
|
||||||
|
.file_name()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
// Hotkey badge
|
||||||
|
let mut hotkey_badge = None;
|
||||||
|
for slot in &app_state.hotkey_config.slots {
|
||||||
|
if slot.action.name == "play"
|
||||||
|
&& let Some(file_path_str) = slot.action.args.get("file_path")
|
||||||
|
&& Path::new(file_path_str) == path
|
||||||
|
{
|
||||||
|
if let Some(chord) = &slot.key_chord {
|
||||||
|
hotkey_badge = Some(format!("[{}]", chord));
|
||||||
|
} else {
|
||||||
|
hotkey_badge = Some(format!("[{}]", slot.slot));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(badge) = &hotkey_badge {
|
||||||
|
ui.label(
|
||||||
|
RichText::new(badge)
|
||||||
|
.small()
|
||||||
|
.monospace()
|
||||||
|
.color(Color32::from_rgb(100, 200, 100)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_button_text = RichText::new(&file_name);
|
||||||
|
|
||||||
|
let file_button = Button::new(file_button_text).frame(false).truncate();
|
||||||
|
let file_button_response = ui.add(file_button);
|
||||||
|
if file_button_response.clicked() {
|
||||||
|
ui.input(|i| {
|
||||||
|
if i.modifiers.ctrl {
|
||||||
|
actions.push(FileAction::Play(path.clone(), true));
|
||||||
|
} else if i.modifiers.shift
|
||||||
|
&& let Some(last_track) = audio_player_state.tracks.last()
|
||||||
|
{
|
||||||
|
actions.push(FileAction::StopAndPlay(last_track.id, path.clone(), true));
|
||||||
|
} else {
|
||||||
|
actions.push(FileAction::Play(path.clone(), false));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context menu
|
||||||
|
file_button_response.context_menu(|ui| {
|
||||||
|
if ui
|
||||||
|
.button(format!(
|
||||||
|
"{} {}",
|
||||||
|
ICON_BOLT.codepoint,
|
||||||
|
t!("gui.context.files.play_solo")
|
||||||
|
))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(FileAction::Play(path.clone(), false));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ui
|
||||||
|
.button(format!(
|
||||||
|
"{} {}",
|
||||||
|
ICON_ADD.codepoint,
|
||||||
|
t!("gui.context.files.add_new")
|
||||||
|
))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(FileAction::Play(path.clone(), true));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ui
|
||||||
|
.button(format!(
|
||||||
|
"{} {}",
|
||||||
|
ICON_SWAP_HORIZ.codepoint,
|
||||||
|
t!("gui.context.files.replace_last")
|
||||||
|
))
|
||||||
|
.clicked()
|
||||||
|
&& let Some(last_track) = audio_player_state.tracks.last()
|
||||||
|
{
|
||||||
|
actions.push(FileAction::StopAndPlay(last_track.id, path.clone(), true));
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
if ui
|
||||||
|
.button(format!(
|
||||||
|
"{} {}",
|
||||||
|
ICON_OPEN_IN_BROWSER.codepoint,
|
||||||
|
t!("gui.context.files.show_in_fm")
|
||||||
|
))
|
||||||
|
.clicked()
|
||||||
|
&& let Err(e) = opener::reveal(&path)
|
||||||
|
{
|
||||||
|
eprintln!("Failed to open file manager: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
if ui
|
||||||
|
.button(format!(
|
||||||
|
"{} {}",
|
||||||
|
ICON_KEYBOARD.codepoint,
|
||||||
|
t!("gui.context.files.asign_hotkey")
|
||||||
|
))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(FileAction::AssignHotkey(path.clone()));
|
||||||
|
ui.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
if ui
|
||||||
|
.button(format!(
|
||||||
|
"{} {}",
|
||||||
|
ICON_FILE_COPY.codepoint,
|
||||||
|
t!("gui.context.files.copy_cli_command")
|
||||||
|
))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
ui.ctx().copy_text(format!(
|
||||||
|
"pwsp-cli action play \"{}\"",
|
||||||
|
path.to_string_lossy()
|
||||||
|
.replace('\\', "\\\\")
|
||||||
|
.replace('"', "\\\"")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn draw_tree_node(
|
||||||
|
ui: &mut Ui,
|
||||||
|
path: std::path::PathBuf,
|
||||||
|
config: &GuiConfig,
|
||||||
|
app_state: &mut AppState,
|
||||||
|
audio_player_state: &AudioPlayerState,
|
||||||
|
actions: &mut Vec<FileAction>,
|
||||||
|
matching_dirs: &HashSet<PathBuf>,
|
||||||
|
matching_files: &HashSet<PathBuf>,
|
||||||
|
search_query: &str,
|
||||||
|
rendered_count: &mut usize,
|
||||||
|
max_results: usize,
|
||||||
|
) {
|
||||||
|
if *rendered_count >= max_results && !search_query.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if path.is_dir() {
|
||||||
|
Self::draw_tree_node_dir(
|
||||||
|
ui,
|
||||||
|
path,
|
||||||
|
config,
|
||||||
|
app_state,
|
||||||
|
audio_player_state,
|
||||||
|
actions,
|
||||||
|
matching_dirs,
|
||||||
|
matching_files,
|
||||||
|
search_query,
|
||||||
|
rendered_count,
|
||||||
|
max_results,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Self::draw_tree_node_file(
|
||||||
|
ui,
|
||||||
|
path,
|
||||||
|
app_state,
|
||||||
|
audio_player_state,
|
||||||
|
actions,
|
||||||
|
rendered_count,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
use crate::gui::SoundpadGui;
|
||||||
|
use egui::{AtomExt, Button, Label, RichText, Slider, Ui, Vec2};
|
||||||
|
use egui_material_icons::icons::*;
|
||||||
|
use pwsp_lib::types::gui::SliderLatch;
|
||||||
|
use rust_i18n::t;
|
||||||
|
|
||||||
|
/// Masters go past 100% on purpose: amplifying the mic feed without deafening yourself is
|
||||||
|
/// the point of splitting the two paths.
|
||||||
|
const MAX_MASTER_VOLUME: f32 = 2.0;
|
||||||
|
const VOLUME_SLIDER_WIDTH: f32 = 90.0;
|
||||||
|
const ICON_SIZE: f32 = 18.0;
|
||||||
|
/// Fixes the row height before anything is placed in it.
|
||||||
|
///
|
||||||
|
/// A horizontal layout centres each widget against the row height known at the time, so a
|
||||||
|
/// row that grows while being filled leaves whatever was added first sitting too high.
|
||||||
|
const FOOTER_ROW_HEIGHT: f32 = 24.0;
|
||||||
|
|
||||||
|
impl SoundpadGui {
|
||||||
|
pub fn draw_footer(&mut self, ui: &mut Ui) {
|
||||||
|
ui.add_space(5.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.set_min_height(FOOTER_ROW_HEIGHT);
|
||||||
|
|
||||||
|
self.draw_monitoring_volume(ui);
|
||||||
|
self.draw_mic_volume(ui);
|
||||||
|
|
||||||
|
// Right-aligns the icon buttons. Clamped because a negative value pushes them
|
||||||
|
// out of view instead of merely crowding them.
|
||||||
|
let spacer = ui.available_width() - ICON_SIZE * 2.0 - ui.spacing().item_spacing.x * 2.0;
|
||||||
|
ui.add_space(spacer.max(0.0));
|
||||||
|
|
||||||
|
self.draw_hotkeys_button(ui);
|
||||||
|
self.draw_settings_button(ui);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_monitoring_volume(&mut self, ui: &mut Ui) {
|
||||||
|
let volume = self.audio_player_state.monitoring_volume;
|
||||||
|
let icon = Self::get_volume_icon(volume);
|
||||||
|
ui.add_sized(
|
||||||
|
[ICON_SIZE, FOOTER_ROW_HEIGHT],
|
||||||
|
Label::new(RichText::new(icon).size(ICON_SIZE)),
|
||||||
|
)
|
||||||
|
.on_hover_text(format!(
|
||||||
|
"{}: {:.0}%",
|
||||||
|
t!("gui.monitoring_volume"),
|
||||||
|
volume * 100.0
|
||||||
|
));
|
||||||
|
|
||||||
|
Self::draw_volume_slider(ui, &mut self.app_state.monitoring_volume, volume);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_mic_volume(&mut self, ui: &mut Ui) {
|
||||||
|
let volume = self.audio_player_state.mic_volume;
|
||||||
|
let icon = if volume <= 0.0 {
|
||||||
|
ICON_MIC_OFF.codepoint
|
||||||
|
} else {
|
||||||
|
ICON_MIC.codepoint
|
||||||
|
};
|
||||||
|
ui.add_sized(
|
||||||
|
[ICON_SIZE, FOOTER_ROW_HEIGHT],
|
||||||
|
Label::new(RichText::new(icon).size(ICON_SIZE)),
|
||||||
|
)
|
||||||
|
.on_hover_text(format!("{}: {:.0}%", t!("gui.mic_volume"), volume * 100.0));
|
||||||
|
|
||||||
|
Self::draw_volume_slider(ui, &mut self.app_state.mic_volume, volume);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_volume_slider(ui: &mut Ui, latch: &mut SliderLatch, daemon_value: f32) {
|
||||||
|
latch.sync(daemon_value);
|
||||||
|
|
||||||
|
// A Slider draws its rail at spacing().slider_width regardless of what add_sized
|
||||||
|
// allocates, so both need the same number or the widget overruns its space.
|
||||||
|
ui.spacing_mut().slider_width = VOLUME_SLIDER_WIDTH;
|
||||||
|
|
||||||
|
let slider = Slider::new(&mut latch.value, 0.0..=MAX_MASTER_VOLUME)
|
||||||
|
.show_value(false)
|
||||||
|
.step_by(0.01);
|
||||||
|
if ui
|
||||||
|
.add_sized([VOLUME_SLIDER_WIDTH, FOOTER_ROW_HEIGHT], slider)
|
||||||
|
.drag_stopped()
|
||||||
|
{
|
||||||
|
latch.dragged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_hotkeys_button(&mut self, ui: &mut Ui) {
|
||||||
|
let hotkeys_button =
|
||||||
|
Button::new(ICON_KEYBOARD.atom_size(Vec2::new(18.0, 18.0))).frame(false);
|
||||||
|
let hotkeys_button_response = ui.add_sized([ICON_SIZE, FOOTER_ROW_HEIGHT], hotkeys_button);
|
||||||
|
if hotkeys_button_response.clicked() {
|
||||||
|
self.app_state.show_hotkeys = true;
|
||||||
|
}
|
||||||
|
hotkeys_button_response.on_hover_text("Hotkeys (H)");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_settings_button(&mut self, ui: &mut Ui) {
|
||||||
|
let settings_button =
|
||||||
|
Button::new(ICON_SETTINGS.atom_size(Vec2::new(18.0, 18.0))).frame(false);
|
||||||
|
let settings_button_response =
|
||||||
|
ui.add_sized([ICON_SIZE, FOOTER_ROW_HEIGHT], settings_button);
|
||||||
|
if settings_button_response.clicked() {
|
||||||
|
self.app_state.show_settings = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
use crate::gui::SoundpadGui;
|
||||||
|
use egui::{Button, CollapsingHeader, FontFamily, Label, RichText, Slider, Ui};
|
||||||
|
use egui_material_icons::icons::*;
|
||||||
|
use pwsp_lib::types::{audio_player::TrackInfo, gui::AppState};
|
||||||
|
use pwsp_lib::utils::gui::format_time_pair;
|
||||||
|
|
||||||
|
pub(crate) enum TrackAction {
|
||||||
|
Pause(u32),
|
||||||
|
Resume(u32),
|
||||||
|
ToggleLoop(u32),
|
||||||
|
Stop(u32),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SoundpadGui {
|
||||||
|
pub fn draw_header(&mut self, ui: &mut Ui) {
|
||||||
|
ui.vertical_centered_justified(|ui| {
|
||||||
|
if self.audio_player_state.tracks.is_empty() {
|
||||||
|
ui.label("No tracks playing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut action = None;
|
||||||
|
|
||||||
|
for track in &self.audio_player_state.tracks {
|
||||||
|
CollapsingHeader::new(
|
||||||
|
RichText::new(
|
||||||
|
track
|
||||||
|
.path
|
||||||
|
.file_stem()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_str()
|
||||||
|
.unwrap_or_default(),
|
||||||
|
)
|
||||||
|
.family(FontFamily::Monospace),
|
||||||
|
)
|
||||||
|
.default_open(true)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
if let Some(act) = Self::draw_track_control(ui, &mut self.app_state, track) {
|
||||||
|
action = Some(act);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.separator();
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(action) = action {
|
||||||
|
match action {
|
||||||
|
TrackAction::Pause(id) => self.pause(Some(id)),
|
||||||
|
TrackAction::Resume(id) => self.resume(Some(id)),
|
||||||
|
TrackAction::ToggleLoop(id) => self.toggle_loop(Some(id)),
|
||||||
|
TrackAction::Stop(id) => self.stop(Some(id)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_playback_controls(ui: &mut Ui, track: &TrackInfo) -> Option<TrackAction> {
|
||||||
|
let mut action = None;
|
||||||
|
|
||||||
|
let play_button = Button::new(if track.paused {
|
||||||
|
ICON_PLAY_ARROW
|
||||||
|
} else {
|
||||||
|
ICON_PAUSE
|
||||||
|
})
|
||||||
|
.corner_radius(15.0);
|
||||||
|
|
||||||
|
if ui.add_sized([30.0, 30.0], play_button).clicked() {
|
||||||
|
action = Some(if track.paused {
|
||||||
|
TrackAction::Resume(track.id)
|
||||||
|
} else {
|
||||||
|
TrackAction::Pause(track.id)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let loop_button = Button::new(
|
||||||
|
RichText::new(if track.looped {
|
||||||
|
ICON_REPEAT_ONE
|
||||||
|
} else {
|
||||||
|
ICON_REPEAT
|
||||||
|
})
|
||||||
|
.size(18.0),
|
||||||
|
)
|
||||||
|
.frame(false);
|
||||||
|
|
||||||
|
if ui.add_sized([15.0, 30.0], loop_button).clicked() {
|
||||||
|
action = Some(TrackAction::ToggleLoop(track.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
action
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_position_control(
|
||||||
|
ui: &mut Ui,
|
||||||
|
ui_state: &mut pwsp_lib::types::gui::TrackUiState,
|
||||||
|
track: &TrackInfo,
|
||||||
|
default_slider_width: f32,
|
||||||
|
) {
|
||||||
|
let duration = track.duration.unwrap_or(1.0);
|
||||||
|
let position_slider = Slider::new(&mut ui_state.position.value, 0.0..=duration)
|
||||||
|
.show_value(false)
|
||||||
|
.step_by(0.01);
|
||||||
|
|
||||||
|
let position_slider_width = ui.available_width()
|
||||||
|
- (30.0 * 3.0)
|
||||||
|
- default_slider_width
|
||||||
|
- (ui.spacing().item_spacing.x * 6.0);
|
||||||
|
|
||||||
|
ui.spacing_mut().slider_width = position_slider_width;
|
||||||
|
if ui.add_sized([30.0, 30.0], position_slider).drag_stopped() {
|
||||||
|
ui_state.position.dragged = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let time_label =
|
||||||
|
Label::new(RichText::new(format_time_pair(track.position, duration)).monospace());
|
||||||
|
ui.add_sized([30.0, 30.0], time_label);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_volume_control(
|
||||||
|
ui: &mut Ui,
|
||||||
|
ui_state: &mut pwsp_lib::types::gui::TrackUiState,
|
||||||
|
track: &TrackInfo,
|
||||||
|
default_slider_width: f32,
|
||||||
|
) {
|
||||||
|
let volume_icon = Self::get_volume_icon(track.volume);
|
||||||
|
let volume_label = Label::new(RichText::new(volume_icon).size(18.0));
|
||||||
|
ui.add_sized([30.0, 30.0], volume_label)
|
||||||
|
.on_hover_text(format!("Volume: {:.0}%", track.volume * 100.0));
|
||||||
|
|
||||||
|
let volume_slider = Slider::new(&mut ui_state.volume.value, 0.0..=1.0)
|
||||||
|
.show_value(false)
|
||||||
|
.step_by(0.01);
|
||||||
|
|
||||||
|
ui.spacing_mut().slider_width = default_slider_width - 30.0;
|
||||||
|
ui.spacing_mut().item_spacing.x = 0.0;
|
||||||
|
|
||||||
|
if ui.add_sized([30.0, 30.0], volume_slider).drag_stopped() {
|
||||||
|
ui_state.volume.dragged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_stop_control(ui: &mut Ui, track: &TrackInfo) -> Option<TrackAction> {
|
||||||
|
let stop_button = Button::new(ICON_CLOSE).frame(false);
|
||||||
|
if ui.add_sized([30.0, 30.0], stop_button).clicked() {
|
||||||
|
Some(TrackAction::Stop(track.id))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_track_control(
|
||||||
|
ui: &mut Ui,
|
||||||
|
app_state: &mut AppState,
|
||||||
|
track: &TrackInfo,
|
||||||
|
) -> Option<TrackAction> {
|
||||||
|
let ui_state = app_state.track_ui_states.entry(track.id).or_default();
|
||||||
|
|
||||||
|
ui_state.position.sync(track.position);
|
||||||
|
ui_state.volume.sync(track.volume);
|
||||||
|
|
||||||
|
let mut action = None;
|
||||||
|
|
||||||
|
ui.horizontal_top(|ui| {
|
||||||
|
if let Some(act) = Self::draw_playback_controls(ui, track) {
|
||||||
|
action = Some(act);
|
||||||
|
}
|
||||||
|
|
||||||
|
let default_slider_width = ui.spacing().slider_width;
|
||||||
|
Self::draw_position_control(ui, ui_state, track, default_slider_width);
|
||||||
|
Self::draw_volume_control(ui, ui_state, track, default_slider_width);
|
||||||
|
|
||||||
|
if let Some(act) = Self::draw_stop_control(ui, track) {
|
||||||
|
action = Some(act);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
action
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,304 @@
|
|||||||
|
use crate::gui::SoundpadGui;
|
||||||
|
use egui::{Button, Color32, Label, RichText, TextEdit, Ui};
|
||||||
|
use egui_extras::{Column, TableBuilder};
|
||||||
|
use egui_material_icons::icons::*;
|
||||||
|
use pwsp_lib::types::socket::Request;
|
||||||
|
use pwsp_lib::utils::gui::make_request_async;
|
||||||
|
use rust_i18n::t;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub(crate) enum HotkeyAction {
|
||||||
|
Remove(String),
|
||||||
|
Capture(String),
|
||||||
|
ClearChord(String),
|
||||||
|
Play(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SoundpadGui {
|
||||||
|
pub fn draw_hotkeys(&mut self, ui: &mut Ui) {
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
ui.spacing_mut().item_spacing.y = 5.0;
|
||||||
|
|
||||||
|
self.draw_hotkeys_header(ui);
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
self.draw_hotkeys_search(ui);
|
||||||
|
ui.separator();
|
||||||
|
ui.add_space(5.0);
|
||||||
|
|
||||||
|
let action = self.draw_hotkeys_table(ui);
|
||||||
|
|
||||||
|
if let Some(action) = action {
|
||||||
|
self.handle_hotkey_action(action);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_hotkeys_header(&mut self, ui: &mut Ui) {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let back_button = Button::new(ICON_ARROW_BACK).frame(false);
|
||||||
|
if ui.add(back_button).clicked() {
|
||||||
|
self.app_state.show_hotkeys = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.vertical_centered(|ui| {
|
||||||
|
ui.label(
|
||||||
|
RichText::new(t!("gui.hotkeys.header"))
|
||||||
|
.color(Color32::WHITE)
|
||||||
|
.monospace(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_hotkeys_search(&mut self, ui: &mut Ui) {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.menu_button(
|
||||||
|
format!(
|
||||||
|
"{} {}",
|
||||||
|
ICON_ADD.codepoint,
|
||||||
|
t!("gui.hotkeys.add_command_select")
|
||||||
|
),
|
||||||
|
|ui| {
|
||||||
|
let mut selected_cmd = None;
|
||||||
|
if ui.button(t!("gui.hotkeys.toggle_pause_command")).clicked() {
|
||||||
|
selected_cmd = Some(("cmd_toggle_pause", Request::toggle_pause(None)));
|
||||||
|
}
|
||||||
|
if ui.button(t!("gui.hotkeys.stop_playback_command")).clicked() {
|
||||||
|
selected_cmd = Some(("cmd_stop", Request::stop(None)));
|
||||||
|
}
|
||||||
|
if ui
|
||||||
|
.button(t!("gui.hotkeys.pause_playback_command"))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
selected_cmd = Some(("cmd_pause", Request::pause(None)));
|
||||||
|
}
|
||||||
|
if ui
|
||||||
|
.button(t!("gui.hotkeys.resume_playback_command"))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
selected_cmd = Some(("cmd_resume", Request::resume(None)));
|
||||||
|
}
|
||||||
|
if ui.button(t!("gui.hotkeys.toggle_loop_command")).clicked() {
|
||||||
|
selected_cmd = Some(("cmd_toggle_loop", Request::toggle_loop(None)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((slot_name, req)) = selected_cmd {
|
||||||
|
make_request_async(Request::set_hotkey_action(slot_name, &req));
|
||||||
|
self.app_state
|
||||||
|
.hotkey_config
|
||||||
|
.set_slot(slot_name.to_string(), req);
|
||||||
|
self.app_state.assigning_hotkey_slot = Some(slot_name.to_string());
|
||||||
|
self.app_state.hotkey_capture_active = true;
|
||||||
|
ui.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ui.add_space(10.0);
|
||||||
|
|
||||||
|
ui.add(
|
||||||
|
TextEdit::singleline(&mut self.app_state.hotkey_search_query)
|
||||||
|
.hint_text(t!("gui.hotkeys.search_placeholder"))
|
||||||
|
.desired_width(f32::INFINITY),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_hotkeys_table(&mut self, ui: &mut Ui) -> Option<HotkeyAction> {
|
||||||
|
let conflicts = self.app_state.hotkey_config.find_conflicts();
|
||||||
|
let conflict_slots: std::collections::HashSet<&str> =
|
||||||
|
conflicts.into_iter().flat_map(|(a, b)| [a, b]).collect();
|
||||||
|
|
||||||
|
let search = self.app_state.hotkey_search_query.to_lowercase();
|
||||||
|
let mut action: Option<HotkeyAction> = None;
|
||||||
|
|
||||||
|
let slots: Vec<_> = self
|
||||||
|
.app_state
|
||||||
|
.hotkey_config
|
||||||
|
.slots
|
||||||
|
.iter()
|
||||||
|
.filter(|s| {
|
||||||
|
if search.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
s.slot.to_lowercase().contains(&search)
|
||||||
|
|| format!("{:?}", s.action).to_lowercase().contains(&search)
|
||||||
|
|| s.key_chord
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_lowercase()
|
||||||
|
.contains(&search)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let available_width = ui.available_width();
|
||||||
|
let col_width = (available_width / 4.0).max(80.0);
|
||||||
|
|
||||||
|
TableBuilder::new(ui)
|
||||||
|
.striped(true)
|
||||||
|
.column(Column::exact(col_width).clip(true)) // Slot
|
||||||
|
.column(Column::exact(col_width).clip(true)) // Sound / Action name
|
||||||
|
.column(Column::exact(col_width).clip(true)) // Key Chord
|
||||||
|
.column(Column::exact(col_width).clip(true)) // Actions
|
||||||
|
.header(30.0, |mut header| {
|
||||||
|
header.col(|ui| {
|
||||||
|
ui.label(
|
||||||
|
RichText::new(t!("gui.hotkeys.column_slot"))
|
||||||
|
.strong()
|
||||||
|
.monospace(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
header.col(|ui| {
|
||||||
|
ui.label(
|
||||||
|
RichText::new(t!("gui.hotkeys.column_sound"))
|
||||||
|
.strong()
|
||||||
|
.monospace(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
header.col(|ui| {
|
||||||
|
ui.label(
|
||||||
|
RichText::new(t!("gui.hotkeys.column_key_chord"))
|
||||||
|
.strong()
|
||||||
|
.monospace(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
header.col(|ui| {
|
||||||
|
ui.label(
|
||||||
|
RichText::new(t!("gui.hotkeys.column_actions"))
|
||||||
|
.strong()
|
||||||
|
.monospace(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.body(|mut body| {
|
||||||
|
if slots.is_empty() {
|
||||||
|
body.row(30.0, |mut row| {
|
||||||
|
row.col(|_| {});
|
||||||
|
row.col(|ui| {
|
||||||
|
ui.label(RichText::new(t!("gui.hotkeys.no_hotkeys_configured")));
|
||||||
|
});
|
||||||
|
row.col(|_| {});
|
||||||
|
row.col(|_| {});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for slot in &slots {
|
||||||
|
body.row(30.0, |mut row| {
|
||||||
|
// Column 1: Slot
|
||||||
|
row.col(|ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if conflict_slots.contains(slot.slot.as_str()) {
|
||||||
|
ui.label(
|
||||||
|
RichText::new(ICON_WARNING.codepoint)
|
||||||
|
.color(Color32::from_rgb(255, 165, 0)),
|
||||||
|
)
|
||||||
|
.on_hover_text("Key chord conflict");
|
||||||
|
}
|
||||||
|
ui.add(
|
||||||
|
Label::new(RichText::new(&slot.slot).monospace()).truncate(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Column 2: Sound / Action name
|
||||||
|
row.col(|ui| {
|
||||||
|
let action_name = match slot.action.name.as_str() {
|
||||||
|
"play" => {
|
||||||
|
if let Some(file_path_str) = slot.action.args.get("file_path") {
|
||||||
|
Path::new(file_path_str)
|
||||||
|
.file_name()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string()
|
||||||
|
} else {
|
||||||
|
"Play".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"toggle_pause" => "Toggle Pause".to_string(),
|
||||||
|
"pause" => "Pause Playback".to_string(),
|
||||||
|
"resume" => "Resume Playback".to_string(),
|
||||||
|
"stop" => "Stop Playback".to_string(),
|
||||||
|
"toggle_loop" => "Toggle Loop".to_string(),
|
||||||
|
other => other.to_string(),
|
||||||
|
};
|
||||||
|
ui.add(Label::new(RichText::new(action_name).monospace()).truncate());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Column 3: Key Chord
|
||||||
|
row.col(|ui| {
|
||||||
|
let chord_text = slot.key_chord.as_deref().unwrap_or("(none)");
|
||||||
|
ui.add(
|
||||||
|
Label::new(RichText::new(chord_text).monospace().color(
|
||||||
|
if slot.key_chord.is_some() {
|
||||||
|
Color32::from_rgb(100, 200, 100)
|
||||||
|
} else {
|
||||||
|
Color32::GRAY
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.truncate(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Column 4: Actions
|
||||||
|
row.col(|ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui
|
||||||
|
.add(Button::new(ICON_DELETE).frame(false))
|
||||||
|
.on_hover_text("Remove slot")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
action = Some(HotkeyAction::Remove(slot.slot.clone()));
|
||||||
|
}
|
||||||
|
if ui
|
||||||
|
.add(Button::new(ICON_KEYBOARD).frame(false))
|
||||||
|
.on_hover_text("Set key chord")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
action = Some(HotkeyAction::Capture(slot.slot.clone()));
|
||||||
|
}
|
||||||
|
if slot.key_chord.is_some()
|
||||||
|
&& ui
|
||||||
|
.add(Button::new(ICON_BACKSPACE).frame(false))
|
||||||
|
.on_hover_text("Clear key chord")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
action = Some(HotkeyAction::ClearChord(slot.slot.clone()));
|
||||||
|
}
|
||||||
|
if ui
|
||||||
|
.add(Button::new(ICON_PLAY_ARROW).frame(false))
|
||||||
|
.on_hover_text("Play")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
action = Some(HotkeyAction::Play(slot.slot.clone()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
action
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_hotkey_action(&mut self, action: HotkeyAction) {
|
||||||
|
match action {
|
||||||
|
HotkeyAction::Remove(slot) => {
|
||||||
|
make_request_async(Request::clear_hotkey(&slot));
|
||||||
|
self.app_state.hotkey_config.remove_slot(&slot);
|
||||||
|
}
|
||||||
|
HotkeyAction::Capture(slot) => {
|
||||||
|
self.app_state.assigning_hotkey_slot = Some(slot);
|
||||||
|
self.app_state.hotkey_capture_active = true;
|
||||||
|
}
|
||||||
|
HotkeyAction::ClearChord(slot) => {
|
||||||
|
make_request_async(Request::clear_hotkey_key(&slot));
|
||||||
|
self.app_state.hotkey_config.set_key_chord(&slot, None);
|
||||||
|
}
|
||||||
|
HotkeyAction::Play(slot) => {
|
||||||
|
self.play_hotkey_slot(&slot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
use crate::gui::SoundpadGui;
|
||||||
|
use egui::Ui;
|
||||||
|
use egui_material_icons::icons::*;
|
||||||
|
|
||||||
|
mod body;
|
||||||
|
mod footer;
|
||||||
|
mod header;
|
||||||
|
mod hotkey_capture;
|
||||||
|
mod hotkeys;
|
||||||
|
mod settings;
|
||||||
|
mod waiting_for_daemon;
|
||||||
|
|
||||||
|
impl SoundpadGui {
|
||||||
|
pub(crate) fn get_volume_icon(volume: f32) -> &'static str {
|
||||||
|
if volume > 0.7 {
|
||||||
|
ICON_VOLUME_UP.codepoint
|
||||||
|
} else if volume <= 0.0 {
|
||||||
|
ICON_VOLUME_OFF.codepoint
|
||||||
|
} else if volume < 0.3 {
|
||||||
|
ICON_VOLUME_MUTE.codepoint
|
||||||
|
} else {
|
||||||
|
ICON_VOLUME_DOWN.codepoint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn draw(&mut self, ui: &mut Ui) {
|
||||||
|
self.draw_header(ui);
|
||||||
|
self.draw_body(ui);
|
||||||
|
ui.separator();
|
||||||
|
self.draw_footer(ui);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_volume_icon() {
|
||||||
|
assert_eq!(SoundpadGui::get_volume_icon(0.8), ICON_VOLUME_UP.codepoint);
|
||||||
|
assert_eq!(SoundpadGui::get_volume_icon(0.0), ICON_VOLUME_OFF.codepoint);
|
||||||
|
assert_eq!(
|
||||||
|
SoundpadGui::get_volume_icon(-0.1),
|
||||||
|
ICON_VOLUME_OFF.codepoint
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SoundpadGui::get_volume_icon(0.2),
|
||||||
|
ICON_VOLUME_MUTE.codepoint
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SoundpadGui::get_volume_icon(0.5),
|
||||||
|
ICON_VOLUME_DOWN.codepoint
|
||||||
|
);
|
||||||
|
|
||||||
|
// Masters go past 100%, which must not fall off the top of the ladder.
|
||||||
|
assert_eq!(SoundpadGui::get_volume_icon(1.0), ICON_VOLUME_UP.codepoint);
|
||||||
|
assert_eq!(SoundpadGui::get_volume_icon(2.0), ICON_VOLUME_UP.codepoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
use crate::{gui::SoundpadGui, locale};
|
||||||
|
use egui::{Align, Button, Color32, ComboBox, Layout, RichText, Slider, Ui};
|
||||||
|
use egui_material_icons::icons::ICON_ARROW_BACK;
|
||||||
|
use pwsp_lib::types::config::PreferredTheme;
|
||||||
|
use rust_i18n::t;
|
||||||
|
|
||||||
|
impl SoundpadGui {
|
||||||
|
pub fn draw_settings(&mut self, ui: &mut Ui) {
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
ui.spacing_mut().item_spacing.y = 5.0;
|
||||||
|
// --------- Back Button and Title ----------
|
||||||
|
ui.horizontal_top(|ui| {
|
||||||
|
let back_button = Button::new(ICON_ARROW_BACK).frame(false);
|
||||||
|
let back_button_response = ui.add(back_button);
|
||||||
|
if back_button_response.clicked() {
|
||||||
|
self.app_state.show_settings = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.add_space(ui.available_width() / 2.0 - 40.0);
|
||||||
|
|
||||||
|
ui.label(
|
||||||
|
RichText::new(t!("gui.settings.header"))
|
||||||
|
.color(Color32::WHITE)
|
||||||
|
.monospace(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
// --------------------------------
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
ui.add_space(20.0);
|
||||||
|
|
||||||
|
// --------- Checkboxes ----------
|
||||||
|
let save_volume_response = ui.checkbox(
|
||||||
|
&mut self.config.save_volume,
|
||||||
|
t!("gui.settings.remember_volume"),
|
||||||
|
);
|
||||||
|
let save_volume_multiplier_response = ui.checkbox(
|
||||||
|
&mut self.config.save_volume_multiplier,
|
||||||
|
t!("gui.settings.remember_volume_multiplier"),
|
||||||
|
);
|
||||||
|
let save_input_response =
|
||||||
|
ui.checkbox(&mut self.config.save_input, t!("gui.settings.remember_mic"));
|
||||||
|
let save_scale_response = ui.checkbox(
|
||||||
|
&mut self.config.save_scale_factor,
|
||||||
|
t!("gui.settings.remember_ui_scale"),
|
||||||
|
);
|
||||||
|
let pause_on_exit_response = ui.checkbox(
|
||||||
|
&mut self.config.pause_on_exit,
|
||||||
|
t!("gui.settings.pause_on_window_close"),
|
||||||
|
);
|
||||||
|
|
||||||
|
if save_volume_response.changed()
|
||||||
|
|| save_volume_multiplier_response.changed()
|
||||||
|
|| save_input_response.changed()
|
||||||
|
|| save_scale_response.changed()
|
||||||
|
|| pause_on_exit_response.changed()
|
||||||
|
{
|
||||||
|
self.config.save_to_file().ok();
|
||||||
|
}
|
||||||
|
// --------------------------------
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
// ---------- Selectors -----------
|
||||||
|
self.draw_mic_selection(ui);
|
||||||
|
self.draw_output_selection(ui);
|
||||||
|
self.draw_language_selection(ui);
|
||||||
|
|
||||||
|
let mut selected_theme = self.config.preferred_theme.clone();
|
||||||
|
ComboBox::from_label(t!("gui.settings.theme.label"))
|
||||||
|
.selected_text(match self.config.preferred_theme {
|
||||||
|
PreferredTheme::System => t!("gui.settings.theme.system"),
|
||||||
|
PreferredTheme::Light => t!("gui.settings.theme.light"),
|
||||||
|
PreferredTheme::Dark => t!("gui.settings.theme.dark"),
|
||||||
|
})
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
ui.selectable_value(
|
||||||
|
&mut selected_theme,
|
||||||
|
PreferredTheme::System,
|
||||||
|
t!("gui.settings.theme.system"),
|
||||||
|
);
|
||||||
|
ui.selectable_value(
|
||||||
|
&mut selected_theme,
|
||||||
|
PreferredTheme::Light,
|
||||||
|
t!("gui.settings.theme.light"),
|
||||||
|
);
|
||||||
|
ui.selectable_value(
|
||||||
|
&mut selected_theme,
|
||||||
|
PreferredTheme::Dark,
|
||||||
|
t!("gui.settings.theme.dark"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if selected_theme != self.config.preferred_theme {
|
||||||
|
self.config.preferred_theme = selected_theme;
|
||||||
|
self.config.save_to_file().ok();
|
||||||
|
}
|
||||||
|
// --------------------------------
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
// ----------- Sliders ------------
|
||||||
|
// Volume multiplier
|
||||||
|
self.app_state
|
||||||
|
.volume_multiplier
|
||||||
|
.sync(self.audio_player_state.volume_multiplier);
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let slider = Slider::new(&mut self.app_state.volume_multiplier.value, 0.01..=3.0);
|
||||||
|
let response = ui.add(slider);
|
||||||
|
ui.label(t!("gui.settings.volume_multiplier"));
|
||||||
|
|
||||||
|
if response.changed() {
|
||||||
|
// This condition is required to avoid spamming requests while dragging, but to allow changing the value via TextEdit
|
||||||
|
if !response.dragged() || (response.dragged() && response.drag_stopped()) {
|
||||||
|
self.app_state.volume_multiplier.dragged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// --------------------------------
|
||||||
|
|
||||||
|
ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
|
||||||
|
ui.label(t!(
|
||||||
|
"gui.settings.version",
|
||||||
|
version = env!("CARGO_PKG_VERSION")
|
||||||
|
));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_mic_selection(&mut self, ui: &mut Ui) {
|
||||||
|
let mics = &self.audio_player_state.all_inputs_sorted;
|
||||||
|
|
||||||
|
let mut selected_input = self.audio_player_state.current_input.to_owned();
|
||||||
|
let prev_input = selected_input.to_owned();
|
||||||
|
ComboBox::from_label(t!("gui.choose_mic_select"))
|
||||||
|
.height(30.0)
|
||||||
|
.selected_text(
|
||||||
|
self.audio_player_state
|
||||||
|
.all_inputs
|
||||||
|
.get(&selected_input)
|
||||||
|
.unwrap_or(&String::new()),
|
||||||
|
)
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
for (name, nick) in mics {
|
||||||
|
ui.selectable_value(&mut selected_input, name.clone(), nick);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if selected_input != prev_input {
|
||||||
|
self.set_input(selected_input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_output_selection(&mut self, ui: &mut Ui) {
|
||||||
|
let outputs = &self.audio_player_state.all_outputs_sorted;
|
||||||
|
|
||||||
|
let mut selected_output = self.audio_player_state.current_output.to_owned();
|
||||||
|
let prev_output = selected_output.to_owned();
|
||||||
|
|
||||||
|
// An empty selection means no device is pinned.
|
||||||
|
let selected_text = self
|
||||||
|
.audio_player_state
|
||||||
|
.all_outputs
|
||||||
|
.get(&selected_output)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| t!("gui.default_output").to_string());
|
||||||
|
|
||||||
|
ComboBox::from_label(t!("gui.choose_output_select"))
|
||||||
|
.height(30.0)
|
||||||
|
.selected_text(selected_text)
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
// Listed first so pinning a device stays undoable.
|
||||||
|
ui.selectable_value(
|
||||||
|
&mut selected_output,
|
||||||
|
String::new(),
|
||||||
|
t!("gui.default_output"),
|
||||||
|
);
|
||||||
|
for (name, nick) in outputs {
|
||||||
|
ui.selectable_value(&mut selected_output, name.clone(), nick);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if selected_output != prev_output {
|
||||||
|
self.set_output(selected_output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_language_selection(&mut self, ui: &mut Ui) {
|
||||||
|
let mut selected = self.config.forced_lang.clone();
|
||||||
|
let previous = selected.clone();
|
||||||
|
|
||||||
|
let selected_text = match &selected {
|
||||||
|
Some(code) => locale::display_name(code).to_string(),
|
||||||
|
None => t!("gui.settings.language.system").to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
ComboBox::from_label(t!("gui.settings.language.label"))
|
||||||
|
.height(30.0)
|
||||||
|
.selected_text(selected_text)
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
// Listed first so picking a language stays undoable.
|
||||||
|
ui.selectable_value(&mut selected, None, t!("gui.settings.language.system"));
|
||||||
|
for code in locale::available() {
|
||||||
|
let name = locale::display_name(&code);
|
||||||
|
ui.selectable_value(&mut selected, Some(code.clone()), name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if selected != previous {
|
||||||
|
self.config.forced_lang = selected;
|
||||||
|
self.config.save_to_file().ok();
|
||||||
|
locale::apply(&self.config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
use pwsp_lib::types::config::GuiConfig;
|
||||||
|
|
||||||
|
/// Applies the UI language: the one pinned in the config, or the system's when none is.
|
||||||
|
///
|
||||||
|
/// Safe to call at any time — `t!` reads the locale on every lookup, so the next frame
|
||||||
|
/// already renders in the new language.
|
||||||
|
pub fn apply(config: &GuiConfig) {
|
||||||
|
let locale = config.forced_lang.clone().unwrap_or_else(system_locale);
|
||||||
|
rust_i18n::set_locale(&locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn system_locale() -> String {
|
||||||
|
sys_locale::get_locale().unwrap_or_else(|| String::from("en-US"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locales shipped under `locales/`, in a stable order for the menu.
|
||||||
|
pub fn available() -> Vec<String> {
|
||||||
|
let mut locales: Vec<String> = rust_i18n::available_locales!()
|
||||||
|
.into_iter()
|
||||||
|
.map(|locale| locale.to_string())
|
||||||
|
.collect();
|
||||||
|
locales.sort_unstable();
|
||||||
|
locales
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a language names itself, which is how it should appear in a language picker
|
||||||
|
/// regardless of the language the rest of the UI is in. An unnamed locale falls back to
|
||||||
|
/// its code, so a newly shipped translation is still selectable.
|
||||||
|
pub fn display_name(code: &str) -> &str {
|
||||||
|
match code {
|
||||||
|
"en" => "English",
|
||||||
|
"ru" => "Русский",
|
||||||
|
"es" => "Español",
|
||||||
|
"fr" => "Français",
|
||||||
|
"zh" => "中文",
|
||||||
|
"ar" => "العربية",
|
||||||
|
"kz" => "Қазақша",
|
||||||
|
"he" => "עברית",
|
||||||
|
"pt-BR" => "Português (Brasil)",
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_shipped_locale_has_a_native_name() {
|
||||||
|
for code in available() {
|
||||||
|
assert_ne!(
|
||||||
|
display_name(&code),
|
||||||
|
code,
|
||||||
|
"locale {code} falls back to its code in the picker"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
mod gui;
|
||||||
|
mod locale;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, anyhow};
|
||||||
|
use pwsp_lib::utils::gui::{ensure_pwsp_audio_dir, get_gui_config};
|
||||||
|
use rust_i18n::i18n;
|
||||||
|
use std::{
|
||||||
|
env,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
|
||||||
|
i18n!("locales", fallback = "en");
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
locale::apply(&get_gui_config());
|
||||||
|
|
||||||
|
let args = env::args().skip(1).collect::<Vec<String>>();
|
||||||
|
|
||||||
|
if let Some(uri) = args.first() {
|
||||||
|
match download_audio_from_url(uri).await {
|
||||||
|
Ok(path) => println!("Successfully downloaded to: {:?}", path),
|
||||||
|
Err(e) => eprintln!("Error downloading file: {}", e),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
gui::run().await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download_audio_from_url(uri: &str) -> Result<PathBuf> {
|
||||||
|
let prefix = "soundpad://sound/url/";
|
||||||
|
|
||||||
|
let target_url = uri
|
||||||
|
.strip_prefix(prefix)
|
||||||
|
.ok_or_else(|| anyhow!("URI does not containt an expected prefix: {}", prefix))?;
|
||||||
|
|
||||||
|
let file_name_encoded = match target_url.split('/').next_back() {
|
||||||
|
Some(path) => path.to_string(),
|
||||||
|
None => {
|
||||||
|
let id = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.expect("Time went back")
|
||||||
|
.as_nanos();
|
||||||
|
format!("downloaded_audio_{}.mp3", id)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let file_name = percent_encoding::percent_decode_str(&file_name_encoded.clone())
|
||||||
|
.decode_utf8()
|
||||||
|
.unwrap_or_else(|_| file_name_encoded.into())
|
||||||
|
.into_owned();
|
||||||
|
|
||||||
|
let normalized_file_name = file_name.replace('\\', "/");
|
||||||
|
let sanitized_file_name = Path::new(&normalized_file_name)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("downloaded_audio.mp3");
|
||||||
|
|
||||||
|
let save_path = ensure_pwsp_audio_dir()?.join(sanitized_file_name);
|
||||||
|
|
||||||
|
let response = reqwest::get(target_url)
|
||||||
|
.await?
|
||||||
|
.error_for_status()
|
||||||
|
.context("Failed to fetch file")?;
|
||||||
|
|
||||||
|
let bytes = response.bytes().await?;
|
||||||
|
|
||||||
|
tokio::fs::write(&save_path, bytes)
|
||||||
|
.await
|
||||||
|
.context("Failed to save file to disk")?;
|
||||||
|
|
||||||
|
Ok(save_path)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
[package]
|
||||||
|
name = "pwsp-lib"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tokio.workspace = true
|
||||||
|
async-trait.workspace = true
|
||||||
|
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
||||||
|
dirs.workspace = true
|
||||||
|
itertools.workspace = true
|
||||||
|
evdev.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
rustix.workspace = true
|
||||||
|
|
||||||
|
rodio.workspace = true
|
||||||
|
pipewire.workspace = true
|
||||||
|
|
||||||
|
egui.workspace = true
|
||||||
|
|
||||||
|
reqwest.workspace = true
|
||||||
@@ -0,0 +1,809 @@
|
|||||||
|
use crate::{
|
||||||
|
types::pipewire::{AudioDevice, DeviceType},
|
||||||
|
utils::{
|
||||||
|
daemon::with_daemon_config,
|
||||||
|
pipewire::{
|
||||||
|
PwTerminator, create_link, ensure_route, get_all_devices, get_device, get_device_by_id,
|
||||||
|
get_sink,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use anyhow::{Result, anyhow};
|
||||||
|
use rodio::{Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{
|
||||||
|
collections::{HashMap, HashSet},
|
||||||
|
fs,
|
||||||
|
io::BufReader,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
const VIRTUAL_MIC_NAME: &str = "pwsp-virtual-mic";
|
||||||
|
|
||||||
|
/// Streams are re-opened on the first play after an idle period, so this poll sits in the
|
||||||
|
/// path between pressing a key and hearing the sound. The node normally shows up within a
|
||||||
|
/// poll or two; the attempt count only bounds the pathological case.
|
||||||
|
const NODE_DISCOVERY_ATTEMPTS: u32 = 200;
|
||||||
|
const NODE_DISCOVERY_INTERVAL: Duration = Duration::from_millis(5);
|
||||||
|
|
||||||
|
type FileDecoder = Decoder<BufReader<fs::File>>;
|
||||||
|
|
||||||
|
#[derive(Debug, Eq, PartialEq, Default, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum PlayerState {
|
||||||
|
#[default]
|
||||||
|
Stopped,
|
||||||
|
Paused,
|
||||||
|
Playing,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TrackInfo {
|
||||||
|
pub id: u32,
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub duration: Option<f32>,
|
||||||
|
pub position: f32,
|
||||||
|
pub volume: f32,
|
||||||
|
pub looped: bool,
|
||||||
|
pub paused: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FullState {
|
||||||
|
pub state: PlayerState,
|
||||||
|
pub tracks: Vec<TrackInfo>,
|
||||||
|
pub monitoring_volume: f32,
|
||||||
|
pub mic_volume: f32,
|
||||||
|
pub volume_multiplier: f32,
|
||||||
|
pub current_input: String,
|
||||||
|
pub all_inputs: HashMap<String, String>,
|
||||||
|
pub current_output: String,
|
||||||
|
pub all_outputs: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which of the two independent output paths a volume applies to.
|
||||||
|
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||||
|
pub enum VolumeTarget {
|
||||||
|
/// What the user hears locally.
|
||||||
|
Monitoring,
|
||||||
|
/// What is fed into the virtual microphone, i.e. what everybody else hears.
|
||||||
|
Mic,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The two rodio players backing a single track, one per output path.
|
||||||
|
///
|
||||||
|
/// Every playback control has to reach both, so they are wrapped rather than fanned out
|
||||||
|
/// by hand at each call site.
|
||||||
|
pub struct PlayerPair {
|
||||||
|
pub monitoring: Player,
|
||||||
|
pub mic: Option<Player>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlayerPair {
|
||||||
|
pub fn for_each(&self, f: impl Fn(&Player)) {
|
||||||
|
f(&self.monitoring);
|
||||||
|
if let Some(mic) = &self.mic {
|
||||||
|
f(mic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The player that answers queries about position and paused state for the pair.
|
||||||
|
pub fn primary(&self) -> &Player {
|
||||||
|
&self.monitoring
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True once every player of the pair has run out of samples.
|
||||||
|
pub fn empty(&self) -> bool {
|
||||||
|
self.monitoring.empty() && self.mic.as_ref().is_none_or(|mic| mic.empty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PlayingSound {
|
||||||
|
pub id: u32,
|
||||||
|
pub players: PlayerPair,
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub duration: Option<f32>,
|
||||||
|
pub looped: bool,
|
||||||
|
pub volume: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Final linear gain handed to a rodio `Player`.
|
||||||
|
///
|
||||||
|
/// Values above `1.0` are allowed on purpose; anything non-finite or negative collapses to
|
||||||
|
/// silence rather than reaching the mixer.
|
||||||
|
pub fn effective_gain(master: f32, track: f32, multiplier: f32) -> f32 {
|
||||||
|
let gain = master * track * multiplier;
|
||||||
|
if gain.is_finite() && gain > 0.0 {
|
||||||
|
gain
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AudioPlayer {
|
||||||
|
monitoring_stream: Option<MixerDeviceSink>,
|
||||||
|
mic_stream: Option<MixerDeviceSink>,
|
||||||
|
|
||||||
|
/// PipeWire nodes backing the two streams, once discovered.
|
||||||
|
monitoring_node: Option<AudioDevice>,
|
||||||
|
mic_node: Option<AudioDevice>,
|
||||||
|
|
||||||
|
/// Links we created ourselves; dropping them tears the routes down.
|
||||||
|
monitoring_route: Option<PwTerminator>,
|
||||||
|
mic_route: Option<PwTerminator>,
|
||||||
|
input_link_sender: Option<PwTerminator>,
|
||||||
|
|
||||||
|
pub tracks: HashMap<u32, PlayingSound>,
|
||||||
|
pub next_id: u32,
|
||||||
|
|
||||||
|
pub input_device_name: Option<String>,
|
||||||
|
/// `None` pins nothing, leaving the monitoring stream to be routed like any other
|
||||||
|
/// application's.
|
||||||
|
pub output_device_name: Option<String>,
|
||||||
|
|
||||||
|
pub monitoring_volume: f32,
|
||||||
|
pub mic_volume: f32,
|
||||||
|
pub volume_multiplier: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioPlayer {
|
||||||
|
pub async fn new() -> Result<Self> {
|
||||||
|
let (
|
||||||
|
default_input_name,
|
||||||
|
default_output_name,
|
||||||
|
default_monitoring_volume,
|
||||||
|
default_mic_volume,
|
||||||
|
default_volume_multiplier,
|
||||||
|
) = with_daemon_config(|c| {
|
||||||
|
(
|
||||||
|
c.default_input_name.clone(),
|
||||||
|
c.default_output_name.clone(),
|
||||||
|
c.default_monitoring_volume.unwrap_or(1.0),
|
||||||
|
c.default_mic_volume.unwrap_or(1.0),
|
||||||
|
c.default_volume_multiplier.unwrap_or(1.0),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut audio_player = AudioPlayer {
|
||||||
|
monitoring_stream: None,
|
||||||
|
mic_stream: None,
|
||||||
|
monitoring_node: None,
|
||||||
|
mic_node: None,
|
||||||
|
monitoring_route: None,
|
||||||
|
mic_route: None,
|
||||||
|
input_link_sender: None,
|
||||||
|
|
||||||
|
tracks: HashMap::new(),
|
||||||
|
next_id: 1,
|
||||||
|
|
||||||
|
input_device_name: default_input_name,
|
||||||
|
output_device_name: default_output_name,
|
||||||
|
|
||||||
|
monitoring_volume: default_monitoring_volume,
|
||||||
|
mic_volume: default_mic_volume,
|
||||||
|
volume_multiplier: default_volume_multiplier,
|
||||||
|
};
|
||||||
|
|
||||||
|
if audio_player.input_device_name.is_some() {
|
||||||
|
audio_player.link_devices().await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(audio_player)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens both output streams and routes them, if that has not happened yet.
|
||||||
|
///
|
||||||
|
/// Streams are opened one at a time: node discovery below tells them apart by diffing
|
||||||
|
/// the graph around each open, which only works if the opens do not overlap.
|
||||||
|
async fn ensure_streams(&mut self) -> Result<()> {
|
||||||
|
if self.monitoring_stream.is_none() {
|
||||||
|
let (stream, node) = open_stream_and_identify().await?;
|
||||||
|
self.monitoring_stream = Some(stream);
|
||||||
|
self.monitoring_node = node;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.mic_stream.is_none() {
|
||||||
|
let (stream, node) = open_stream_and_identify().await?;
|
||||||
|
self.mic_stream = Some(stream);
|
||||||
|
self.mic_node = node;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.ensure_routes().await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closes both output streams once nothing is playing.
|
||||||
|
///
|
||||||
|
/// This is what lets a laptop suspend: an open stream keeps the audio device busy and
|
||||||
|
/// blocks sleep. Routing is rebuilt on the next `play()`, since re-opening the streams
|
||||||
|
/// mints new node ids.
|
||||||
|
fn drop_streams(&mut self) {
|
||||||
|
if self.monitoring_stream.is_none() && self.mic_stream.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.monitoring_stream = None;
|
||||||
|
self.mic_stream = None;
|
||||||
|
// The nodes and our links die with the streams, so none of this may outlive them.
|
||||||
|
self.monitoring_node = None;
|
||||||
|
self.mic_node = None;
|
||||||
|
self.monitoring_route = None;
|
||||||
|
self.mic_route = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-asserts both routes. Idempotent, so it can run on every device-check tick.
|
||||||
|
async fn ensure_routes(&mut self) {
|
||||||
|
if let Some(node) = self.mic_node.clone() {
|
||||||
|
match self.route(&node, get_device(VIRTUAL_MIC_NAME).await).await {
|
||||||
|
Ok(Some(terminator)) => self.mic_route = Some(terminator),
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(err) => eprintln!("Failed to route mic stream to virtual mic: {}", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With nothing pinned the monitoring stream is left alone: picking a target
|
||||||
|
// ourselves would mean re-implementing the session manager's policy, where the
|
||||||
|
// default sink is only a fallback that a per-stream target overrides.
|
||||||
|
if let Some(name) = self.output_device_name.clone()
|
||||||
|
&& let Some(node) = self.monitoring_node.clone()
|
||||||
|
{
|
||||||
|
match self.route(&node, get_sink(&name).await).await {
|
||||||
|
Ok(Some(terminator)) => self.monitoring_route = Some(terminator),
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(err) => eprintln!(
|
||||||
|
"Failed to route monitoring stream to output device {}: {}",
|
||||||
|
name, err
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-reads the source node before linking, so a route survives the node's ports
|
||||||
|
/// being rediscovered.
|
||||||
|
async fn route(
|
||||||
|
&self,
|
||||||
|
source: &AudioDevice,
|
||||||
|
target: Result<AudioDevice>,
|
||||||
|
) -> Result<Option<PwTerminator>> {
|
||||||
|
let source = get_device_by_id(source.id).await?;
|
||||||
|
ensure_route(&source, &target?).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn abort_link_thread(&mut self) {
|
||||||
|
if self.input_link_sender.is_some() {
|
||||||
|
println!("Sent terminate signal to input link thread");
|
||||||
|
self.input_link_sender = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn link_devices(&mut self) -> Result<()> {
|
||||||
|
self.abort_link_thread();
|
||||||
|
|
||||||
|
let input_device;
|
||||||
|
if let Some(input_device_name) = &self.input_device_name {
|
||||||
|
if let Ok(device) = get_device(input_device_name).await {
|
||||||
|
input_device = device;
|
||||||
|
} else {
|
||||||
|
eprintln!(
|
||||||
|
"Could not find selected input device {}, skipping device linking",
|
||||||
|
input_device_name
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
eprintln!("No input device selected, skipping device linking");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let daemon_input;
|
||||||
|
if let Ok(device) = get_device(VIRTUAL_MIC_NAME).await {
|
||||||
|
daemon_input = device;
|
||||||
|
} else {
|
||||||
|
eprintln!("Could not find pwsp-virtual-mic device, skipping device linking");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(output_fl) = input_device.output_fl.clone() else {
|
||||||
|
eprintln!("Failed to get input device output_fl");
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let Some(output_fr) = input_device.output_fr.clone() else {
|
||||||
|
eprintln!("Failed to get input device output_fr");
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let Some(input_fl) = daemon_input.input_fl.clone() else {
|
||||||
|
eprintln!("Failed to get pwsp-virtual-mic input_fl");
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let Some(input_fr) = daemon_input.input_fr.clone() else {
|
||||||
|
eprintln!("Failed to get pwsp-virtual-mic input_fr");
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
self.input_link_sender = Some(create_link(output_fl, output_fr, input_fl, input_fr).await?);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pause(&mut self, id: Option<u32>) {
|
||||||
|
self.for_selected(id, |sound| sound.players.for_each(|p| p.pause()));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resume(&mut self, id: Option<u32>) {
|
||||||
|
self.for_selected(id, |sound| sound.players.for_each(|p| p.play()));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn for_selected(&mut self, id: Option<u32>, f: impl Fn(&mut PlayingSound)) {
|
||||||
|
if let Some(id) = id {
|
||||||
|
if let Some(sound) = self.tracks.get_mut(&id) {
|
||||||
|
f(sound);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for sound in self.tracks.values_mut() {
|
||||||
|
f(sound);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop(&mut self, id: Option<u32>) {
|
||||||
|
if let Some(id) = id {
|
||||||
|
self.tracks.remove(&id);
|
||||||
|
} else {
|
||||||
|
self.tracks.clear();
|
||||||
|
}
|
||||||
|
if self.tracks.is_empty() {
|
||||||
|
self.drop_streams();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_paused(&self) -> bool {
|
||||||
|
if self.tracks.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.tracks
|
||||||
|
.values()
|
||||||
|
.all(|s| s.players.primary().is_paused())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_state(&self) -> PlayerState {
|
||||||
|
if self.tracks.is_empty() {
|
||||||
|
return PlayerState::Stopped;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self
|
||||||
|
.tracks
|
||||||
|
.values()
|
||||||
|
.any(|s| !s.players.primary().is_paused() && !s.players.primary().empty())
|
||||||
|
{
|
||||||
|
return PlayerState::Playing;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.is_paused() {
|
||||||
|
return PlayerState::Paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayerState::Stopped
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_volume(&self, id: Option<u32>) -> Option<f32> {
|
||||||
|
match id {
|
||||||
|
Some(id) => self.tracks.get(&id).map(|sound| sound.volume),
|
||||||
|
None => Some(self.monitoring_volume),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pushes the current master/track/multiplier state onto every live player.
|
||||||
|
///
|
||||||
|
/// Single source of truth for the gain math — every setter below funnels through it.
|
||||||
|
fn reapply_volumes(&mut self) {
|
||||||
|
for sound in self.tracks.values() {
|
||||||
|
sound.players.monitoring.set_volume(effective_gain(
|
||||||
|
self.monitoring_volume,
|
||||||
|
sound.volume,
|
||||||
|
self.volume_multiplier,
|
||||||
|
));
|
||||||
|
if let Some(mic) = &sound.players.mic {
|
||||||
|
mic.set_volume(effective_gain(
|
||||||
|
self.mic_volume,
|
||||||
|
sound.volume,
|
||||||
|
self.volume_multiplier,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_master_volume(&mut self, volume: f32, target: VolumeTarget) {
|
||||||
|
match target {
|
||||||
|
VolumeTarget::Monitoring => self.monitoring_volume = volume,
|
||||||
|
VolumeTarget::Mic => self.mic_volume = volume,
|
||||||
|
}
|
||||||
|
self.reapply_volumes();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_master_volume(&self, target: VolumeTarget) -> f32 {
|
||||||
|
match target {
|
||||||
|
VolumeTarget::Monitoring => self.monitoring_volume,
|
||||||
|
VolumeTarget::Mic => self.mic_volume,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_volume_multiplier(&mut self, multiplier: f32) {
|
||||||
|
self.volume_multiplier = multiplier;
|
||||||
|
self.reapply_volumes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets a single track's volume, or — without an id — moves both masters at once,
|
||||||
|
/// which is the "make everything quieter" shortcut.
|
||||||
|
pub fn set_volume(&mut self, volume: f32, id: Option<u32>) {
|
||||||
|
if let Some(id) = id {
|
||||||
|
if let Some(sound) = self.tracks.get_mut(&id) {
|
||||||
|
sound.volume = volume;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.monitoring_volume = volume;
|
||||||
|
self.mic_volume = volume;
|
||||||
|
}
|
||||||
|
self.reapply_volumes();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_position(&self, id: Option<u32>) -> f32 {
|
||||||
|
if let Some(id) = id {
|
||||||
|
if let Some(sound) = self.tracks.get(&id) {
|
||||||
|
return sound.players.primary().get_pos().as_secs_f32();
|
||||||
|
}
|
||||||
|
} else if let Some(sound) = self.tracks.values().last() {
|
||||||
|
// Fallback to last added track if no ID
|
||||||
|
return sound.players.primary().get_pos().as_secs_f32();
|
||||||
|
}
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn seek(&mut self, position: f32, id: Option<u32>) -> Result<()> {
|
||||||
|
let position = if position < 0.0 { 0.0 } else { position };
|
||||||
|
let position = Duration::from_secs_f32(position);
|
||||||
|
|
||||||
|
if let Some(id) = id {
|
||||||
|
if let Some(sound) = self.tracks.get_mut(&id) {
|
||||||
|
sound.players.monitoring.try_seek(position)?;
|
||||||
|
if let Some(mic) = &sound.players.mic {
|
||||||
|
mic.try_seek(position).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for sound in self.tracks.values_mut() {
|
||||||
|
sound.players.for_each(|p| {
|
||||||
|
p.try_seek(position).ok();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_duration(&mut self, id: Option<u32>) -> Result<f32> {
|
||||||
|
if let Some(id) = id {
|
||||||
|
if let Some(sound) = self.tracks.get(&id) {
|
||||||
|
return sound.duration.ok_or(anyhow!("Unknown duration"));
|
||||||
|
}
|
||||||
|
} else if let Some(sound) = self.tracks.values().last() {
|
||||||
|
return sound.duration.ok_or(anyhow!("Unknown duration"));
|
||||||
|
}
|
||||||
|
Err(anyhow!("No track playing"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn play(&mut self, file_path: &Path, concurrent: bool) -> Result<u32> {
|
||||||
|
// One decoder per output path: the two streams run on different device clocks,
|
||||||
|
// so they cannot share a source anyway, and rodio's `Buffered` (the only shareable
|
||||||
|
// source) does not support seeking.
|
||||||
|
let (monitoring_source, mic_source) = tokio::try_join!(
|
||||||
|
decode(file_path.to_path_buf()),
|
||||||
|
decode(file_path.to_path_buf()),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
if !concurrent {
|
||||||
|
self.tracks.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.ensure_streams().await?;
|
||||||
|
|
||||||
|
let duration = monitoring_source.total_duration().map(|d| d.as_secs_f32());
|
||||||
|
|
||||||
|
let monitoring_mixer = self
|
||||||
|
.monitoring_stream
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow!("monitoring stream is unexpectedly missing"))?
|
||||||
|
.mixer();
|
||||||
|
let monitoring = Player::connect_new(monitoring_mixer);
|
||||||
|
monitoring.set_volume(effective_gain(
|
||||||
|
self.monitoring_volume,
|
||||||
|
1.0,
|
||||||
|
self.volume_multiplier,
|
||||||
|
));
|
||||||
|
monitoring.append(monitoring_source);
|
||||||
|
monitoring.play();
|
||||||
|
|
||||||
|
// A missing mic stream degrades to monitoring-only playback rather than failing.
|
||||||
|
let mic = self.mic_stream.as_ref().map(|stream| {
|
||||||
|
let player = Player::connect_new(stream.mixer());
|
||||||
|
player.set_volume(effective_gain(self.mic_volume, 1.0, self.volume_multiplier));
|
||||||
|
player.append(mic_source);
|
||||||
|
player.play();
|
||||||
|
player
|
||||||
|
});
|
||||||
|
|
||||||
|
let id = self.next_id;
|
||||||
|
self.next_id += 1;
|
||||||
|
|
||||||
|
self.tracks.insert(
|
||||||
|
id,
|
||||||
|
PlayingSound {
|
||||||
|
id,
|
||||||
|
players: PlayerPair { monitoring, mic },
|
||||||
|
path: file_path.to_path_buf(),
|
||||||
|
duration,
|
||||||
|
looped: false,
|
||||||
|
volume: 1.0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_loop(&mut self, enabled: bool, id: Option<u32>) {
|
||||||
|
self.for_selected(id, |sound| sound.looped = enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_tracks(&self) -> Vec<TrackInfo> {
|
||||||
|
let mut tracks: Vec<_> = self
|
||||||
|
.tracks
|
||||||
|
.values()
|
||||||
|
.map(|sound| TrackInfo {
|
||||||
|
id: sound.id,
|
||||||
|
path: sound.path.clone(),
|
||||||
|
duration: sound.duration,
|
||||||
|
position: sound.players.primary().get_pos().as_secs_f32(),
|
||||||
|
volume: sound.volume,
|
||||||
|
looped: sound.looped,
|
||||||
|
paused: sound.players.primary().is_paused(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
tracks.sort_by_key(|t| t.id);
|
||||||
|
tracks
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update(&mut self, check_devices: bool) {
|
||||||
|
if check_devices {
|
||||||
|
if let Some(input_device_name) = &self.input_device_name {
|
||||||
|
// Unlink devices if selected input device was removed
|
||||||
|
if self.input_link_sender.is_some() && get_device(input_device_name).await.is_err()
|
||||||
|
{
|
||||||
|
eprintln!(
|
||||||
|
"Selected input device {} was removed, unlinking devices",
|
||||||
|
input_device_name
|
||||||
|
);
|
||||||
|
self.abort_link_thread();
|
||||||
|
}
|
||||||
|
// Link devices if not linked
|
||||||
|
else if self.input_link_sender.is_none() {
|
||||||
|
self.link_devices().await.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.monitoring_stream.is_some() || self.mic_stream.is_some() {
|
||||||
|
self.ensure_routes().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.restart_looped_tracks().await;
|
||||||
|
|
||||||
|
self.tracks
|
||||||
|
.retain(|_, sound| !sound.players.empty() || sound.looped);
|
||||||
|
|
||||||
|
if self.tracks.is_empty() {
|
||||||
|
self.drop_streams();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn restart_looped_tracks(&mut self) {
|
||||||
|
let restarts: Vec<u32> = self
|
||||||
|
.tracks
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, sound)| sound.looped && sound.players.empty())
|
||||||
|
.map(|(id, _)| *id)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut restart_futures = vec![];
|
||||||
|
for id in restarts {
|
||||||
|
if let Some(sound) = self.tracks.get(&id) {
|
||||||
|
let path = sound.path.clone();
|
||||||
|
restart_futures.push(async move {
|
||||||
|
tokio::try_join!(decode(path.clone()), decode(path))
|
||||||
|
.ok()
|
||||||
|
.map(|(monitoring, mic)| (id, monitoring, mic))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for future in restart_futures {
|
||||||
|
if let Some((id, monitoring_source, mic_source)) = future.await
|
||||||
|
&& let Some(sound) = self.tracks.get_mut(&id)
|
||||||
|
{
|
||||||
|
if sound.players.monitoring.empty() {
|
||||||
|
sound.players.monitoring.append(monitoring_source);
|
||||||
|
sound.players.monitoring.play();
|
||||||
|
}
|
||||||
|
if let Some(mic) = &sound.players.mic
|
||||||
|
&& mic.empty()
|
||||||
|
{
|
||||||
|
mic.append(mic_source);
|
||||||
|
mic.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_current_input_device(&mut self, name: &str) -> Result<()> {
|
||||||
|
let input_device = get_device(name).await?;
|
||||||
|
|
||||||
|
if input_device.device_type != DeviceType::Input {
|
||||||
|
return Err(anyhow!("Selected device is not an input device"));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.input_device_name = Some(name.to_string());
|
||||||
|
|
||||||
|
self.link_devices().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_current_output_device(&mut self, name: &str) -> Result<()> {
|
||||||
|
// Fails early with a useful message if the name does not name a sink.
|
||||||
|
get_sink(name).await?;
|
||||||
|
|
||||||
|
self.output_device_name = Some(name.to_string());
|
||||||
|
self.monitoring_route = None;
|
||||||
|
self.ensure_routes().await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops pinning an output device.
|
||||||
|
///
|
||||||
|
/// The existing link is deliberately left in place: tearing it down would strand the
|
||||||
|
/// node with no output at all, since autoconnect only runs when a node first appears.
|
||||||
|
/// Streams close as soon as playback stops, so the next sound opens a fresh node that
|
||||||
|
/// gets routed normally.
|
||||||
|
pub fn clear_current_output_device(&mut self) {
|
||||||
|
self.output_device_name = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn decode(path: PathBuf) -> Result<FileDecoder> {
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
if !path.exists() {
|
||||||
|
return Err(anyhow!("File does not exist: {}", path.display()));
|
||||||
|
}
|
||||||
|
let file = fs::File::open(&path)?;
|
||||||
|
Decoder::try_from(file).map_err(|e| anyhow!(e))
|
||||||
|
})
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True for the `Stream/Output/Audio` nodes that belong to us.
|
||||||
|
///
|
||||||
|
/// The guard matters: node discovery below hands the result to link pruning, and pruning
|
||||||
|
/// a stranger's node would silence another application.
|
||||||
|
fn is_own_stream_node(device: &AudioDevice) -> bool {
|
||||||
|
let is_pwsp = |s: &str| s.to_ascii_lowercase().contains("pwsp");
|
||||||
|
(is_pwsp(&device.name) || is_pwsp(&device.nick))
|
||||||
|
&& device.output_fl.is_some()
|
||||||
|
&& device.output_fr.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn own_stream_node_ids() -> HashSet<u32> {
|
||||||
|
match get_all_devices().await {
|
||||||
|
Ok(devices) => devices
|
||||||
|
.outputs
|
||||||
|
.iter()
|
||||||
|
.filter(|d| is_own_stream_node(d))
|
||||||
|
.map(|d| d.id)
|
||||||
|
.collect(),
|
||||||
|
Err(_) => HashSet::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens one rodio output stream and works out which PipeWire node it produced.
|
||||||
|
///
|
||||||
|
/// Identification is a before/after diff of our own stream nodes rather than an index
|
||||||
|
/// into a sorted list, so it does not depend on enumeration order. Callers must open
|
||||||
|
/// streams one at a time for the diff to stay unambiguous.
|
||||||
|
///
|
||||||
|
/// A stream that cannot be matched to a node yields `None`: playback still works, only
|
||||||
|
/// explicit routing is skipped.
|
||||||
|
async fn open_stream_and_identify() -> Result<(MixerDeviceSink, Option<AudioDevice>)> {
|
||||||
|
let before = own_stream_node_ids().await;
|
||||||
|
|
||||||
|
let mut stream = DeviceSinkBuilder::open_default_sink()?;
|
||||||
|
stream.log_on_drop(false);
|
||||||
|
|
||||||
|
// Checked before the first sleep: the PCM is already open by the time open_stream
|
||||||
|
// returns, so the node is often registered and this costs nothing.
|
||||||
|
for attempt in 0..NODE_DISCOVERY_ATTEMPTS {
|
||||||
|
if attempt > 0 {
|
||||||
|
tokio::time::sleep(NODE_DISCOVERY_INTERVAL).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let devices = match get_all_devices().await {
|
||||||
|
Ok(devices) => devices,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(node) = devices
|
||||||
|
.outputs
|
||||||
|
.into_iter()
|
||||||
|
.find(|d| !before.contains(&d.id) && is_own_stream_node(d))
|
||||||
|
{
|
||||||
|
return Ok((stream, Some(node)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!("Timed out waiting for the new PipeWire node, routing will be skipped");
|
||||||
|
Ok((stream, None))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_effective_gain() {
|
||||||
|
assert_eq!(effective_gain(1.0, 1.0, 1.0), 1.0);
|
||||||
|
assert_eq!(effective_gain(0.5, 0.5, 1.0), 0.25);
|
||||||
|
|
||||||
|
// Amplification beyond 1.0 is allowed on purpose.
|
||||||
|
assert_eq!(effective_gain(2.0, 1.0, 1.0), 2.0);
|
||||||
|
assert_eq!(effective_gain(2.0, 1.0, 3.0), 6.0);
|
||||||
|
|
||||||
|
// Anything that is not a usable gain collapses to silence.
|
||||||
|
assert_eq!(effective_gain(0.0, 1.0, 1.0), 0.0);
|
||||||
|
assert_eq!(effective_gain(-1.0, 1.0, 1.0), 0.0);
|
||||||
|
assert_eq!(effective_gain(f32::NAN, 1.0, 1.0), 0.0);
|
||||||
|
assert_eq!(effective_gain(f32::INFINITY, 1.0, 1.0), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stream_node(name: &str, with_ports: bool) -> AudioDevice {
|
||||||
|
use crate::types::pipewire::Port;
|
||||||
|
|
||||||
|
let mut device = AudioDevice::new(1, None, None, Some(name), DeviceType::Output);
|
||||||
|
if with_ports {
|
||||||
|
device.add_port(Port {
|
||||||
|
node_id: 1,
|
||||||
|
port_id: 1,
|
||||||
|
name: "output_FL".to_string(),
|
||||||
|
});
|
||||||
|
device.add_port(Port {
|
||||||
|
node_id: 1,
|
||||||
|
port_id: 2,
|
||||||
|
name: "output_FR".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
device
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_own_stream_node() {
|
||||||
|
assert!(is_own_stream_node(&stream_node(
|
||||||
|
"alsa_playback.pwsp-daemon",
|
||||||
|
true
|
||||||
|
)));
|
||||||
|
assert!(is_own_stream_node(&stream_node("PWSP-daemon", true)));
|
||||||
|
|
||||||
|
// Somebody else's playback stream must never be treated as ours.
|
||||||
|
assert!(!is_own_stream_node(&stream_node("Firefox", true)));
|
||||||
|
// Nor a node whose ports have not been discovered yet.
|
||||||
|
assert!(!is_own_stream_node(&stream_node(
|
||||||
|
"alsa_playback.pwsp-daemon",
|
||||||
|
false
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
types::{
|
types::{
|
||||||
audio_player::{FullState, PlayerState},
|
audio_player::{FullState, PlayerState, VolumeTarget},
|
||||||
config::HotkeyConfig,
|
config::{DaemonConfig, HotkeyConfig},
|
||||||
socket::{Request, Response},
|
socket::{Request, Response},
|
||||||
},
|
},
|
||||||
utils::{
|
utils::{
|
||||||
commands::parse_command,
|
commands::parse_command,
|
||||||
daemon::get_audio_player,
|
daemon::{get_audio_player, with_daemon_config},
|
||||||
pipewire::{get_all_devices, get_device},
|
pipewire::{get_all_devices, get_device},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -46,11 +46,26 @@ pub struct GetVolumeCommand {
|
|||||||
pub id: Option<u32>,
|
pub id: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct GetVolumeMultiplierCommand {}
|
||||||
|
|
||||||
pub struct SetVolumeCommand {
|
pub struct SetVolumeCommand {
|
||||||
pub volume: Option<f32>,
|
pub volume: Option<f32>,
|
||||||
pub id: Option<u32>,
|
pub id: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct SetVolumeMultiplierCommand {
|
||||||
|
pub volume_multiplier: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct GetMasterVolumeCommand {
|
||||||
|
pub target: VolumeTarget,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SetMasterVolumeCommand {
|
||||||
|
pub volume: Option<f32>,
|
||||||
|
pub target: VolumeTarget,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct GetPositionCommand {
|
pub struct GetPositionCommand {
|
||||||
pub id: Option<u32>,
|
pub id: Option<u32>,
|
||||||
}
|
}
|
||||||
@@ -79,6 +94,14 @@ pub struct SetCurrentInputCommand {
|
|||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct GetCurrentOutputCommand {}
|
||||||
|
|
||||||
|
pub struct GetAllOutputsCommand {}
|
||||||
|
|
||||||
|
pub struct SetCurrentOutputCommand {
|
||||||
|
pub name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct SetLoopCommand {
|
pub struct SetLoopCommand {
|
||||||
pub enabled: Option<bool>,
|
pub enabled: Option<bool>,
|
||||||
pub id: Option<u32>,
|
pub id: Option<u32>,
|
||||||
@@ -127,6 +150,14 @@ pub struct ClearHotkeyKeyCommand {
|
|||||||
pub slot: Option<String>,
|
pub slot: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct GetDaemonConfigCommand {}
|
||||||
|
|
||||||
|
pub struct SaveDaemonConfigCommand {}
|
||||||
|
|
||||||
|
pub struct UpdateDaemonConfigCommand {
|
||||||
|
pub new_config: DaemonConfig,
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Executable for PingCommand {
|
impl Executable for PingCommand {
|
||||||
async fn execute(&self) -> Response {
|
async fn execute(&self) -> Response {
|
||||||
@@ -184,7 +215,7 @@ impl Executable for TogglePauseCommand {
|
|||||||
|
|
||||||
if let Some(id) = self.id {
|
if let Some(id) = self.id {
|
||||||
if let Some(track) = audio_player.tracks.get(&id) {
|
if let Some(track) = audio_player.tracks.get(&id) {
|
||||||
if track.sink.is_paused() {
|
if track.players.primary().is_paused() {
|
||||||
audio_player.resume(Some(id));
|
audio_player.resume(Some(id));
|
||||||
Response::new(true, "Audio was resumed")
|
Response::new(true, "Audio was resumed")
|
||||||
} else {
|
} else {
|
||||||
@@ -248,7 +279,7 @@ impl Executable for GetStateCommand {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Executable for GetVolumeCommand {
|
impl Executable for GetVolumeCommand {
|
||||||
async fn execute(&self) -> Response {
|
async fn execute(&self) -> Response {
|
||||||
let mut audio_player = match get_audio_player().await {
|
let audio_player = match get_audio_player().await {
|
||||||
Ok(player) => player.lock().await,
|
Ok(player) => player.lock().await,
|
||||||
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
|
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
|
||||||
};
|
};
|
||||||
@@ -262,10 +293,28 @@ impl Executable for GetVolumeCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Executable for GetVolumeMultiplierCommand {
|
||||||
|
async fn execute(&self) -> Response {
|
||||||
|
let audio_player = match get_audio_player().await {
|
||||||
|
Ok(player) => player.lock().await,
|
||||||
|
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
|
||||||
|
};
|
||||||
|
|
||||||
|
Response::new(true, audio_player.volume_multiplier.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rejects values that would corrupt the mixer. The daemon is the trust boundary here:
|
||||||
|
/// hotkeys store raw `Request` JSON, so a bad value can arrive without passing the CLI.
|
||||||
|
fn validate_volume(volume: Option<f32>) -> Option<f32> {
|
||||||
|
volume.filter(|v| v.is_finite() && *v >= 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
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) = validate_volume(self.volume) {
|
||||||
let mut audio_player = match get_audio_player().await {
|
let mut audio_player = match get_audio_player().await {
|
||||||
Ok(player) => player.lock().await,
|
Ok(player) => player.lock().await,
|
||||||
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
|
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
|
||||||
@@ -278,6 +327,60 @@ impl Executable for SetVolumeCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Executable for GetMasterVolumeCommand {
|
||||||
|
async fn execute(&self) -> Response {
|
||||||
|
let audio_player = match get_audio_player().await {
|
||||||
|
Ok(player) => player.lock().await,
|
||||||
|
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
|
||||||
|
};
|
||||||
|
|
||||||
|
Response::new(
|
||||||
|
true,
|
||||||
|
audio_player.get_master_volume(self.target).to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Executable for SetMasterVolumeCommand {
|
||||||
|
async fn execute(&self) -> Response {
|
||||||
|
if let Some(volume) = validate_volume(self.volume) {
|
||||||
|
let mut audio_player = match get_audio_player().await {
|
||||||
|
Ok(player) => player.lock().await,
|
||||||
|
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
|
||||||
|
};
|
||||||
|
audio_player.set_master_volume(volume, self.target);
|
||||||
|
let name = match self.target {
|
||||||
|
VolumeTarget::Monitoring => "Monitoring",
|
||||||
|
VolumeTarget::Mic => "Microphone",
|
||||||
|
};
|
||||||
|
Response::new(true, format!("{} volume was set to {}", name, volume))
|
||||||
|
} else {
|
||||||
|
Response::new(false, "Invalid volume value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Executable for SetVolumeMultiplierCommand {
|
||||||
|
async fn execute(&self) -> Response {
|
||||||
|
if let Some(volume_multiplier) = validate_volume(self.volume_multiplier) {
|
||||||
|
let mut audio_player = match get_audio_player().await {
|
||||||
|
Ok(player) => player.lock().await,
|
||||||
|
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
|
||||||
|
};
|
||||||
|
audio_player.set_volume_multiplier(volume_multiplier);
|
||||||
|
Response::new(
|
||||||
|
true,
|
||||||
|
format!("Audio volume multiplier was set to {}", volume_multiplier),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Response::new(false, "Invalid volume multiplier value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Executable for GetPositionCommand {
|
impl Executable for GetPositionCommand {
|
||||||
async fn execute(&self) -> Response {
|
async fn execute(&self) -> Response {
|
||||||
@@ -383,8 +486,8 @@ impl Executable for GetCurrentInputCommand {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Executable for GetAllInputsCommand {
|
impl Executable for GetAllInputsCommand {
|
||||||
async fn execute(&self) -> Response {
|
async fn execute(&self) -> Response {
|
||||||
let (input_devices, _output_devices) = match get_all_devices().await {
|
let input_devices = match get_all_devices().await {
|
||||||
Ok(devices) => devices,
|
Ok(devices) => devices.inputs,
|
||||||
Err(err) => return Response::new(false, format!("Failed to get devices: {}", err)),
|
Err(err) => return Response::new(false, format!("Failed to get devices: {}", err)),
|
||||||
};
|
};
|
||||||
let mut input_devices_strings = vec![];
|
let mut input_devices_strings = vec![];
|
||||||
@@ -420,6 +523,66 @@ impl Executable for SetCurrentInputCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Executable for GetCurrentOutputCommand {
|
||||||
|
async fn execute(&self) -> Response {
|
||||||
|
let audio_player = match get_audio_player().await {
|
||||||
|
Ok(player) => player.lock().await,
|
||||||
|
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
|
||||||
|
};
|
||||||
|
|
||||||
|
match &audio_player.output_device_name {
|
||||||
|
Some(name) => Response::new(true, name),
|
||||||
|
// No pinned device means playback follows whatever the system default is.
|
||||||
|
None => Response::new(true, ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Executable for GetAllOutputsCommand {
|
||||||
|
async fn execute(&self) -> Response {
|
||||||
|
let sinks = match get_all_devices().await {
|
||||||
|
Ok(devices) => devices.sinks,
|
||||||
|
Err(err) => return Response::new(false, format!("Failed to get devices: {}", err)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let response_message = sinks
|
||||||
|
.into_iter()
|
||||||
|
.map(|device| format!("{} - {}", device.name, device.nick))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("; ");
|
||||||
|
|
||||||
|
Response::new(true, response_message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Executable for SetCurrentOutputCommand {
|
||||||
|
async fn execute(&self) -> Response {
|
||||||
|
let Some(name) = &self.name else {
|
||||||
|
return Response::new(false, "Invalid device name");
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut audio_player = match get_audio_player().await {
|
||||||
|
Ok(player) => player.lock().await,
|
||||||
|
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
|
||||||
|
};
|
||||||
|
|
||||||
|
// An empty name is how a client asks to stop pinning a device and follow the
|
||||||
|
// system default again.
|
||||||
|
if name.is_empty() {
|
||||||
|
audio_player.clear_current_output_device();
|
||||||
|
return Response::new(true, "Output device follows the system default");
|
||||||
|
}
|
||||||
|
|
||||||
|
match audio_player.set_current_output_device(name).await {
|
||||||
|
Ok(_) => Response::new(true, "Output device was set"),
|
||||||
|
Err(err) => Response::new(false, err.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Executable for SetLoopCommand {
|
impl Executable for SetLoopCommand {
|
||||||
async fn execute(&self) -> Response {
|
async fn execute(&self) -> Response {
|
||||||
@@ -472,10 +635,11 @@ impl Executable for GetDaemonVersionCommand {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Executable for GetFullStateCommand {
|
impl Executable for GetFullStateCommand {
|
||||||
async fn execute(&self) -> Response {
|
async fn execute(&self) -> Response {
|
||||||
let (input_devices, _output_devices) = match get_all_devices().await {
|
let devices = match get_all_devices().await {
|
||||||
Ok(devices) => devices,
|
Ok(devices) => devices,
|
||||||
Err(err) => return Response::new(false, format!("Failed to get devices: {}", err)),
|
Err(err) => return Response::new(false, format!("Failed to get devices: {}", err)),
|
||||||
};
|
};
|
||||||
|
let input_devices = devices.inputs;
|
||||||
let mut all_inputs = HashMap::new();
|
let mut all_inputs = HashMap::new();
|
||||||
let mut current_input_nick = String::new();
|
let mut current_input_nick = String::new();
|
||||||
|
|
||||||
@@ -504,12 +668,22 @@ impl Executable for GetFullStateCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let all_outputs: HashMap<String, String> = devices
|
||||||
|
.sinks
|
||||||
|
.into_iter()
|
||||||
|
.map(|device| (device.name, device.nick))
|
||||||
|
.collect();
|
||||||
|
|
||||||
let full_state = FullState {
|
let full_state = FullState {
|
||||||
state: audio_player.get_state(),
|
state: audio_player.get_state(),
|
||||||
tracks: audio_player.get_tracks(),
|
tracks: audio_player.get_tracks(),
|
||||||
volume: audio_player.volume,
|
monitoring_volume: audio_player.monitoring_volume,
|
||||||
|
mic_volume: audio_player.mic_volume,
|
||||||
|
volume_multiplier: audio_player.volume_multiplier,
|
||||||
current_input: current_input_nick,
|
current_input: current_input_nick,
|
||||||
all_inputs,
|
all_inputs,
|
||||||
|
current_output: audio_player.output_device_name.clone().unwrap_or_default(),
|
||||||
|
all_outputs,
|
||||||
};
|
};
|
||||||
|
|
||||||
match serde_json::to_string(&full_state) {
|
match serde_json::to_string(&full_state) {
|
||||||
@@ -723,3 +897,36 @@ impl Executable for ClearHotkeyKeyCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Executable for GetDaemonConfigCommand {
|
||||||
|
async fn execute(&self) -> Response {
|
||||||
|
let serialized = with_daemon_config(|c| serde_json::to_string(&c));
|
||||||
|
|
||||||
|
match serialized {
|
||||||
|
Ok(s) => Response::new(true, s),
|
||||||
|
Err(err) => Response::new(false, format!("Failed to serialize daemon config: {}", err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Executable for SaveDaemonConfigCommand {
|
||||||
|
async fn execute(&self) -> Response {
|
||||||
|
match with_daemon_config(|c| c.save_to_file()) {
|
||||||
|
Ok(_) => Response::new(true, "Daemon config saved successfully"),
|
||||||
|
Err(err) => Response::new(false, format!("Failed to save daemon config: {}", err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Executable for UpdateDaemonConfigCommand {
|
||||||
|
async fn execute(&self) -> Response {
|
||||||
|
with_daemon_config(|c| {
|
||||||
|
c.clone_from(&self.new_config);
|
||||||
|
});
|
||||||
|
|
||||||
|
Response::new(true, "Daemon config updated successfully")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
use crate::{
|
||||||
|
types::socket::Request,
|
||||||
|
utils::{config::get_config_path, gui::ensure_pwsp_audio_dir},
|
||||||
|
};
|
||||||
|
use anyhow::Result;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{
|
||||||
|
cmp::Ordering,
|
||||||
|
collections::HashMap,
|
||||||
|
fs,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
time::SystemTime,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Default, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct DaemonConfig {
|
||||||
|
pub default_input_name: Option<String>,
|
||||||
|
pub default_output_name: Option<String>,
|
||||||
|
pub default_monitoring_volume: Option<f32>,
|
||||||
|
pub default_mic_volume: Option<f32>,
|
||||||
|
pub default_volume_multiplier: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DaemonConfig {
|
||||||
|
pub fn save_to_file(&self) -> Result<()> {
|
||||||
|
let config_path = get_config_path()?.join("daemon.json");
|
||||||
|
|
||||||
|
if let Some(config_dir) = config_path.parent()
|
||||||
|
&& !config_path.exists()
|
||||||
|
{
|
||||||
|
fs::create_dir_all(config_dir)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let config_json = serde_json::to_string_pretty(self)?;
|
||||||
|
fs::write(config_path, config_json.as_bytes())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_from_file() -> Result<DaemonConfig> {
|
||||||
|
let config_path = get_config_path()?.join("daemon.json");
|
||||||
|
let bytes = fs::read(config_path)?;
|
||||||
|
match serde_json::from_slice::<DaemonConfig>(&bytes) {
|
||||||
|
Ok(config) => Ok(config),
|
||||||
|
Err(_) => Ok(DaemonConfig::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub enum PreferredTheme {
|
||||||
|
System,
|
||||||
|
Light,
|
||||||
|
Dark,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub enum SortOrder {
|
||||||
|
#[default]
|
||||||
|
AlphabeticalAsc,
|
||||||
|
AlphabeticalDesc,
|
||||||
|
DateModifiedNewest,
|
||||||
|
DateModifiedOldest,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct DirSettings {
|
||||||
|
pub sort_order: SortOrder,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct GuiConfig {
|
||||||
|
pub scale_factor: f32,
|
||||||
|
pub left_panel_width: f32,
|
||||||
|
|
||||||
|
pub save_volume: bool,
|
||||||
|
pub save_volume_multiplier: bool,
|
||||||
|
pub save_input: bool,
|
||||||
|
pub save_scale_factor: bool,
|
||||||
|
pub pause_on_exit: bool,
|
||||||
|
|
||||||
|
pub dirs: Vec<PathBuf>,
|
||||||
|
pub dirs_settings: HashMap<PathBuf, DirSettings>,
|
||||||
|
|
||||||
|
pub preferred_theme: PreferredTheme,
|
||||||
|
/// UI language code, or `None` to follow the system locale.
|
||||||
|
pub forced_lang: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SortOrder {
|
||||||
|
pub fn compare(&self, a: &Path, b: &Path) -> Ordering {
|
||||||
|
match self {
|
||||||
|
SortOrder::AlphabeticalAsc => a.cmp(b),
|
||||||
|
SortOrder::AlphabeticalDesc => b.cmp(a),
|
||||||
|
SortOrder::DateModifiedNewest => {
|
||||||
|
let a_time = fs::metadata(a)
|
||||||
|
.and_then(|m| m.modified())
|
||||||
|
.unwrap_or(SystemTime::UNIX_EPOCH);
|
||||||
|
let b_time = fs::metadata(b)
|
||||||
|
.and_then(|m| m.modified())
|
||||||
|
.unwrap_or(SystemTime::UNIX_EPOCH);
|
||||||
|
b_time.cmp(&a_time)
|
||||||
|
}
|
||||||
|
SortOrder::DateModifiedOldest => {
|
||||||
|
let a_time = fs::metadata(a)
|
||||||
|
.and_then(|m| m.modified())
|
||||||
|
.unwrap_or(SystemTime::UNIX_EPOCH);
|
||||||
|
let b_time = fs::metadata(b)
|
||||||
|
.and_then(|m| m.modified())
|
||||||
|
.unwrap_or(SystemTime::UNIX_EPOCH);
|
||||||
|
a_time.cmp(&b_time)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for GuiConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
GuiConfig {
|
||||||
|
scale_factor: 1.0,
|
||||||
|
left_panel_width: 280.0,
|
||||||
|
|
||||||
|
save_volume: false,
|
||||||
|
save_volume_multiplier: false,
|
||||||
|
save_input: false,
|
||||||
|
save_scale_factor: false,
|
||||||
|
pause_on_exit: false,
|
||||||
|
|
||||||
|
dirs: vec![ensure_pwsp_audio_dir().unwrap()],
|
||||||
|
|
||||||
|
preferred_theme: PreferredTheme::System,
|
||||||
|
dirs_settings: HashMap::new(),
|
||||||
|
forced_lang: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GuiConfig {
|
||||||
|
pub fn get_sort_order(&self, path: &Path) -> SortOrder {
|
||||||
|
let mut current = Some(path);
|
||||||
|
while let Some(p) = current {
|
||||||
|
if let Some(settings) = self.dirs_settings.get(p) {
|
||||||
|
return settings.sort_order;
|
||||||
|
}
|
||||||
|
current = p.parent();
|
||||||
|
}
|
||||||
|
SortOrder::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_to_file(&mut self) -> Result<()> {
|
||||||
|
let config_path = get_config_path()?.join("gui.json");
|
||||||
|
|
||||||
|
if let Some(config_dir) = config_path.parent()
|
||||||
|
&& !config_path.exists()
|
||||||
|
{
|
||||||
|
fs::create_dir_all(config_dir)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do not save scale factor if user does not want to
|
||||||
|
if !self.save_scale_factor {
|
||||||
|
self.scale_factor = 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let config_json = serde_json::to_string_pretty(self)?;
|
||||||
|
fs::write(config_path, config_json.as_bytes())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_from_file() -> Result<GuiConfig> {
|
||||||
|
let config_path = get_config_path()?.join("gui.json");
|
||||||
|
let bytes = fs::read(config_path)?;
|
||||||
|
match serde_json::from_slice::<GuiConfig>(&bytes) {
|
||||||
|
Ok(config) => Ok(config),
|
||||||
|
Err(_) => Ok(GuiConfig::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct HotkeySlot {
|
||||||
|
pub slot: String,
|
||||||
|
pub action: Request,
|
||||||
|
pub key_chord: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct HotkeyConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub slots: Vec<HotkeySlot>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HotkeyConfig {
|
||||||
|
pub fn config_path() -> Result<PathBuf> {
|
||||||
|
Ok(get_config_path()?.join("hotkeys.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load() -> Result<HotkeyConfig> {
|
||||||
|
let path = Self::config_path()?;
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(HotkeyConfig::default());
|
||||||
|
}
|
||||||
|
let bytes = fs::read(&path)?;
|
||||||
|
match serde_json::from_slice::<HotkeyConfig>(&bytes) {
|
||||||
|
Ok(config) => Ok(config),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self) -> Result<()> {
|
||||||
|
let path = Self::config_path()?;
|
||||||
|
if let Some(dir) = path.parent()
|
||||||
|
&& !dir.exists()
|
||||||
|
{
|
||||||
|
fs::create_dir_all(dir)?;
|
||||||
|
}
|
||||||
|
let json = serde_json::to_string_pretty(self)?;
|
||||||
|
fs::write(path, json.as_bytes())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_slot(&self, slot: &str) -> Option<&HotkeySlot> {
|
||||||
|
self.slots.iter().find(|s| s.slot == slot)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_slot_mut(&mut self, slot: &str) -> Option<&mut HotkeySlot> {
|
||||||
|
self.slots.iter_mut().find(|s| s.slot == slot)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_slot(&mut self, slot: String, action: Request) {
|
||||||
|
if let Some(existing) = self.find_slot_mut(&slot) {
|
||||||
|
existing.action = action;
|
||||||
|
} else {
|
||||||
|
self.slots.push(HotkeySlot {
|
||||||
|
slot,
|
||||||
|
action,
|
||||||
|
key_chord: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_key_chord(&mut self, slot: &str, key_chord: Option<String>) -> bool {
|
||||||
|
if let Some(existing) = self.find_slot_mut(slot) {
|
||||||
|
existing.key_chord = key_chord;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_slot(&mut self, slot: &str) -> bool {
|
||||||
|
let len = self.slots.len();
|
||||||
|
self.slots.retain(|s| s.slot != slot);
|
||||||
|
self.slots.len() != len
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns pairs of slot names that share the same key chord.
|
||||||
|
pub fn find_conflicts(&self) -> Vec<(&str, &str)> {
|
||||||
|
let mut conflicts = vec![];
|
||||||
|
let mut chord_map: HashMap<&str, Vec<&str>> = HashMap::new();
|
||||||
|
|
||||||
|
for s in &self.slots {
|
||||||
|
if let Some(chord) = &s.key_chord {
|
||||||
|
chord_map.entry(chord.as_str()).or_default().push(&s.slot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for slots in chord_map.values() {
|
||||||
|
if slots.len() > 1 {
|
||||||
|
for i in 0..slots.len() {
|
||||||
|
for j in (i + 1)..slots.len() {
|
||||||
|
conflicts.push((slots[i], slots[j]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
conflicts
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find which slot(s) have the given key chord.
|
||||||
|
pub fn slots_for_chord(&self, chord: &str) -> Vec<&HotkeySlot> {
|
||||||
|
self.slots
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.key_chord.as_deref() == Some(chord))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_gui_config_default() {
|
||||||
|
let config = GuiConfig::default();
|
||||||
|
assert_eq!(config.scale_factor, 1.0);
|
||||||
|
assert_eq!(config.left_panel_width, 280.0);
|
||||||
|
assert!(!config.save_volume);
|
||||||
|
assert_eq!(config.preferred_theme, PreferredTheme::System);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_hotkey_config_operations() {
|
||||||
|
let mut config = HotkeyConfig::default();
|
||||||
|
assert!(config.slots.is_empty());
|
||||||
|
|
||||||
|
let req = Request::ping();
|
||||||
|
config.set_slot("slot1".to_string(), req.clone());
|
||||||
|
assert_eq!(config.slots.len(), 1);
|
||||||
|
assert_eq!(config.slots[0].slot, "slot1");
|
||||||
|
assert_eq!(config.slots[0].action, req);
|
||||||
|
assert!(config.slots[0].key_chord.is_none());
|
||||||
|
|
||||||
|
// Test find_slot
|
||||||
|
let found = config.find_slot("slot1");
|
||||||
|
assert!(found.is_some());
|
||||||
|
assert_eq!(found.unwrap().slot, "slot1");
|
||||||
|
|
||||||
|
// Test set_key_chord
|
||||||
|
let updated = config.set_key_chord("slot1", Some("Ctrl+A".to_string()));
|
||||||
|
assert!(updated);
|
||||||
|
assert_eq!(config.slots[0].key_chord.as_deref(), Some("Ctrl+A"));
|
||||||
|
|
||||||
|
// Test set_key_chord for non-existent slot
|
||||||
|
let updated_non_existent = config.set_key_chord("slot2", Some("Ctrl+B".to_string()));
|
||||||
|
assert!(!updated_non_existent);
|
||||||
|
|
||||||
|
// Test find_slot_mut
|
||||||
|
let found_mut = config.find_slot_mut("slot1");
|
||||||
|
assert!(found_mut.is_some());
|
||||||
|
found_mut.unwrap().key_chord = Some("Ctrl+B".to_string());
|
||||||
|
assert_eq!(config.slots[0].key_chord.as_deref(), Some("Ctrl+B"));
|
||||||
|
|
||||||
|
// Test slots_for_chord
|
||||||
|
let slots = config.slots_for_chord("Ctrl+B");
|
||||||
|
assert_eq!(slots.len(), 1);
|
||||||
|
assert_eq!(slots[0].slot, "slot1");
|
||||||
|
|
||||||
|
let empty_slots = config.slots_for_chord("Ctrl+A");
|
||||||
|
assert!(empty_slots.is_empty());
|
||||||
|
|
||||||
|
// Test remove_slot
|
||||||
|
let removed = config.remove_slot("slot1");
|
||||||
|
assert!(removed);
|
||||||
|
assert!(config.slots.is_empty());
|
||||||
|
|
||||||
|
let removed_non_existent = config.remove_slot("slot1");
|
||||||
|
assert!(!removed_non_existent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_hotkey_config_conflicts() {
|
||||||
|
let mut config = HotkeyConfig::default();
|
||||||
|
config.set_slot("slot1".to_string(), Request::ping());
|
||||||
|
config.set_slot("slot2".to_string(), Request::ping());
|
||||||
|
config.set_slot("slot3".to_string(), Request::ping());
|
||||||
|
|
||||||
|
config.set_key_chord("slot1", Some("Ctrl+A".to_string()));
|
||||||
|
config.set_key_chord("slot2", Some("Ctrl+A".to_string())); // Conflict with slot1
|
||||||
|
config.set_key_chord("slot3", Some("Ctrl+B".to_string()));
|
||||||
|
|
||||||
|
let conflicts = config.find_conflicts();
|
||||||
|
assert_eq!(conflicts.len(), 1);
|
||||||
|
assert!(conflicts.contains(&("slot1", "slot2")) || conflicts.contains(&("slot2", "slot1")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
use crate::types::{
|
||||||
|
audio_player::{PlayerState, TrackInfo},
|
||||||
|
config::HotkeyConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
use egui::Id;
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
collections::{HashMap, HashSet},
|
||||||
|
path::PathBuf,
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub type ScanResult = (PathBuf, Vec<PathBuf>, HashMap<PathBuf, Vec<PathBuf>>);
|
||||||
|
|
||||||
|
/// How long the daemon's value is ignored after a slider commits, so the knob does not
|
||||||
|
/// snap back before the daemon has caught up.
|
||||||
|
const SETTLE_TIME: Duration = Duration::from_millis(300);
|
||||||
|
|
||||||
|
/// A slider whose local value wins over the daemon's while the user is interacting.
|
||||||
|
///
|
||||||
|
/// The daemon is polled at 60 Hz, so without this the value would fight the user mid-drag
|
||||||
|
/// and jump back right after release.
|
||||||
|
#[derive(Default, Debug)]
|
||||||
|
pub struct SliderLatch {
|
||||||
|
pub value: f32,
|
||||||
|
pub dragged: bool,
|
||||||
|
pub ignore_update_until: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SliderLatch {
|
||||||
|
/// Whether the daemon's value may overwrite the local one this frame.
|
||||||
|
fn should_sync(&self) -> bool {
|
||||||
|
!self.dragged
|
||||||
|
&& self
|
||||||
|
.ignore_update_until
|
||||||
|
.is_none_or(|until| Instant::now() > until)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adopts the daemon's value unless the user is currently driving the slider.
|
||||||
|
pub fn sync(&mut self, value: f32) {
|
||||||
|
if self.should_sync() {
|
||||||
|
self.value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called once the pending change has been sent to the daemon.
|
||||||
|
fn commit(&mut self) {
|
||||||
|
self.dragged = false;
|
||||||
|
self.ignore_update_until = Some(Instant::now() + SETTLE_TIME);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the value to send, if the user finished a change that has not been sent yet.
|
||||||
|
pub fn take_pending(&mut self) -> Option<f32> {
|
||||||
|
if self.dragged {
|
||||||
|
let value = self.value;
|
||||||
|
self.commit();
|
||||||
|
Some(value)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Debug)]
|
||||||
|
pub struct TrackUiState {
|
||||||
|
pub position: SliderLatch,
|
||||||
|
pub volume: SliderLatch,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Debug)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub search_query: String,
|
||||||
|
|
||||||
|
pub track_ui_states: HashMap<u32, TrackUiState>,
|
||||||
|
|
||||||
|
pub show_settings: bool,
|
||||||
|
pub force_focus_search: bool,
|
||||||
|
|
||||||
|
pub monitoring_volume: SliderLatch,
|
||||||
|
pub mic_volume: SliderLatch,
|
||||||
|
pub volume_multiplier: SliderLatch,
|
||||||
|
|
||||||
|
pub search_field_id: Option<Id>,
|
||||||
|
|
||||||
|
pub current_dir: Option<PathBuf>,
|
||||||
|
pub dirs: Vec<PathBuf>,
|
||||||
|
pub dirs_to_remove: HashSet<PathBuf>,
|
||||||
|
|
||||||
|
pub listed_files: HashSet<PathBuf>,
|
||||||
|
pub listed_dirs: HashSet<PathBuf>,
|
||||||
|
pub dir_cache: HashMap<PathBuf, Vec<PathBuf>>,
|
||||||
|
|
||||||
|
pub scanning_dirs: Arc<Mutex<HashSet<PathBuf>>>,
|
||||||
|
pub scanned_this_session: HashSet<PathBuf>,
|
||||||
|
pub finished_scans: Arc<Mutex<Vec<ScanResult>>>,
|
||||||
|
pub recursive_files_cache: HashMap<PathBuf, Vec<PathBuf>>,
|
||||||
|
|
||||||
|
pub show_hotkeys: bool,
|
||||||
|
pub hotkey_capture_active: bool,
|
||||||
|
|
||||||
|
pub hotkey_config: HotkeyConfig,
|
||||||
|
pub hotkey_search_query: String,
|
||||||
|
|
||||||
|
pub assigning_hotkey_slot: Option<String>,
|
||||||
|
pub assigning_hotkey_for_file: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Debug, Clone)]
|
||||||
|
pub struct AudioPlayerState {
|
||||||
|
pub state: PlayerState,
|
||||||
|
pub new_state: Option<PlayerState>,
|
||||||
|
|
||||||
|
pub tracks: Vec<TrackInfo>,
|
||||||
|
|
||||||
|
/// What the user hears locally.
|
||||||
|
pub monitoring_volume: f32,
|
||||||
|
/// What is sent to the virtual microphone.
|
||||||
|
pub mic_volume: f32,
|
||||||
|
pub volume_multiplier: f32,
|
||||||
|
|
||||||
|
pub current_input: String,
|
||||||
|
pub all_inputs: HashMap<String, String>,
|
||||||
|
pub all_inputs_sorted: Vec<(String, String)>,
|
||||||
|
|
||||||
|
/// Empty means "follow the system default sink".
|
||||||
|
pub current_output: String,
|
||||||
|
pub all_outputs: HashMap<String, String>,
|
||||||
|
pub all_outputs_sorted: Vec<(String, String)>,
|
||||||
|
|
||||||
|
pub is_daemon_running: bool,
|
||||||
|
|
||||||
|
pub hotkey_config: Option<HotkeyConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_slider_latch_syncs_when_idle() {
|
||||||
|
let mut latch = SliderLatch::default();
|
||||||
|
|
||||||
|
latch.sync(0.7);
|
||||||
|
assert_eq!(latch.value, 0.7);
|
||||||
|
|
||||||
|
// While the user drags, the daemon must not move the knob out from under them.
|
||||||
|
latch.dragged = true;
|
||||||
|
latch.sync(0.2);
|
||||||
|
assert_eq!(latch.value, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_slider_latch_take_pending() {
|
||||||
|
let mut latch = SliderLatch::default();
|
||||||
|
|
||||||
|
// Nothing to send until the user actually changes something.
|
||||||
|
assert_eq!(latch.take_pending(), None);
|
||||||
|
|
||||||
|
latch.value = 1.5;
|
||||||
|
latch.dragged = true;
|
||||||
|
assert_eq!(latch.take_pending(), Some(1.5));
|
||||||
|
|
||||||
|
// The change is sent exactly once.
|
||||||
|
assert_eq!(latch.take_pending(), None);
|
||||||
|
|
||||||
|
// The daemon is then ignored briefly, so a stale value already in flight cannot
|
||||||
|
// snap the slider back.
|
||||||
|
assert!(!latch.should_sync());
|
||||||
|
latch.sync(0.1);
|
||||||
|
assert_eq!(latch.value, 1.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Terminate {}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Ord, PartialOrd, Eq, PartialEq)]
|
||||||
|
pub struct Port {
|
||||||
|
pub node_id: u32,
|
||||||
|
pub port_id: u32,
|
||||||
|
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Ord, PartialOrd, Eq, PartialEq)]
|
||||||
|
pub enum DeviceType {
|
||||||
|
Input,
|
||||||
|
Output,
|
||||||
|
Sink,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A link between two nodes in the PipeWire graph.
|
||||||
|
#[derive(Debug, Clone, Copy, Ord, PartialOrd, Eq, PartialEq)]
|
||||||
|
pub struct LinkInfo {
|
||||||
|
pub id: u32,
|
||||||
|
pub output_node: u32,
|
||||||
|
pub input_node: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Ord, PartialOrd, Eq, PartialEq)]
|
||||||
|
pub struct AudioDevice {
|
||||||
|
pub id: u32,
|
||||||
|
|
||||||
|
pub nick: String,
|
||||||
|
pub name: String,
|
||||||
|
|
||||||
|
pub device_type: DeviceType,
|
||||||
|
|
||||||
|
pub input_fl: Option<Port>,
|
||||||
|
pub input_fr: Option<Port>,
|
||||||
|
pub output_fl: Option<Port>,
|
||||||
|
pub output_fr: Option<Port>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioDevice {
|
||||||
|
pub fn new(
|
||||||
|
id: u32,
|
||||||
|
nick: Option<&str>,
|
||||||
|
description: Option<&str>,
|
||||||
|
name: Option<&str>,
|
||||||
|
device_type: DeviceType,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
nick: nick
|
||||||
|
.or(description)
|
||||||
|
.or(name)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string(),
|
||||||
|
name: name.unwrap_or_default().to_string(),
|
||||||
|
device_type,
|
||||||
|
input_fl: None,
|
||||||
|
input_fr: None,
|
||||||
|
output_fl: None,
|
||||||
|
output_fr: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_port(&mut self, port: Port) {
|
||||||
|
// `playback_*` are the input ports of an `Audio/Sink` node. `monitor_*` are its
|
||||||
|
// output ports and are deliberately left unmapped — we never record from a sink.
|
||||||
|
match port.name.as_str() {
|
||||||
|
"input_FL" | "playback_FL" => self.input_fl = Some(port),
|
||||||
|
"input_FR" | "playback_FR" => self.input_fr = Some(port),
|
||||||
|
"output_FL" | "capture_FL" => self.output_fl = Some(port),
|
||||||
|
"output_FR" | "capture_FR" => self.output_fr = Some(port),
|
||||||
|
"input_MONO" | "playback_MONO" => {
|
||||||
|
self.input_fl = Some(port.clone());
|
||||||
|
self.input_fr = Some(port);
|
||||||
|
}
|
||||||
|
"output_MONO" | "capture_MONO" => {
|
||||||
|
self.output_fl = Some(port.clone());
|
||||||
|
self.output_fr = Some(port);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_audio_device_new() {
|
||||||
|
let device = AudioDevice::new(
|
||||||
|
1,
|
||||||
|
Some("NickName"),
|
||||||
|
Some("Description"),
|
||||||
|
Some("Name"),
|
||||||
|
DeviceType::Input,
|
||||||
|
);
|
||||||
|
assert_eq!(device.id, 1);
|
||||||
|
assert_eq!(device.nick, "NickName");
|
||||||
|
assert_eq!(device.name, "Name");
|
||||||
|
assert_eq!(device.device_type, DeviceType::Input);
|
||||||
|
|
||||||
|
// Fallbacks for nick
|
||||||
|
let device_no_nick =
|
||||||
|
AudioDevice::new(2, None, Some("Desc"), Some("Name"), DeviceType::Output);
|
||||||
|
assert_eq!(device_no_nick.nick, "Desc");
|
||||||
|
|
||||||
|
let device_no_desc = AudioDevice::new(3, None, None, Some("Name"), DeviceType::Output);
|
||||||
|
assert_eq!(device_no_desc.nick, "Name");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_audio_device_sink_ports() {
|
||||||
|
let mut sink = AudioDevice::new(1, None, None, Some("speakers"), DeviceType::Sink);
|
||||||
|
|
||||||
|
let port = |id: u32, name: &str| Port {
|
||||||
|
node_id: 1,
|
||||||
|
port_id: id,
|
||||||
|
name: name.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// playback_* carries audio into the sink, so those are its inputs.
|
||||||
|
sink.add_port(port(10, "playback_FL"));
|
||||||
|
sink.add_port(port(11, "playback_FR"));
|
||||||
|
assert_eq!(sink.input_fl, Some(port(10, "playback_FL")));
|
||||||
|
assert_eq!(sink.input_fr, Some(port(11, "playback_FR")));
|
||||||
|
|
||||||
|
// monitor_* is what the sink plays out; linking a stream there would be a loop.
|
||||||
|
sink.add_port(port(12, "monitor_FL"));
|
||||||
|
sink.add_port(port(13, "monitor_FR"));
|
||||||
|
assert_eq!(sink.output_fl, None);
|
||||||
|
assert_eq!(sink.output_fr, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_audio_device_add_port() {
|
||||||
|
let mut device = AudioDevice::new(1, None, None, Some("device-name"), DeviceType::Input);
|
||||||
|
|
||||||
|
let port_fl = Port {
|
||||||
|
node_id: 1,
|
||||||
|
port_id: 10,
|
||||||
|
name: "input_FL".to_string(),
|
||||||
|
};
|
||||||
|
let port_fr = Port {
|
||||||
|
node_id: 1,
|
||||||
|
port_id: 11,
|
||||||
|
name: "input_FR".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
device.add_port(port_fl.clone());
|
||||||
|
device.add_port(port_fr.clone());
|
||||||
|
|
||||||
|
assert_eq!(device.input_fl, Some(port_fl));
|
||||||
|
assert_eq!(device.input_fr, Some(port_fr));
|
||||||
|
|
||||||
|
// Test output ports
|
||||||
|
let port_out_fl = Port {
|
||||||
|
node_id: 1,
|
||||||
|
port_id: 12,
|
||||||
|
name: "output_FL".to_string(),
|
||||||
|
};
|
||||||
|
let port_out_fr = Port {
|
||||||
|
node_id: 1,
|
||||||
|
port_id: 13,
|
||||||
|
name: "capture_FR".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
device.add_port(port_out_fl.clone());
|
||||||
|
device.add_port(port_out_fr.clone());
|
||||||
|
|
||||||
|
assert_eq!(device.output_fl, Some(port_out_fl));
|
||||||
|
assert_eq!(device.output_fr, Some(port_out_fr));
|
||||||
|
|
||||||
|
// Test MONO ports
|
||||||
|
let mut device_mono =
|
||||||
|
AudioDevice::new(2, None, None, Some("mono-device"), DeviceType::Input);
|
||||||
|
let port_mono = Port {
|
||||||
|
node_id: 2,
|
||||||
|
port_id: 20,
|
||||||
|
name: "input_MONO".to_string(),
|
||||||
|
};
|
||||||
|
device_mono.add_port(port_mono.clone());
|
||||||
|
|
||||||
|
assert_eq!(device_mono.input_fl, Some(port_mono.clone()));
|
||||||
|
assert_eq!(device_mono.input_fr, Some(port_mono));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::types::config::DaemonConfig;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
@@ -94,6 +95,10 @@ impl Request {
|
|||||||
Request::new("get_volume", args)
|
Request::new("get_volume", args)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_volume_multiplier() -> Self {
|
||||||
|
Request::new("get_volume_multiplier", vec![])
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_position(id: Option<u32>) -> Self {
|
pub fn get_position(id: Option<u32>) -> Self {
|
||||||
let mut args = vec![];
|
let mut args = vec![];
|
||||||
let id_str;
|
let id_str;
|
||||||
@@ -138,6 +143,32 @@ impl Request {
|
|||||||
Request::new("set_volume".to_string(), args)
|
Request::new("set_volume".to_string(), args)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_monitoring_volume() -> Self {
|
||||||
|
Request::new("get_monitoring_volume", vec![])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_monitoring_volume(volume: f32) -> Self {
|
||||||
|
Request::new(
|
||||||
|
"set_monitoring_volume",
|
||||||
|
vec![("volume", &volume.to_string())],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_mic_volume() -> Self {
|
||||||
|
Request::new("get_mic_volume", vec![])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_mic_volume(volume: f32) -> Self {
|
||||||
|
Request::new("set_mic_volume", vec![("volume", &volume.to_string())])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_volume_multiplier(volume: f32) -> Self {
|
||||||
|
Request::new(
|
||||||
|
"set_volume_multiplier",
|
||||||
|
vec![("volume_multiplier", &volume.to_string())],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn seek(position: f32, id: Option<u32>) -> Self {
|
pub fn seek(position: f32, id: Option<u32>) -> Self {
|
||||||
let mut args = vec![("position".to_string(), position.to_string())];
|
let mut args = vec![("position".to_string(), position.to_string())];
|
||||||
if let Some(id) = id {
|
if let Some(id) = id {
|
||||||
@@ -150,6 +181,18 @@ impl Request {
|
|||||||
Request::new("set_input", vec![("input_name", name)])
|
Request::new("set_input", vec![("input_name", name)])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_output() -> Self {
|
||||||
|
Request::new("get_output", vec![])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_outputs() -> Self {
|
||||||
|
Request::new("get_outputs", vec![])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_output(name: &str) -> Self {
|
||||||
|
Request::new("set_output", vec![("output_name", name)])
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_loop(enabled: &str, id: Option<u32>) -> Self {
|
pub fn set_loop(enabled: &str, id: Option<u32>) -> Self {
|
||||||
let mut args = vec![("enabled".to_string(), enabled.to_string())];
|
let mut args = vec![("enabled".to_string(), enabled.to_string())];
|
||||||
if let Some(id) = id {
|
if let Some(id) = id {
|
||||||
@@ -172,6 +215,24 @@ impl Request {
|
|||||||
Request::new("get_daemon_version", vec![])
|
Request::new("get_daemon_version", vec![])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_daemon_config() -> Self {
|
||||||
|
Request::new("get_daemon_config", vec![])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_daemon_config() -> Self {
|
||||||
|
Request::new("save_daemon_config", vec![])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_daemon_config(new_config: &DaemonConfig) -> Self {
|
||||||
|
Request::new(
|
||||||
|
"update_daemon_config",
|
||||||
|
vec![(
|
||||||
|
"new_config",
|
||||||
|
&serde_json::to_string(&new_config).unwrap_or_default(),
|
||||||
|
)],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_full_state() -> Self {
|
pub fn get_full_state() -> Self {
|
||||||
Request::new("get_full_state", vec![])
|
Request::new("get_full_state", vec![])
|
||||||
}
|
}
|
||||||
@@ -238,3 +299,123 @@ impl Response {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_response_new() {
|
||||||
|
let res = Response::new(true, "success-msg");
|
||||||
|
assert!(res.status);
|
||||||
|
assert_eq!(res.message, "success-msg");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_volume_path_request_constructors() {
|
||||||
|
let monitoring = Request::set_monitoring_volume(1.5);
|
||||||
|
assert_eq!(monitoring.name, "set_monitoring_volume");
|
||||||
|
assert_eq!(
|
||||||
|
monitoring.args.get("volume").map(|s| s.as_str()),
|
||||||
|
Some("1.5")
|
||||||
|
);
|
||||||
|
|
||||||
|
let mic = Request::set_mic_volume(2.0);
|
||||||
|
assert_eq!(mic.name, "set_mic_volume");
|
||||||
|
assert_eq!(mic.args.get("volume").map(|s| s.as_str()), Some("2"));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
Request::get_monitoring_volume().name,
|
||||||
|
"get_monitoring_volume"
|
||||||
|
);
|
||||||
|
assert!(Request::get_monitoring_volume().args.is_empty());
|
||||||
|
assert_eq!(Request::get_mic_volume().name, "get_mic_volume");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_output_request_constructors() {
|
||||||
|
let set_output = Request::set_output("some-sink");
|
||||||
|
assert_eq!(set_output.name, "set_output");
|
||||||
|
assert_eq!(
|
||||||
|
set_output.args.get("output_name").map(|s| s.as_str()),
|
||||||
|
Some("some-sink")
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(Request::get_output().name, "get_output");
|
||||||
|
assert_eq!(Request::get_outputs().name, "get_outputs");
|
||||||
|
assert!(Request::get_outputs().args.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_request_constructors() {
|
||||||
|
// test ping
|
||||||
|
let req_ping = Request::ping();
|
||||||
|
assert_eq!(req_ping.name, "ping");
|
||||||
|
assert!(req_ping.args.is_empty());
|
||||||
|
|
||||||
|
// test kill
|
||||||
|
let req_kill = Request::kill();
|
||||||
|
assert_eq!(req_kill.name, "kill");
|
||||||
|
|
||||||
|
// test pause (with and without id)
|
||||||
|
let req_pause_no_id = Request::pause(None);
|
||||||
|
assert_eq!(req_pause_no_id.name, "pause");
|
||||||
|
assert!(req_pause_no_id.args.is_empty());
|
||||||
|
|
||||||
|
let req_pause_with_id = Request::pause(Some(42));
|
||||||
|
assert_eq!(req_pause_with_id.name, "pause");
|
||||||
|
assert_eq!(
|
||||||
|
req_pause_with_id.args.get("id").map(|s| s.as_str()),
|
||||||
|
Some("42")
|
||||||
|
);
|
||||||
|
|
||||||
|
// test play
|
||||||
|
let req_play = Request::play("/path/to/sound.mp3", true);
|
||||||
|
assert_eq!(req_play.name, "play");
|
||||||
|
assert_eq!(
|
||||||
|
req_play.args.get("file_path").map(|s| s.as_str()),
|
||||||
|
Some("/path/to/sound.mp3")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
req_play.args.get("concurrent").map(|s| s.as_str()),
|
||||||
|
Some("true")
|
||||||
|
);
|
||||||
|
|
||||||
|
// test set_volume
|
||||||
|
let req_volume = Request::set_volume(0.8, Some(10));
|
||||||
|
assert_eq!(req_volume.name, "set_volume");
|
||||||
|
assert_eq!(
|
||||||
|
req_volume.args.get("volume").map(|s| s.as_str()),
|
||||||
|
Some("0.8")
|
||||||
|
);
|
||||||
|
assert_eq!(req_volume.args.get("id").map(|s| s.as_str()), Some("10"));
|
||||||
|
|
||||||
|
// test set_hotkey_action_and_key
|
||||||
|
let action = Request::ping();
|
||||||
|
let req_hotkey_action_and_key =
|
||||||
|
Request::set_hotkey_action_and_key("slot1", &action, "Ctrl+P");
|
||||||
|
assert_eq!(req_hotkey_action_and_key.name, "set_hotkey_action_and_key");
|
||||||
|
assert_eq!(
|
||||||
|
req_hotkey_action_and_key
|
||||||
|
.args
|
||||||
|
.get("slot")
|
||||||
|
.map(|s| s.as_str()),
|
||||||
|
Some("slot1")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
req_hotkey_action_and_key
|
||||||
|
.args
|
||||||
|
.get("key_chord")
|
||||||
|
.map(|s| s.as_str()),
|
||||||
|
Some("Ctrl+P")
|
||||||
|
);
|
||||||
|
let action_json = serde_json::to_string(&action).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
req_hotkey_action_and_key
|
||||||
|
.args
|
||||||
|
.get("action")
|
||||||
|
.map(|s| s.as_str()),
|
||||||
|
Some(action_json.as_str())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,18 @@
|
|||||||
use crate::types::{commands::*, socket::Request};
|
use crate::types::{
|
||||||
|
audio_player::VolumeTarget, commands::*, config::DaemonConfig, socket::Request,
|
||||||
|
};
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Which of the two output paths a `*_monitoring_volume` / `*_mic_volume` command means.
|
||||||
|
fn volume_target(command_name: &str) -> Option<VolumeTarget> {
|
||||||
|
match command_name {
|
||||||
|
"get_monitoring_volume" | "set_monitoring_volume" => Some(VolumeTarget::Monitoring),
|
||||||
|
"get_mic_volume" | "set_mic_volume" => Some(VolumeTarget::Mic),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn parse_command(request: &Request) -> Option<Box<dyn Executable + Send>> {
|
pub fn parse_command(request: &Request) -> Option<Box<dyn Executable + Send>> {
|
||||||
let id = request.args.get("id").and_then(|s| s.parse::<u32>().ok());
|
let id = request.args.get("id").and_then(|s| s.parse::<u32>().ok());
|
||||||
|
|
||||||
@@ -15,6 +26,7 @@ pub fn parse_command(request: &Request) -> Option<Box<dyn Executable + Send>> {
|
|||||||
"is_paused" => Some(Box::new(IsPausedCommand {})),
|
"is_paused" => Some(Box::new(IsPausedCommand {})),
|
||||||
"get_state" => Some(Box::new(GetStateCommand {})),
|
"get_state" => Some(Box::new(GetStateCommand {})),
|
||||||
"get_volume" => Some(Box::new(GetVolumeCommand { id })),
|
"get_volume" => Some(Box::new(GetVolumeCommand { id })),
|
||||||
|
"get_volume_multiplier" => Some(Box::new(GetVolumeMultiplierCommand {})),
|
||||||
"set_volume" => {
|
"set_volume" => {
|
||||||
let volume = request
|
let volume = request
|
||||||
.args
|
.args
|
||||||
@@ -24,6 +36,30 @@ pub fn parse_command(request: &Request) -> Option<Box<dyn Executable + Send>> {
|
|||||||
.ok();
|
.ok();
|
||||||
Some(Box::new(SetVolumeCommand { volume, id }))
|
Some(Box::new(SetVolumeCommand { volume, id }))
|
||||||
}
|
}
|
||||||
|
"get_monitoring_volume" | "get_mic_volume" => Some(Box::new(GetMasterVolumeCommand {
|
||||||
|
target: volume_target(&request.name)?,
|
||||||
|
})),
|
||||||
|
"set_monitoring_volume" | "set_mic_volume" => {
|
||||||
|
let volume = request
|
||||||
|
.args
|
||||||
|
.get("volume")
|
||||||
|
.unwrap_or(&String::new())
|
||||||
|
.parse::<f32>()
|
||||||
|
.ok();
|
||||||
|
Some(Box::new(SetMasterVolumeCommand {
|
||||||
|
volume,
|
||||||
|
target: volume_target(&request.name)?,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
"set_volume_multiplier" => {
|
||||||
|
let volume_multiplier = request
|
||||||
|
.args
|
||||||
|
.get("volume_multiplier")
|
||||||
|
.unwrap_or(&String::new())
|
||||||
|
.parse::<f32>()
|
||||||
|
.ok();
|
||||||
|
Some(Box::new(SetVolumeMultiplierCommand { volume_multiplier }))
|
||||||
|
}
|
||||||
"get_position" => Some(Box::new(GetPositionCommand { id })),
|
"get_position" => Some(Box::new(GetPositionCommand { id })),
|
||||||
"seek" => {
|
"seek" => {
|
||||||
let position = request
|
let position = request
|
||||||
@@ -60,6 +96,12 @@ pub fn parse_command(request: &Request) -> Option<Box<dyn Executable + Send>> {
|
|||||||
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 }))
|
||||||
}
|
}
|
||||||
|
"get_output" => Some(Box::new(GetCurrentOutputCommand {})),
|
||||||
|
"get_outputs" => Some(Box::new(GetAllOutputsCommand {})),
|
||||||
|
"set_output" => {
|
||||||
|
let name = Some(request.args.get("output_name").unwrap_or(&String::new())).cloned();
|
||||||
|
Some(Box::new(SetCurrentOutputCommand { name }))
|
||||||
|
}
|
||||||
"set_loop" => {
|
"set_loop" => {
|
||||||
let enabled = request
|
let enabled = request
|
||||||
.args
|
.args
|
||||||
@@ -119,6 +161,17 @@ pub fn parse_command(request: &Request) -> Option<Box<dyn Executable + Send>> {
|
|||||||
key_chord,
|
key_chord,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
"get_daemon_config" => Some(Box::new(GetDaemonConfigCommand {})),
|
||||||
|
"save_daemon_config" => Some(Box::new(SaveDaemonConfigCommand {})),
|
||||||
|
"update_daemon_config" => {
|
||||||
|
let new_config = request
|
||||||
|
.args
|
||||||
|
.get("new_config")
|
||||||
|
.and_then(|nc| serde_json::from_str::<DaemonConfig>(nc).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
Some(Box::new(UpdateDaemonConfigCommand { new_config }))
|
||||||
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,4 +259,57 @@ mod tests {
|
|||||||
let cmd = parse_command(&request);
|
let cmd = parse_command(&request);
|
||||||
assert!(cmd.is_some());
|
assert!(cmd.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_volume_target_mapping() {
|
||||||
|
assert_eq!(
|
||||||
|
volume_target("set_monitoring_volume"),
|
||||||
|
Some(VolumeTarget::Monitoring)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
volume_target("get_monitoring_volume"),
|
||||||
|
Some(VolumeTarget::Monitoring)
|
||||||
|
);
|
||||||
|
assert_eq!(volume_target("set_mic_volume"), Some(VolumeTarget::Mic));
|
||||||
|
assert_eq!(volume_target("get_mic_volume"), Some(VolumeTarget::Mic));
|
||||||
|
|
||||||
|
// The two paths share a dispatch arm, so a name that is neither must not
|
||||||
|
// silently fall through to one of them.
|
||||||
|
assert_eq!(volume_target("set_volume"), None);
|
||||||
|
assert_eq!(volume_target("mic"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_new_commands() {
|
||||||
|
let with_volume = |name: &str| {
|
||||||
|
let mut args = HashMap::new();
|
||||||
|
args.insert("volume".to_string(), "2.0".to_string());
|
||||||
|
Request {
|
||||||
|
name: name.to_string(),
|
||||||
|
args,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(parse_command(&with_volume("set_monitoring_volume")).is_some());
|
||||||
|
assert!(parse_command(&with_volume("set_mic_volume")).is_some());
|
||||||
|
|
||||||
|
for name in [
|
||||||
|
"get_monitoring_volume",
|
||||||
|
"get_mic_volume",
|
||||||
|
"get_output",
|
||||||
|
"get_outputs",
|
||||||
|
] {
|
||||||
|
let request = Request::new(name, vec![]);
|
||||||
|
assert!(
|
||||||
|
parse_command(&request).is_some(),
|
||||||
|
"{} did not dispatch",
|
||||||
|
name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let set_output = Request::set_output("some-sink");
|
||||||
|
assert!(parse_command(&set_output).is_some());
|
||||||
|
|
||||||
|
assert!(parse_command(&Request::new("set_speaker_volume", vec![])).is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
pub fn get_config_path() -> Result<PathBuf> {
|
||||||
|
let config_path = dirs::config_dir().context("Failed to obtain config dir")?;
|
||||||
|
Ok(config_path.join("pwsp"))
|
||||||
|
}
|
||||||
@@ -4,61 +4,108 @@ use crate::types::{
|
|||||||
socket::{MAX_MESSAGE_SIZE, Request, Response},
|
socket::{MAX_MESSAGE_SIZE, Request, Response},
|
||||||
};
|
};
|
||||||
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
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,
|
||||||
|
sync::{Arc, Mutex, OnceLock},
|
||||||
|
};
|
||||||
use tokio::{
|
use tokio::{
|
||||||
io::{AsyncReadExt, AsyncWriteExt},
|
io::{AsyncReadExt, AsyncWriteExt},
|
||||||
net::UnixStream,
|
net::UnixStream,
|
||||||
sync::{Mutex, OnceCell},
|
sync::{Mutex as AsyncMutex, OnceCell},
|
||||||
time::{Duration, sleep},
|
time::{Duration, sleep},
|
||||||
};
|
};
|
||||||
|
|
||||||
static AUDIO_PLAYER: OnceCell<Mutex<AudioPlayer>> = OnceCell::const_new();
|
static AUDIO_PLAYER: OnceCell<AsyncMutex<AudioPlayer>> = OnceCell::const_new();
|
||||||
|
static DAEMON_CONFIG: OnceLock<Arc<Mutex<DaemonConfig>>> = OnceLock::new();
|
||||||
|
|
||||||
pub async fn get_audio_player() -> Result<&'static Mutex<AudioPlayer>, String> {
|
pub async fn get_audio_player() -> Result<&'static AsyncMutex<AudioPlayer>, String> {
|
||||||
AUDIO_PLAYER
|
AUDIO_PLAYER
|
||||||
.get_or_try_init(|| async {
|
.get_or_try_init(|| async {
|
||||||
println!("Initializing audio player");
|
println!("Initializing audio player");
|
||||||
match AudioPlayer::new().await {
|
match AudioPlayer::new().await {
|
||||||
Ok(player) => Ok(Mutex::new(player)),
|
Ok(player) => Ok(AsyncMutex::new(player)),
|
||||||
Err(err) => Err(err.to_string()),
|
Err(err) => Err(err.to_string()),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_daemon_config() -> DaemonConfig {
|
pub fn get_daemon_config() -> &'static Arc<Mutex<DaemonConfig>> {
|
||||||
DaemonConfig::load_from_file().unwrap_or_else(|_| {
|
DAEMON_CONFIG.get_or_init(|| {
|
||||||
|
Arc::new(Mutex::new(DaemonConfig::load_from_file().unwrap_or_else(
|
||||||
|
|_| {
|
||||||
let config = DaemonConfig::default();
|
let config = DaemonConfig::default();
|
||||||
config.save_to_file().ok();
|
config.save_to_file().ok();
|
||||||
config
|
config
|
||||||
|
},
|
||||||
|
)))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_runtime_dir() -> PathBuf {
|
pub fn with_daemon_config<R>(f: impl FnOnce(&mut DaemonConfig) -> R) -> R {
|
||||||
dirs::runtime_dir().unwrap_or(PathBuf::from("/run/pwsp"))
|
let config = get_daemon_config().clone();
|
||||||
|
let mut guard = config.lock().unwrap();
|
||||||
|
f(&mut guard)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_runtime_dir() -> Result<(), Box<dyn Error>> {
|
fn get_current_uid() -> u32 {
|
||||||
|
rustix::process::geteuid().as_raw()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_runtime_dir() -> PathBuf {
|
||||||
|
dirs::runtime_dir().unwrap_or_else(|| {
|
||||||
|
let uid = get_current_uid();
|
||||||
|
env::temp_dir().join(format!("pwsp-{}", uid))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_runtime_dir() -> Result<()> {
|
||||||
let runtime_dir = get_runtime_dir();
|
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)?;
|
||||||
}
|
}
|
||||||
fs::set_permissions(&runtime_dir, fs::Permissions::from_mode(0o700))?;
|
|
||||||
|
|
||||||
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(());
|
||||||
}
|
}
|
||||||
@@ -199,3 +199,79 @@ pub async fn start_global_hotkey_listener() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_modifier_state() {
|
||||||
|
let mut state = ModifierState::new();
|
||||||
|
assert!(!state.any_active());
|
||||||
|
|
||||||
|
// Press Ctrl
|
||||||
|
state.update(KeyCode::KEY_LEFTCTRL, true);
|
||||||
|
assert!(state.ctrl);
|
||||||
|
assert!(state.any_active());
|
||||||
|
|
||||||
|
// Release Ctrl
|
||||||
|
state.update(KeyCode::KEY_LEFTCTRL, false);
|
||||||
|
assert!(!state.ctrl);
|
||||||
|
assert!(!state.any_active());
|
||||||
|
|
||||||
|
// Press multiple modifiers
|
||||||
|
state.update(KeyCode::KEY_RIGHTALT, true);
|
||||||
|
state.update(KeyCode::KEY_LEFTSHIFT, true);
|
||||||
|
assert!(state.alt);
|
||||||
|
assert!(state.shift);
|
||||||
|
assert!(state.any_active());
|
||||||
|
|
||||||
|
// Update a non-modifier key
|
||||||
|
state.update(KeyCode::KEY_A, true);
|
||||||
|
// Modifier states should remain unchanged
|
||||||
|
assert!(state.alt);
|
||||||
|
assert!(state.shift);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_modifier() {
|
||||||
|
assert!(ModifierState::is_modifier(KeyCode::KEY_LEFTCTRL));
|
||||||
|
assert!(ModifierState::is_modifier(KeyCode::KEY_RIGHTCTRL));
|
||||||
|
assert!(ModifierState::is_modifier(KeyCode::KEY_LEFTALT));
|
||||||
|
assert!(ModifierState::is_modifier(KeyCode::KEY_RIGHTALT));
|
||||||
|
assert!(ModifierState::is_modifier(KeyCode::KEY_LEFTSHIFT));
|
||||||
|
assert!(ModifierState::is_modifier(KeyCode::KEY_RIGHTSHIFT));
|
||||||
|
assert!(ModifierState::is_modifier(KeyCode::KEY_LEFTMETA));
|
||||||
|
assert!(ModifierState::is_modifier(KeyCode::KEY_RIGHTMETA));
|
||||||
|
|
||||||
|
assert!(!ModifierState::is_modifier(KeyCode::KEY_A));
|
||||||
|
assert!(!ModifierState::is_modifier(KeyCode::KEY_1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_evdev_key_name() {
|
||||||
|
assert_eq!(evdev_key_name(KeyCode::KEY_A), Some("A"));
|
||||||
|
assert_eq!(evdev_key_name(KeyCode::KEY_Z), Some("Z"));
|
||||||
|
assert_eq!(evdev_key_name(KeyCode::KEY_0), Some("0"));
|
||||||
|
assert_eq!(evdev_key_name(KeyCode::KEY_F1), Some("F1"));
|
||||||
|
assert_eq!(evdev_key_name(KeyCode::KEY_F12), Some("F12"));
|
||||||
|
assert_eq!(evdev_key_name(KeyCode::KEY_ENTER), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_chord() {
|
||||||
|
let mut modifiers = ModifierState::new();
|
||||||
|
|
||||||
|
assert_eq!(build_chord(&modifiers, "A"), "A");
|
||||||
|
|
||||||
|
modifiers.ctrl = true;
|
||||||
|
assert_eq!(build_chord(&modifiers, "A"), "Ctrl+A");
|
||||||
|
|
||||||
|
modifiers.shift = true;
|
||||||
|
assert_eq!(build_chord(&modifiers, "B"), "Ctrl+Shift+B");
|
||||||
|
|
||||||
|
modifiers.alt = true;
|
||||||
|
modifiers.meta = true;
|
||||||
|
assert_eq!(build_chord(&modifiers, "F5"), "Ctrl+Alt+Shift+Super+F5");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
types::{
|
types::{
|
||||||
audio_player::FullState,
|
audio_player::FullState,
|
||||||
config::{GuiConfig, HotkeyConfig},
|
config::{DaemonConfig, GuiConfig, HotkeyConfig},
|
||||||
gui::AudioPlayerState,
|
gui::AudioPlayerState,
|
||||||
socket::{Request, Response},
|
socket::{Request, Response},
|
||||||
},
|
},
|
||||||
utils::daemon::{is_daemon_running, make_request},
|
utils::daemon::{is_daemon_running, make_request},
|
||||||
};
|
};
|
||||||
|
use anyhow::{Result, anyhow};
|
||||||
use std::{
|
use std::{
|
||||||
error::Error,
|
collections::HashMap,
|
||||||
|
fs,
|
||||||
|
path::PathBuf,
|
||||||
sync::{Arc, Mutex},
|
sync::{Arc, Mutex},
|
||||||
time::Instant,
|
time::Instant,
|
||||||
};
|
};
|
||||||
@@ -22,11 +25,24 @@ pub fn get_gui_config() -> GuiConfig {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn make_request_sync(request: Request) -> Result<Response, Box<dyn Error>> {
|
pub fn update_daemon_config(new_config: &DaemonConfig) -> Result<()> {
|
||||||
|
make_request_sync(Request::update_daemon_config(new_config))?;
|
||||||
|
make_request_async(Request::save_daemon_config());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_daemon_config() -> Result<DaemonConfig> {
|
||||||
|
let response = make_request_sync(Request::get_daemon_config())?;
|
||||||
|
let config: DaemonConfig = serde_json::from_str(&response.message)?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn make_request_sync(request: Request) -> Result<Response> {
|
||||||
tokio::task::block_in_place(|| {
|
tokio::task::block_in_place(|| {
|
||||||
tokio::runtime::Handle::current()
|
tokio::runtime::Handle::current()
|
||||||
.block_on(make_request(request))
|
.block_on(make_request(request))
|
||||||
.map_err(|e| e as Box<dyn Error>)
|
.map_err(|e| anyhow!(e))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +52,21 @@ pub fn make_request_async(request: Request) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn ensure_pwsp_audio_dir() -> Result<PathBuf> {
|
||||||
|
let audio_dir = dirs::audio_dir().unwrap_or(
|
||||||
|
dirs::home_dir()
|
||||||
|
.map(|p| p.join("Music"))
|
||||||
|
.ok_or_else(|| anyhow!("Failed to get home directory. Is your system ok?"))?,
|
||||||
|
);
|
||||||
|
let pwsp_audio_dir = audio_dir.join("PWSP");
|
||||||
|
|
||||||
|
if !pwsp_audio_dir.exists() {
|
||||||
|
fs::create_dir_all(&pwsp_audio_dir)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(pwsp_audio_dir)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn format_time_pair(position: f32, duration: f32) -> String {
|
pub fn format_time_pair(position: f32, duration: f32) -> String {
|
||||||
fn format_time(seconds: f32) -> String {
|
fn format_time(seconds: f32) -> String {
|
||||||
let total_seconds = seconds.round() as u32;
|
let total_seconds = seconds.round() as u32;
|
||||||
@@ -47,6 +78,16 @@ pub fn format_time_pair(position: f32, duration: f32) -> String {
|
|||||||
format!("{}/{}", format_time(position), format_time(duration))
|
format!("{}/{}", format_time(position), format_time(duration))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Turns a name→nickname map into the stable order the combo boxes render in.
|
||||||
|
fn sorted_devices(devices: &HashMap<String, String>) -> Vec<(String, String)> {
|
||||||
|
let mut sorted: Vec<(String, String)> = devices
|
||||||
|
.iter()
|
||||||
|
.map(|(name, nick)| (name.clone(), nick.clone()))
|
||||||
|
.collect();
|
||||||
|
sorted.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
|
sorted
|
||||||
|
}
|
||||||
|
|
||||||
pub fn start_app_state_thread(audio_player_state_shared: Arc<Mutex<AudioPlayerState>>) {
|
pub fn start_app_state_thread(audio_player_state_shared: Arc<Mutex<AudioPlayerState>>) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let sleep_duration = Duration::from_secs_f32(1.0 / 60.0);
|
let sleep_duration = Duration::from_secs_f32(1.0 / 60.0);
|
||||||
@@ -85,23 +126,25 @@ pub fn start_app_state_thread(audio_player_state_shared: Arc<Mutex<AudioPlayerSt
|
|||||||
None => full_state.state,
|
None => full_state.state,
|
||||||
};
|
};
|
||||||
guard.tracks = full_state.tracks;
|
guard.tracks = full_state.tracks;
|
||||||
guard.volume = full_state.volume;
|
guard.monitoring_volume = full_state.monitoring_volume;
|
||||||
|
guard.mic_volume = full_state.mic_volume;
|
||||||
|
guard.volume_multiplier = full_state.volume_multiplier;
|
||||||
guard.current_input = full_state
|
guard.current_input = full_state
|
||||||
.current_input
|
.current_input
|
||||||
.split(" - ")
|
.split(" - ")
|
||||||
.next()
|
.next()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_string();
|
.to_string();
|
||||||
|
guard.current_output = full_state.current_output;
|
||||||
|
|
||||||
if guard.all_inputs != full_state.all_inputs {
|
if guard.all_inputs != full_state.all_inputs {
|
||||||
guard.all_inputs = full_state.all_inputs;
|
guard.all_inputs = full_state.all_inputs;
|
||||||
let mut sorted: Vec<(String, String)> = guard
|
guard.all_inputs_sorted = sorted_devices(&guard.all_inputs);
|
||||||
.all_inputs
|
}
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| (k.clone(), v.clone()))
|
if guard.all_outputs != full_state.all_outputs {
|
||||||
.collect();
|
guard.all_outputs = full_state.all_outputs;
|
||||||
sorted.sort_by(|a, b| a.0.cmp(&b.0));
|
guard.all_outputs_sorted = sorted_devices(&guard.all_outputs);
|
||||||
guard.all_inputs_sorted = sorted;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
guard.is_daemon_running = true;
|
guard.is_daemon_running = true;
|
||||||
@@ -127,3 +170,16 @@ pub fn start_app_state_thread(audio_player_state_shared: Arc<Mutex<AudioPlayerSt
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_format_time_pair() {
|
||||||
|
assert_eq!(format_time_pair(0.0, 0.0), "00:00/00:00");
|
||||||
|
assert_eq!(format_time_pair(5.4, 10.0), "00:05/00:10");
|
||||||
|
assert_eq!(format_time_pair(59.9, 125.1), "01:00/02:05");
|
||||||
|
assert_eq!(format_time_pair(3600.0, 7205.0), "60:00/120:05");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,540 @@
|
|||||||
|
use crate::types::pipewire::{AudioDevice, DeviceType, LinkInfo, Port};
|
||||||
|
use anyhow::{Result, anyhow};
|
||||||
|
use pipewire::{
|
||||||
|
context::ContextRc, link::Link, main_loop::MainLoopRc, properties::properties,
|
||||||
|
registry::GlobalObject, spa::utils::dict::DictRef,
|
||||||
|
};
|
||||||
|
use std::{cell::RefCell, collections::HashMap, rc::Rc, sync::OnceLock, thread};
|
||||||
|
use tokio::sync::oneshot;
|
||||||
|
|
||||||
|
/// Every audio node PWSP knows about, split by role.
|
||||||
|
pub struct AllDevices {
|
||||||
|
/// `Audio/Source*` nodes — real and virtual microphones.
|
||||||
|
pub inputs: Vec<AudioDevice>,
|
||||||
|
/// `Stream/Output/Audio` nodes — application playback streams, including our own.
|
||||||
|
pub outputs: Vec<AudioDevice>,
|
||||||
|
/// `Audio/Sink` nodes — speakers and headphones.
|
||||||
|
pub sinks: Vec<AudioDevice>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AllDevices {
|
||||||
|
pub fn iter(&self) -> impl Iterator<Item = &AudioDevice> {
|
||||||
|
self.inputs
|
||||||
|
.iter()
|
||||||
|
.chain(self.outputs.iter())
|
||||||
|
.chain(self.sinks.iter())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum PwCommand {
|
||||||
|
GetDevices {
|
||||||
|
resp: oneshot::Sender<AllDevices>,
|
||||||
|
},
|
||||||
|
GetLinks {
|
||||||
|
resp: oneshot::Sender<Vec<LinkInfo>>,
|
||||||
|
},
|
||||||
|
CreateVirtualMic {
|
||||||
|
resp: oneshot::Sender<Result<u32, String>>,
|
||||||
|
},
|
||||||
|
CreateLink {
|
||||||
|
output_fl: Port,
|
||||||
|
output_fr: Port,
|
||||||
|
input_fl: Port,
|
||||||
|
input_fr: Port,
|
||||||
|
resp: oneshot::Sender<Result<(u32, u32), String>>,
|
||||||
|
},
|
||||||
|
/// Drops a proxy we created ourselves, which destroys the underlying object.
|
||||||
|
DestroyObject {
|
||||||
|
id: u32,
|
||||||
|
},
|
||||||
|
/// Destroys a global object owned by somebody else, such as an auto-created link.
|
||||||
|
DestroyGlobal {
|
||||||
|
id: u32,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AppState {
|
||||||
|
input_devices: HashMap<u32, AudioDevice>,
|
||||||
|
output_devices: HashMap<u32, AudioDevice>,
|
||||||
|
sink_devices: HashMap<u32, AudioDevice>,
|
||||||
|
links: HashMap<u32, LinkInfo>,
|
||||||
|
ports: HashMap<u32, Port>,
|
||||||
|
proxies: HashMap<u32, Box<dyn std::any::Any>>,
|
||||||
|
proxy_id_counter: u32,
|
||||||
|
ready_tx: Option<std::sync::mpsc::Sender<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PipewireManager {
|
||||||
|
pub sender: pipewire::channel::Sender<PwCommand>,
|
||||||
|
}
|
||||||
|
|
||||||
|
static MANAGER: OnceLock<PipewireManager> = OnceLock::new();
|
||||||
|
|
||||||
|
pub fn get_manager() -> &'static PipewireManager {
|
||||||
|
MANAGER.get_or_init(|| {
|
||||||
|
let (pw_sender, pw_receiver) = pipewire::channel::channel::<PwCommand>();
|
||||||
|
let (ready_tx, ready_rx) = std::sync::mpsc::channel();
|
||||||
|
|
||||||
|
thread::spawn(move || {
|
||||||
|
let (main_loop, context) = setup_pipewire_context().expect("Failed to setup pipewire");
|
||||||
|
|
||||||
|
// Leak main_loop and context so their borrows can be 'static
|
||||||
|
let main_loop = Box::leak(Box::new(main_loop));
|
||||||
|
let context = Box::leak(Box::new(context));
|
||||||
|
|
||||||
|
// Leak to fix lifetime issues since this thread lives forever. Shared rather
|
||||||
|
// than mutable so both `core` and the `registry` borrowed from it can be
|
||||||
|
// captured by the command closure below.
|
||||||
|
let core: &'static _ = Box::leak(Box::new(
|
||||||
|
context
|
||||||
|
.connect(None)
|
||||||
|
.expect("Failed to connect to pipewire"),
|
||||||
|
));
|
||||||
|
let registry: &'static _ = Box::leak(Box::new(
|
||||||
|
core.get_registry().expect("Failed to get registry"),
|
||||||
|
));
|
||||||
|
|
||||||
|
let state = Rc::new(RefCell::new(AppState {
|
||||||
|
input_devices: HashMap::new(),
|
||||||
|
output_devices: HashMap::new(),
|
||||||
|
sink_devices: HashMap::new(),
|
||||||
|
links: HashMap::new(),
|
||||||
|
ports: HashMap::new(),
|
||||||
|
proxies: HashMap::new(),
|
||||||
|
proxy_id_counter: 10000,
|
||||||
|
ready_tx: Some(ready_tx),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let state_for_registry_add = state.clone();
|
||||||
|
let state_for_registry_remove = state.clone();
|
||||||
|
|
||||||
|
let _listener = registry
|
||||||
|
.add_listener_local()
|
||||||
|
.global(move |global| {
|
||||||
|
let mut s = state_for_registry_add.borrow_mut();
|
||||||
|
match parse_global_object(global) {
|
||||||
|
ParsedGlobal::Device(device) => match device.device_type {
|
||||||
|
DeviceType::Input => {
|
||||||
|
s.input_devices.insert(device.id, device);
|
||||||
|
}
|
||||||
|
DeviceType::Output => {
|
||||||
|
s.output_devices.insert(device.id, device);
|
||||||
|
}
|
||||||
|
DeviceType::Sink => {
|
||||||
|
s.sink_devices.insert(device.id, device);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ParsedGlobal::Port(port) => {
|
||||||
|
let node_id = port.node_id;
|
||||||
|
s.ports.insert(port.port_id, port.clone());
|
||||||
|
if let Some(d) = s.input_devices.get_mut(&node_id) {
|
||||||
|
d.add_port(port);
|
||||||
|
} else if let Some(d) = s.output_devices.get_mut(&node_id) {
|
||||||
|
d.add_port(port);
|
||||||
|
} else if let Some(d) = s.sink_devices.get_mut(&node_id) {
|
||||||
|
d.add_port(port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ParsedGlobal::Link(link) => {
|
||||||
|
s.links.insert(link.id, link);
|
||||||
|
}
|
||||||
|
ParsedGlobal::Unknown => {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.global_remove(move |id| {
|
||||||
|
let mut s = state_for_registry_remove.borrow_mut();
|
||||||
|
s.input_devices.remove(&id);
|
||||||
|
s.output_devices.remove(&id);
|
||||||
|
s.sink_devices.remove(&id);
|
||||||
|
s.links.remove(&id);
|
||||||
|
s.links
|
||||||
|
.retain(|_, link| link.output_node != id && link.input_node != id);
|
||||||
|
s.ports.retain(|_, port| port.node_id != id);
|
||||||
|
s.ports.remove(&id);
|
||||||
|
})
|
||||||
|
.register();
|
||||||
|
|
||||||
|
// sync to signal ready
|
||||||
|
let state_for_sync = state.clone();
|
||||||
|
let _core_listener = core
|
||||||
|
.add_listener_local()
|
||||||
|
.done(move |id, _seq| {
|
||||||
|
if id == 0 {
|
||||||
|
let mut s = state_for_sync.borrow_mut();
|
||||||
|
if let Some(tx) = s.ready_tx.take() {
|
||||||
|
let _ = tx.send(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.register();
|
||||||
|
|
||||||
|
let _pending = core.sync(0).expect("sync failed");
|
||||||
|
|
||||||
|
let state_for_cmd = state.clone();
|
||||||
|
let _receiver = pw_receiver.attach(main_loop.loop_(), move |cmd| {
|
||||||
|
let mut s = state_for_cmd.borrow_mut();
|
||||||
|
match cmd {
|
||||||
|
PwCommand::GetDevices { resp } => {
|
||||||
|
let mut inputs: Vec<AudioDevice> =
|
||||||
|
s.input_devices.values().cloned().collect();
|
||||||
|
let mut outputs: Vec<AudioDevice> =
|
||||||
|
s.output_devices.values().cloned().collect();
|
||||||
|
let mut sinks: Vec<AudioDevice> =
|
||||||
|
s.sink_devices.values().cloned().collect();
|
||||||
|
inputs.sort_by_key(|a| a.id);
|
||||||
|
outputs.sort_by_key(|a| a.id);
|
||||||
|
sinks.sort_by_key(|a| a.id);
|
||||||
|
let _ = resp.send(AllDevices {
|
||||||
|
inputs,
|
||||||
|
outputs,
|
||||||
|
sinks,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
PwCommand::GetLinks { resp } => {
|
||||||
|
let mut links: Vec<LinkInfo> = s.links.values().copied().collect();
|
||||||
|
links.sort_by_key(|l| l.id);
|
||||||
|
let _ = resp.send(links);
|
||||||
|
}
|
||||||
|
PwCommand::CreateVirtualMic { resp } => {
|
||||||
|
let props = properties!(
|
||||||
|
"factory.name" => "support.null-audio-sink",
|
||||||
|
"node.name" => "pwsp-virtual-mic",
|
||||||
|
"node.description" => "PWSP Virtual Mic",
|
||||||
|
"media.class" => "Audio/Source/Virtual",
|
||||||
|
"audio.position" => "[ FL FR ]",
|
||||||
|
"audio.channels" => "2",
|
||||||
|
"object.linger" => "false",
|
||||||
|
);
|
||||||
|
match core.create_object::<pipewire::node::Node>("adapter", &props) {
|
||||||
|
Ok(node) => {
|
||||||
|
s.proxy_id_counter += 1;
|
||||||
|
let id = s.proxy_id_counter;
|
||||||
|
s.proxies.insert(id, Box::new(node));
|
||||||
|
let _ = resp.send(Ok(id));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = resp.send(Err(e.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PwCommand::CreateLink {
|
||||||
|
output_fl,
|
||||||
|
output_fr,
|
||||||
|
input_fl,
|
||||||
|
input_fr,
|
||||||
|
resp,
|
||||||
|
} => {
|
||||||
|
let props_fl = properties! {
|
||||||
|
"link.output.node" => format!("{}", output_fl.node_id).as_str(),
|
||||||
|
"link.output.port" => format!("{}", output_fl.port_id).as_str(),
|
||||||
|
"link.input.node" => format!("{}", input_fl.node_id).as_str(),
|
||||||
|
"link.input.port" => format!("{}", input_fl.port_id).as_str(),
|
||||||
|
};
|
||||||
|
let props_fr = properties! {
|
||||||
|
"link.output.node" => format!("{}", output_fr.node_id).as_str(),
|
||||||
|
"link.output.port" => format!("{}", output_fr.port_id).as_str(),
|
||||||
|
"link.input.node" => format!("{}", input_fr.node_id).as_str(),
|
||||||
|
"link.input.port" => format!("{}", input_fr.port_id).as_str(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let link_fl = match core.create_object::<Link>("link-factory", &props_fl) {
|
||||||
|
Ok(link) => link,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = resp.send(Err(e.to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let link_fr = match core.create_object::<Link>("link-factory", &props_fr) {
|
||||||
|
Ok(link) => link,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = resp.send(Err(e.to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
s.proxy_id_counter += 1;
|
||||||
|
let id_fl = s.proxy_id_counter;
|
||||||
|
s.proxies.insert(id_fl, Box::new(link_fl));
|
||||||
|
|
||||||
|
s.proxy_id_counter += 1;
|
||||||
|
let id_fr = s.proxy_id_counter;
|
||||||
|
s.proxies.insert(id_fr, Box::new(link_fr));
|
||||||
|
|
||||||
|
let _ = resp.send(Ok((id_fl, id_fr)));
|
||||||
|
}
|
||||||
|
PwCommand::DestroyObject { id } => {
|
||||||
|
s.proxies.remove(&id);
|
||||||
|
}
|
||||||
|
PwCommand::DestroyGlobal { id } => {
|
||||||
|
s.links.remove(&id);
|
||||||
|
registry.destroy_global(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
main_loop.run();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for the pipewire thread to be fully up and processed initial events
|
||||||
|
let _ = ready_rx.recv();
|
||||||
|
|
||||||
|
PipewireManager { sender: pw_sender }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn setup_pipewire_context() -> Result<(MainLoopRc, ContextRc), String> {
|
||||||
|
pipewire::init();
|
||||||
|
let main_loop = MainLoopRc::new(None).map_err(|e| e.to_string())?;
|
||||||
|
let context = ContextRc::new(&main_loop, None).map_err(|e| e.to_string())?;
|
||||||
|
Ok((main_loop, context))
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ParsedGlobal {
|
||||||
|
Device(AudioDevice),
|
||||||
|
Port(Port),
|
||||||
|
Link(LinkInfo),
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_global_object(global_object: &GlobalObject<&DictRef>) -> ParsedGlobal {
|
||||||
|
let props = match global_object.props {
|
||||||
|
Some(p) => p,
|
||||||
|
None => return ParsedGlobal::Unknown,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(media_class) = props.get("media.class") {
|
||||||
|
let node_id = global_object.id;
|
||||||
|
let node_nick = props.get("node.nick");
|
||||||
|
let node_name = props.get("node.name");
|
||||||
|
let node_description = props.get("node.description");
|
||||||
|
|
||||||
|
// `Audio/Source/Virtual` (our own virtual mic) also matches "Audio/Source",
|
||||||
|
// which is intended — it is a microphone as far as the rest of PWSP cares.
|
||||||
|
let device_type = if media_class.starts_with("Audio/Source") {
|
||||||
|
DeviceType::Input
|
||||||
|
} else if media_class.starts_with("Stream/Output/Audio") {
|
||||||
|
DeviceType::Output
|
||||||
|
} else if media_class.starts_with("Audio/Sink") {
|
||||||
|
DeviceType::Sink
|
||||||
|
} else {
|
||||||
|
return ParsedGlobal::Unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
return ParsedGlobal::Device(AudioDevice::new(
|
||||||
|
node_id,
|
||||||
|
node_nick,
|
||||||
|
node_description,
|
||||||
|
node_name,
|
||||||
|
device_type,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let (Some(output_node), Some(input_node)) = (
|
||||||
|
props
|
||||||
|
.get("link.output.node")
|
||||||
|
.and_then(|id| id.parse::<u32>().ok()),
|
||||||
|
props
|
||||||
|
.get("link.input.node")
|
||||||
|
.and_then(|id| id.parse::<u32>().ok()),
|
||||||
|
) {
|
||||||
|
return ParsedGlobal::Link(LinkInfo {
|
||||||
|
id: global_object.id,
|
||||||
|
output_node,
|
||||||
|
input_node,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if props.get("port.direction").is_some()
|
||||||
|
&& let (Some(node_id), Some(port_id), Some(port_name)) = (
|
||||||
|
props.get("node.id").and_then(|id| id.parse::<u32>().ok()),
|
||||||
|
props.get("port.id").and_then(|id| id.parse::<u32>().ok()),
|
||||||
|
props.get("port.name"),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return ParsedGlobal::Port(Port {
|
||||||
|
node_id,
|
||||||
|
port_id,
|
||||||
|
name: port_name.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ParsedGlobal::Unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_all_devices() -> Result<AllDevices> {
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
let manager = get_manager();
|
||||||
|
manager
|
||||||
|
.sender
|
||||||
|
.send(PwCommand::GetDevices { resp: tx })
|
||||||
|
.map_err(|_| anyhow!("Failed to send GetDevices to manager"))?;
|
||||||
|
let res = rx
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!("Failed to receive response: {}", e))?;
|
||||||
|
Ok(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_all_links() -> Result<Vec<LinkInfo>> {
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
let manager = get_manager();
|
||||||
|
manager
|
||||||
|
.sender
|
||||||
|
.send(PwCommand::GetLinks { resp: tx })
|
||||||
|
.map_err(|_| anyhow!("Failed to send GetLinks to manager"))?;
|
||||||
|
rx.await
|
||||||
|
.map_err(|e| anyhow!("Failed to receive response: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matches_device_name(device: &AudioDevice, device_name: &str) -> bool {
|
||||||
|
device.name == device_name
|
||||||
|
|| device.nick == device_name
|
||||||
|
|| device.name.contains(device_name)
|
||||||
|
|| device.nick.contains(device_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_device(device_name: &str) -> Result<AudioDevice> {
|
||||||
|
let devices = get_all_devices().await?;
|
||||||
|
|
||||||
|
devices
|
||||||
|
.iter()
|
||||||
|
.find(|device| matches_device_name(device, device_name))
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| anyhow!("Device not found: {}", device_name))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-reads a node by its PipeWire id, so callers always link against fresh ports.
|
||||||
|
pub async fn get_device_by_id(id: u32) -> Result<AudioDevice> {
|
||||||
|
get_all_devices()
|
||||||
|
.await?
|
||||||
|
.iter()
|
||||||
|
.find(|device| device.id == id)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| anyhow!("Node {} is gone", id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Looks up an `Audio/Sink` node by name.
|
||||||
|
pub async fn get_sink(name: &str) -> Result<AudioDevice> {
|
||||||
|
get_all_devices()
|
||||||
|
.await?
|
||||||
|
.sinks
|
||||||
|
.into_iter()
|
||||||
|
.find(|d| matches_device_name(d, name))
|
||||||
|
.ok_or_else(|| anyhow!("Output device not found: {}", name))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PwTerminator {
|
||||||
|
ids: Vec<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for PwTerminator {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let manager = get_manager();
|
||||||
|
for id in &self.ids {
|
||||||
|
let _ = manager.sender.send(PwCommand::DestroyObject { id: *id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_virtual_mic() -> Result<PwTerminator> {
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
let manager = get_manager();
|
||||||
|
manager
|
||||||
|
.sender
|
||||||
|
.send(PwCommand::CreateVirtualMic { resp: tx })
|
||||||
|
.map_err(|_| anyhow!("Failed to send CreateVirtualMic to manager"))?;
|
||||||
|
|
||||||
|
let res = rx
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!("Failed to receive response: {}", e))?;
|
||||||
|
|
||||||
|
let id = res.map_err(|e| anyhow!(e))?;
|
||||||
|
Ok(PwTerminator { ids: vec![id] })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Makes `source_node` feed `target` and nothing else.
|
||||||
|
///
|
||||||
|
/// Idempotent: safe to call on every device-check tick. Returns `Some` only when a new link
|
||||||
|
/// was created, so an already-correct route is left in place and the caller never tears
|
||||||
|
/// down a link it does not own.
|
||||||
|
///
|
||||||
|
/// The link is created *before* stale ones are pruned. That order matters: the node is
|
||||||
|
/// never left unlinked, which is the state that would invite the session manager to
|
||||||
|
/// re-attach it somewhere else.
|
||||||
|
pub async fn ensure_route(
|
||||||
|
source_node: &AudioDevice,
|
||||||
|
target: &AudioDevice,
|
||||||
|
) -> Result<Option<PwTerminator>> {
|
||||||
|
let existing = get_all_links().await?;
|
||||||
|
let already_routed = existing
|
||||||
|
.iter()
|
||||||
|
.any(|link| link.output_node == source_node.id && link.input_node == target.id);
|
||||||
|
|
||||||
|
let terminator = if already_routed {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let output_fl = source_node
|
||||||
|
.output_fl
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| anyhow!("Node {} has no output_FL port", source_node.name))?;
|
||||||
|
let output_fr = source_node
|
||||||
|
.output_fr
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| anyhow!("Node {} has no output_FR port", source_node.name))?;
|
||||||
|
let input_fl = target
|
||||||
|
.input_fl
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| anyhow!("Node {} has no input_FL port", target.name))?;
|
||||||
|
let input_fr = target
|
||||||
|
.input_fr
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| anyhow!("Node {} has no input_FR port", target.name))?;
|
||||||
|
|
||||||
|
Some(create_link(output_fl, output_fr, input_fl, input_fr).await?)
|
||||||
|
};
|
||||||
|
|
||||||
|
prune_links_from(source_node.id, target.id).await?;
|
||||||
|
|
||||||
|
Ok(terminator)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Destroys every link leaving `source_node` that does not end at `keep_target`.
|
||||||
|
async fn prune_links_from(source_node: u32, keep_target: u32) -> Result<()> {
|
||||||
|
let manager = get_manager();
|
||||||
|
for link in get_all_links().await? {
|
||||||
|
if link.output_node == source_node && link.input_node != keep_target {
|
||||||
|
let _ = manager
|
||||||
|
.sender
|
||||||
|
.send(PwCommand::DestroyGlobal { id: link.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_link(
|
||||||
|
output_fl: Port,
|
||||||
|
output_fr: Port,
|
||||||
|
input_fl: Port,
|
||||||
|
input_fr: Port,
|
||||||
|
) -> Result<PwTerminator> {
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
let manager = get_manager();
|
||||||
|
manager
|
||||||
|
.sender
|
||||||
|
.send(PwCommand::CreateLink {
|
||||||
|
output_fl,
|
||||||
|
output_fr,
|
||||||
|
input_fl,
|
||||||
|
input_fr,
|
||||||
|
resp: tx,
|
||||||
|
})
|
||||||
|
.map_err(|_| anyhow!("Failed to send CreateLink to manager"))?;
|
||||||
|
|
||||||
|
let res = rx
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!("Failed to receive response: {}", e))?;
|
||||||
|
|
||||||
|
let (id_fl, id_fr) = res.map_err(|e| anyhow!(e))?;
|
||||||
|
Ok(PwTerminator {
|
||||||
|
ids: vec![id_fl, id_fr],
|
||||||
|
})
|
||||||
|
}
|
||||||
Executable
+169
@@ -0,0 +1,169 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import shutil
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
# Helper to print errors and exit
|
||||||
|
def fatal(msg):
|
||||||
|
print(f"Error: {msg}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# Get the root directory of the project
|
||||||
|
root_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
os.chdir(root_dir)
|
||||||
|
|
||||||
|
# Read current version from Cargo.toml
|
||||||
|
cargo_toml_path = "Cargo.toml"
|
||||||
|
if not os.path.exists(cargo_toml_path):
|
||||||
|
fatal("Cargo.toml not found in the root directory.")
|
||||||
|
|
||||||
|
with open(cargo_toml_path, "r", encoding="utf-8") as f:
|
||||||
|
cargo_toml_content = f.read()
|
||||||
|
|
||||||
|
# We want to match version in [workspace.package]
|
||||||
|
# First, let's find the [workspace.package] section
|
||||||
|
workspace_package_match = re.search(r"\[workspace\.package\](.*?)(?=\n\[|$)", cargo_toml_content, re.DOTALL)
|
||||||
|
if not workspace_package_match:
|
||||||
|
fatal("Could not find [workspace.package] section in Cargo.toml.")
|
||||||
|
|
||||||
|
if isinstance(workspace_package_match, re.Match):
|
||||||
|
workspace_package_sec = workspace_package_match.group(1)
|
||||||
|
version_match = re.search(r'version\s*=\s*"([^"]+)"', workspace_package_sec)
|
||||||
|
if not version_match:
|
||||||
|
fatal("Could not find version in [workspace.package] in Cargo.toml.")
|
||||||
|
|
||||||
|
if isinstance(version_match, re.Match):
|
||||||
|
current_version = version_match.group(1)
|
||||||
|
print(f"Current version detected: {current_version}")
|
||||||
|
|
||||||
|
# Get new version
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
try:
|
||||||
|
new_version = input(f"Enter new version: ").strip()
|
||||||
|
except (KeyboardInterrupt, EOFError):
|
||||||
|
print()
|
||||||
|
sys.exit(0)
|
||||||
|
if not new_version:
|
||||||
|
fatal("No version provided.")
|
||||||
|
else:
|
||||||
|
new_version = sys.argv[1].strip()
|
||||||
|
|
||||||
|
if not re.match(r"^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$", new_version):
|
||||||
|
fatal(f"Invalid version format: '{new_version}'. Should be like '1.10.1'.")
|
||||||
|
|
||||||
|
# 1. Update Cargo.toml
|
||||||
|
print("Updating Cargo.toml...")
|
||||||
|
|
||||||
|
|
||||||
|
def replace_version_in_workspace(match):
|
||||||
|
section_content = match.group(1)
|
||||||
|
updated_section_content = re.sub(r'(version\s*=\s*")[^"]+(")', rf"\g<1>{new_version}\g<2>", section_content)
|
||||||
|
return f"[workspace.package]{updated_section_content}"
|
||||||
|
|
||||||
|
|
||||||
|
new_cargo_toml = re.sub(
|
||||||
|
r"\[workspace\.package\](.*?)(?=\n\[|$)",
|
||||||
|
replace_version_in_workspace,
|
||||||
|
cargo_toml_content,
|
||||||
|
flags=re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(cargo_toml_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(new_cargo_toml)
|
||||||
|
|
||||||
|
# Update Cargo.lock using cargo
|
||||||
|
print("Updating Cargo.lock using cargo generate-lockfile...")
|
||||||
|
try:
|
||||||
|
subprocess.run(["cargo", "generate-lockfile"], check=True)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Failed to update Cargo.lock using cargo: {e}")
|
||||||
|
|
||||||
|
# 2. Update packages/aur/bin/PKGBUILD
|
||||||
|
pkgbuild_bin_path = "packages/aur/bin/PKGBUILD"
|
||||||
|
if os.path.exists(pkgbuild_bin_path):
|
||||||
|
print(f"Updating {pkgbuild_bin_path}...")
|
||||||
|
with open(pkgbuild_bin_path, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
content = re.sub(r"pkgver=[^\n]+", f"pkgver={new_version}", content)
|
||||||
|
content = re.sub(r"pkgrel=[^\n]+", "pkgrel=1", content)
|
||||||
|
with open(pkgbuild_bin_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
# 3. Update packages/aur/standart/PKGBUILD
|
||||||
|
pkgbuild_std_path = "packages/aur/standart/PKGBUILD"
|
||||||
|
if os.path.exists(pkgbuild_std_path):
|
||||||
|
print(f"Updating {pkgbuild_std_path}...")
|
||||||
|
with open(pkgbuild_std_path, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
content = re.sub(r"pkgver=[^\n]+", f"pkgver={new_version}", content)
|
||||||
|
content = re.sub(r"pkgrel=[^\n]+", "pkgrel=1", content)
|
||||||
|
with open(pkgbuild_std_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
|
||||||
|
# Update AUR .SRCINFO files
|
||||||
|
def update_srcinfo(directory, pkgbuild_path, srcinfo_path):
|
||||||
|
if not os.path.exists(srcinfo_path):
|
||||||
|
return
|
||||||
|
print(f"Updating {srcinfo_path}...")
|
||||||
|
if shutil.which("makepkg"):
|
||||||
|
try:
|
||||||
|
print(f"Running makepkg --printsrcinfo in {directory}...")
|
||||||
|
result = subprocess.run(
|
||||||
|
["makepkg", "--printsrcinfo"],
|
||||||
|
cwd=directory,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
with open(srcinfo_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(result.stdout)
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: makepkg failed in {directory}: {e}. Falling back to text replacement.")
|
||||||
|
|
||||||
|
# Text replacement fallback
|
||||||
|
with open(srcinfo_path, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
content = re.sub(r"pkgver\s*=\s*[^\n]+", f"pkgver = {new_version}", content)
|
||||||
|
content = re.sub(r"pkgrel\s*=\s*[^\n]+", "pkgrel = 1", content)
|
||||||
|
content = content.replace(current_version, new_version)
|
||||||
|
with open(srcinfo_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
|
||||||
|
update_srcinfo("packages/aur/bin", pkgbuild_bin_path, "packages/aur/bin/.SRCINFO")
|
||||||
|
update_srcinfo("packages/aur/standart", pkgbuild_std_path, "packages/aur/standart/.SRCINFO")
|
||||||
|
|
||||||
|
# 4. Update packages/flatpak/ru.arabianq.pwsp.metainfo.xml
|
||||||
|
flatpak_xml_path = "packages/flatpak/ru.arabianq.pwsp.metainfo.xml"
|
||||||
|
if os.path.exists(flatpak_xml_path):
|
||||||
|
print(f"Updating {flatpak_xml_path}...")
|
||||||
|
with open(flatpak_xml_path, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
today_str = datetime.today().strftime("%Y-%m-%d")
|
||||||
|
content = re.sub(
|
||||||
|
r'<release\s+version="[^"]+"\s+date="[^"]+"\s*/?>',
|
||||||
|
f'<release version="{new_version}" date="{today_str}" />',
|
||||||
|
content,
|
||||||
|
)
|
||||||
|
with open(flatpak_xml_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
# 5. Update packages/rpm/pwsp.spec
|
||||||
|
rpm_spec_path = "packages/rpm/pwsp.spec"
|
||||||
|
if os.path.exists(rpm_spec_path):
|
||||||
|
print(f"Updating {rpm_spec_path}...")
|
||||||
|
with open(rpm_spec_path, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
content = re.sub(r"Version:\s*[^\n]+", f"Version: {new_version}", content)
|
||||||
|
with open(rpm_spec_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
print(f"Successfully updated all versions to {new_version}!")
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
use pwsp::{
|
|
||||||
types::socket::{MAX_MESSAGE_SIZE, Request, Response},
|
|
||||||
utils::{
|
|
||||||
commands::parse_command,
|
|
||||||
daemon::{
|
|
||||||
create_runtime_dir, get_audio_player, get_daemon_config, get_runtime_dir,
|
|
||||||
is_daemon_running,
|
|
||||||
},
|
|
||||||
global_hotkeys::start_global_hotkey_listener,
|
|
||||||
pipewire::create_virtual_mic,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
use std::{error::Error, fs, time::Duration};
|
|
||||||
use tokio::{
|
|
||||||
io::{AsyncReadExt, AsyncWriteExt},
|
|
||||||
net::UnixListener,
|
|
||||||
time::sleep,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> Result<(), Box<dyn Error>> {
|
|
||||||
create_runtime_dir()?;
|
|
||||||
|
|
||||||
if is_daemon_running()? {
|
|
||||||
return Err("Another instance is already running.".into());
|
|
||||||
}
|
|
||||||
|
|
||||||
get_daemon_config(); // Initialize daemon config
|
|
||||||
create_virtual_mic()?;
|
|
||||||
if let Err(err) = get_audio_player().await {
|
|
||||||
eprintln!("Failed to initialize audio player: {}", err);
|
|
||||||
} // Initialize audio player
|
|
||||||
|
|
||||||
tokio::spawn(async {
|
|
||||||
start_global_hotkey_listener().await;
|
|
||||||
});
|
|
||||||
|
|
||||||
let runtime_dir = get_runtime_dir();
|
|
||||||
|
|
||||||
let lock_file = fs::File::create(runtime_dir.join("daemon.lock"))?;
|
|
||||||
lock_file.lock()?;
|
|
||||||
|
|
||||||
let socket_path = runtime_dir.join("daemon.sock");
|
|
||||||
if let Err(e) = fs::remove_file(&socket_path)
|
|
||||||
&& e.kind() != std::io::ErrorKind::NotFound
|
|
||||||
{
|
|
||||||
return Err(e.into());
|
|
||||||
}
|
|
||||||
|
|
||||||
let listener = UnixListener::bind(&socket_path)?;
|
|
||||||
fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600))?;
|
|
||||||
|
|
||||||
println!(
|
|
||||||
"Daemon started. Listening on {}",
|
|
||||||
socket_path.to_str().unwrap_or_default()
|
|
||||||
);
|
|
||||||
|
|
||||||
let commands_loop_handle = tokio::spawn(async {
|
|
||||||
commands_loop(listener).await.ok();
|
|
||||||
});
|
|
||||||
|
|
||||||
let player_loop_handle = tokio::spawn(async {
|
|
||||||
player_loop().await;
|
|
||||||
});
|
|
||||||
|
|
||||||
tokio::select! {
|
|
||||||
_ = commands_loop_handle => {
|
|
||||||
eprint!("Commands loop was finished, stopping program...");
|
|
||||||
}
|
|
||||||
_ = player_loop_handle => {
|
|
||||||
eprint!("Audio Player loop was finished, stopping program...");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn commands_loop(listener: UnixListener) -> Result<(), Box<dyn Error>> {
|
|
||||||
loop {
|
|
||||||
let (mut stream, _addr) = listener.accept().await?;
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
// ---------- Read request (start) ----------
|
|
||||||
let mut len_bytes = [0u8; 4];
|
|
||||||
if stream.read_exact(&mut len_bytes).await.is_err() {
|
|
||||||
eprintln!("Failed to read message length from client!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let request_len = u32::from_le_bytes(len_bytes) as usize;
|
|
||||||
|
|
||||||
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];
|
|
||||||
if stream.read_exact(&mut buffer).await.is_err() {
|
|
||||||
eprintln!("Failed to read message from client!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let request: Request = match serde_json::from_slice(&buffer) {
|
|
||||||
Ok(req) => req,
|
|
||||||
Err(err) => {
|
|
||||||
let response =
|
|
||||||
Response::new(false, format!("Failed to parse request: {}", err));
|
|
||||||
let response_data = match serde_json::to_vec(&response) {
|
|
||||||
Ok(data) => data,
|
|
||||||
Err(_) => return, // Should not happen with this simple Response
|
|
||||||
};
|
|
||||||
let response_len = response_data.len() as u32;
|
|
||||||
let _ = stream.write_all(&response_len.to_le_bytes()).await;
|
|
||||||
let _ = stream.write_all(&response_data).await;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// ---------- Read request (end) ----------
|
|
||||||
|
|
||||||
// ---------- Generate response (start) ----------
|
|
||||||
let command = parse_command(&request);
|
|
||||||
let response: Response;
|
|
||||||
if let Some(command) = command {
|
|
||||||
response = command.execute().await;
|
|
||||||
} else {
|
|
||||||
response = Response::new(false, "Unknown command");
|
|
||||||
}
|
|
||||||
// ---------- Generate response (end) ----------
|
|
||||||
|
|
||||||
// ---------- Send response (start) ----------
|
|
||||||
let response_data = match serde_json::to_vec(&response) {
|
|
||||||
Ok(data) => data,
|
|
||||||
Err(err) => {
|
|
||||||
eprintln!("Failed to serialize response: {}", err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let response_len = response_data.len() as u32;
|
|
||||||
|
|
||||||
if stream.write_all(&response_len.to_le_bytes()).await.is_err() {
|
|
||||||
eprintln!("Failed to write response length to client!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if stream.write_all(&response_data).await.is_err() {
|
|
||||||
eprintln!("Failed to write response to client!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// ---------- Send response (end) ----------
|
|
||||||
|
|
||||||
if response.status && response.message.eq("killed") {
|
|
||||||
std::process::exit(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn player_loop() {
|
|
||||||
let mut device_check_counter: u32 = 0;
|
|
||||||
loop {
|
|
||||||
let is_idle = match get_audio_player().await {
|
|
||||||
Ok(player_mutex) => {
|
|
||||||
let mut audio_player = player_mutex.lock().await;
|
|
||||||
let check_devices = device_check_counter == 0;
|
|
||||||
audio_player.update(check_devices).await;
|
|
||||||
audio_player.tracks.is_empty()
|
|
||||||
}
|
|
||||||
Err(_err) => true,
|
|
||||||
};
|
|
||||||
|
|
||||||
if is_idle {
|
|
||||||
device_check_counter = 0;
|
|
||||||
sleep(Duration::from_secs(2)).await;
|
|
||||||
} else {
|
|
||||||
// Check devices every ~5 seconds (50 * 100ms) while playing
|
|
||||||
device_check_counter = (device_check_counter + 1) % 50;
|
|
||||||
sleep(Duration::from_millis(100)).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-963
@@ -1,963 +0,0 @@
|
|||||||
use crate::gui::SoundpadGui;
|
|
||||||
use egui::{
|
|
||||||
Align, AtomExt, Button, CollapsingHeader, Color32, ComboBox, CursorIcon, FontFamily, Label,
|
|
||||||
Layout, RichText, ScrollArea, Sense, Slider, TextEdit, Ui, Vec2,
|
|
||||||
};
|
|
||||||
use egui_dnd::dnd;
|
|
||||||
use egui_extras::{Column, TableBuilder};
|
|
||||||
use egui_material_icons::icons::*;
|
|
||||||
use pwsp::types::socket::Request;
|
|
||||||
use pwsp::types::{audio_player::TrackInfo, gui::AppState};
|
|
||||||
use pwsp::utils::gui::{format_time_pair, make_request_async};
|
|
||||||
use std::{
|
|
||||||
path::Path,
|
|
||||||
time::Instant,
|
|
||||||
};
|
|
||||||
|
|
||||||
enum TrackAction {
|
|
||||||
Pause(u32),
|
|
||||||
Resume(u32),
|
|
||||||
ToggleLoop(u32),
|
|
||||||
Stop(u32),
|
|
||||||
}
|
|
||||||
|
|
||||||
enum HotkeyAction {
|
|
||||||
Remove(String),
|
|
||||||
Capture(String),
|
|
||||||
ClearChord(String),
|
|
||||||
Play(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SoundpadGui {
|
|
||||||
fn get_volume_icon(volume: f32) -> &'static str {
|
|
||||||
if volume > 0.7 {
|
|
||||||
ICON_VOLUME_UP.codepoint
|
|
||||||
} else if volume <= 0.0 {
|
|
||||||
ICON_VOLUME_OFF.codepoint
|
|
||||||
} else if volume < 0.3 {
|
|
||||||
ICON_VOLUME_MUTE.codepoint
|
|
||||||
} else {
|
|
||||||
ICON_VOLUME_DOWN.codepoint
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn draw(&mut self, ui: &mut Ui) {
|
|
||||||
self.draw_header(ui);
|
|
||||||
self.draw_body(ui);
|
|
||||||
ui.separator();
|
|
||||||
self.draw_footer(ui);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn draw_waiting_for_daemon(&mut self, ui: &mut Ui) {
|
|
||||||
ui.centered_and_justified(|ui| {
|
|
||||||
ui.label(
|
|
||||||
RichText::new("Waiting for PWSP daemon to start...")
|
|
||||||
.size(34.0)
|
|
||||||
.monospace(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn draw_hotkey_capture(&mut self, ui: &mut Ui) {
|
|
||||||
ui.vertical_centered(|ui| {
|
|
||||||
ui.add_space(ui.available_height() / 3.0);
|
|
||||||
ui.label(
|
|
||||||
RichText::new("Press a key combination (e.g. Ctrl+Alt+1)")
|
|
||||||
.size(18.0)
|
|
||||||
.color(Color32::YELLOW)
|
|
||||||
.monospace(),
|
|
||||||
);
|
|
||||||
ui.add_space(10.0);
|
|
||||||
let target = if let Some(slot) = &self.app_state.assigning_hotkey_slot {
|
|
||||||
format!("for slot '{}'", slot)
|
|
||||||
} else if let Some(path) = &self.app_state.assigning_hotkey_for_file {
|
|
||||||
format!(
|
|
||||||
"for '{}'",
|
|
||||||
path.file_name().unwrap_or_default().to_string_lossy()
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
ui.label(RichText::new(target).size(16.0));
|
|
||||||
ui.add_space(10.0);
|
|
||||||
ui.label("Press Escape to cancel");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn draw_settings(&mut self, ui: &mut Ui) {
|
|
||||||
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("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",
|
|
||||||
);
|
|
||||||
let pause_on_exit_response = ui.checkbox(
|
|
||||||
&mut self.config.pause_on_exit,
|
|
||||||
"Pause audio playback when the window is closed",
|
|
||||||
);
|
|
||||||
|
|
||||||
if save_volume_response.changed()
|
|
||||||
|| 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(format!("GUI version: {}", env!("CARGO_PKG_VERSION")));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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("Hotkeys").color(Color32::WHITE).monospace());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_hotkeys_search(&mut self, ui: &mut Ui) {
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.menu_button(format!("{} Add Command", ICON_ADD.codepoint), |ui| {
|
|
||||||
let mut selected_cmd = None;
|
|
||||||
if ui.button("Toggle Pause").clicked() {
|
|
||||||
selected_cmd = Some(("cmd_toggle_pause", Request::toggle_pause(None)));
|
|
||||||
}
|
|
||||||
if ui.button("Stop Playback").clicked() {
|
|
||||||
selected_cmd = Some(("cmd_stop", Request::stop(None)));
|
|
||||||
}
|
|
||||||
if ui.button("Pause Playback").clicked() {
|
|
||||||
selected_cmd = Some(("cmd_pause", Request::pause(None)));
|
|
||||||
}
|
|
||||||
if ui.button("Resume Playback").clicked() {
|
|
||||||
selected_cmd = Some(("cmd_resume", Request::resume(None)));
|
|
||||||
}
|
|
||||||
if ui.button("Toggle Loop").clicked() {
|
|
||||||
selected_cmd = Some(("cmd_toggle_loop", Request::toggle_loop(None)));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some((slot_name, req)) = selected_cmd {
|
|
||||||
make_request_async(Request::set_hotkey_action(slot_name, &req));
|
|
||||||
self.app_state
|
|
||||||
.hotkey_config
|
|
||||||
.set_slot(slot_name.to_string(), req);
|
|
||||||
self.app_state.assigning_hotkey_slot = Some(slot_name.to_string());
|
|
||||||
self.app_state.hotkey_capture_active = true;
|
|
||||||
ui.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ui.add_space(10.0);
|
|
||||||
|
|
||||||
ui.add(
|
|
||||||
TextEdit::singleline(&mut self.app_state.hotkey_search_query)
|
|
||||||
.hint_text("Search hotkeys...")
|
|
||||||
.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)
|
|
||||||
})
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let available_width = ui.available_width();
|
|
||||||
let col_width = (available_width / 4.0).max(80.0);
|
|
||||||
|
|
||||||
TableBuilder::new(ui)
|
|
||||||
.striped(true)
|
|
||||||
.column(Column::exact(col_width).clip(true)) // Slot
|
|
||||||
.column(Column::exact(col_width).clip(true)) // Sound / Action name
|
|
||||||
.column(Column::exact(col_width).clip(true)) // Key Chord
|
|
||||||
.column(Column::exact(col_width).clip(true)) // Actions
|
|
||||||
.header(30.0, |mut header| {
|
|
||||||
header.col(|ui| {
|
|
||||||
ui.label(
|
|
||||||
RichText::new("Slot")
|
|
||||||
.strong()
|
|
||||||
.monospace()
|
|
||||||
.color(Color32::LIGHT_GRAY),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
header.col(|ui| {
|
|
||||||
ui.label(
|
|
||||||
RichText::new("Sound")
|
|
||||||
.strong()
|
|
||||||
.monospace()
|
|
||||||
.color(Color32::LIGHT_GRAY),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
header.col(|ui| {
|
|
||||||
ui.label(
|
|
||||||
RichText::new("Key Chord")
|
|
||||||
.strong()
|
|
||||||
.monospace()
|
|
||||||
.color(Color32::LIGHT_GRAY),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
header.col(|ui| {
|
|
||||||
ui.label(
|
|
||||||
RichText::new("Actions")
|
|
||||||
.strong()
|
|
||||||
.monospace()
|
|
||||||
.color(Color32::LIGHT_GRAY),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.body(|mut body| {
|
|
||||||
if slots.is_empty() {
|
|
||||||
body.row(30.0, |mut row| {
|
|
||||||
row.col(|_| {});
|
|
||||||
row.col(|ui| {
|
|
||||||
ui.label(
|
|
||||||
RichText::new("No hotkey slots configured.")
|
|
||||||
.color(Color32::GRAY),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
row.col(|_| {});
|
|
||||||
row.col(|_| {});
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for slot in &slots {
|
|
||||||
body.row(30.0, |mut row| {
|
|
||||||
// Column 1: Slot
|
|
||||||
row.col(|ui| {
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = self.app_state.dirs.clone();
|
|
||||||
|
|
||||||
dnd(ui, "dnd_directories").show_vec(&mut dirs, |ui, item, handle, _state| {
|
|
||||||
let path = item.clone();
|
|
||||||
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() {
|
|
||||||
self.open_dir(&path);
|
|
||||||
}
|
|
||||||
|
|
||||||
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, "Show"))
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
self.open_dir(&path);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ui
|
|
||||||
.button(format!(
|
|
||||||
"{} {}",
|
|
||||||
ICON_OPEN_IN_BROWSER.codepoint, "Open in File Manager"
|
|
||||||
))
|
|
||||||
.clicked()
|
|
||||||
&& let Err(e) = opener::open(&path)
|
|
||||||
{
|
|
||||||
eprintln!("Failed to open file manager: {}", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.separator();
|
|
||||||
|
|
||||||
if ui
|
|
||||||
.button(format!("{} {}", ICON_DELETE.codepoint, "Remove"))
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
self.app_state.dirs_to_remove.insert(path.clone());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
self.app_state.dirs = dirs;
|
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
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("Play file");
|
|
||||||
let play_file_button_response = ui.add(play_file_button);
|
|
||||||
if play_file_button_response.clicked() {
|
|
||||||
self.open_file();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_files(&mut self, ui: &mut Ui, area_size: Vec2) {
|
|
||||||
ui.vertical(|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("Search..."),
|
|
||||||
);
|
|
||||||
|
|
||||||
if self.app_state.force_focus_search {
|
|
||||||
search_field_response.request_focus();
|
|
||||||
self.app_state.force_focus_search = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.app_state.search_field_id = Some(search_field_response.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
ui.separator();
|
|
||||||
|
|
||||||
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 files = self.get_filtered_files();
|
|
||||||
|
|
||||||
for entry_path in files {
|
|
||||||
let file_name = entry_path
|
|
||||||
.file_name()
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_string_lossy()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
// Hotkey badge
|
|
||||||
let hotkey_badge = self.get_hotkey_badge(&entry_path);
|
|
||||||
if let Some(badge) = &hotkey_badge {
|
|
||||||
ui.label(
|
|
||||||
RichText::new(badge)
|
|
||||||
.small()
|
|
||||||
.monospace()
|
|
||||||
.color(Color32::from_rgb(100, 200, 100)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut file_button_text = RichText::new(&file_name);
|
|
||||||
if let Some(current_file) = &self.app_state.selected_file
|
|
||||||
&& current_file.eq(&entry_path)
|
|
||||||
{
|
|
||||||
file_button_text = file_button_text.color(Color32::WHITE);
|
|
||||||
}
|
|
||||||
|
|
||||||
let file_button = Button::new(file_button_text).frame(false).truncate();
|
|
||||||
let file_button_response = ui.add(file_button);
|
|
||||||
if file_button_response.clicked() {
|
|
||||||
ui.input(|i| {
|
|
||||||
if i.modifiers.ctrl {
|
|
||||||
self.play_file(&entry_path, true);
|
|
||||||
} else if i.modifiers.shift
|
|
||||||
&& let Some(last_track) =
|
|
||||||
self.audio_player_state.tracks.last()
|
|
||||||
{
|
|
||||||
self.stop(Some(last_track.id));
|
|
||||||
self.play_file(&entry_path, true);
|
|
||||||
} else {
|
|
||||||
self.play_file(&entry_path, false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
self.app_state.selected_file = Some(entry_path.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Context menu
|
|
||||||
file_button_response.context_menu(|ui| {
|
|
||||||
if ui
|
|
||||||
.button(format!("{} {}", ICON_BOLT.codepoint, "Play Solo"))
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
self.play_file(&entry_path, false);
|
|
||||||
self.app_state.selected_file = Some(entry_path.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
if ui
|
|
||||||
.button(format!("{} {}", ICON_ADD.codepoint, "Add New"))
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
self.play_file(&entry_path, true);
|
|
||||||
self.app_state.selected_file = Some(entry_path.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
if ui
|
|
||||||
.button(format!(
|
|
||||||
"{} {}",
|
|
||||||
ICON_SWAP_HORIZ.codepoint, "Replace Last"
|
|
||||||
))
|
|
||||||
.clicked()
|
|
||||||
&& let Some(last_track) = self.audio_player_state.tracks.last()
|
|
||||||
{
|
|
||||||
self.stop(Some(last_track.id));
|
|
||||||
self.play_file(&entry_path, true);
|
|
||||||
self.app_state.selected_file = Some(entry_path.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.separator();
|
|
||||||
|
|
||||||
if ui
|
|
||||||
.button(format!(
|
|
||||||
"{} {}",
|
|
||||||
ICON_OPEN_IN_BROWSER.codepoint, "Show in File Manager"
|
|
||||||
))
|
|
||||||
.clicked()
|
|
||||||
&& let Err(e) = opener::reveal(&entry_path)
|
|
||||||
{
|
|
||||||
eprintln!("Failed to open file manager: {}", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.separator();
|
|
||||||
|
|
||||||
if ui
|
|
||||||
.button(format!(
|
|
||||||
"{} {}",
|
|
||||||
ICON_KEYBOARD.codepoint, "Assign Hotkey"
|
|
||||||
))
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
self.app_state.assigning_hotkey_for_file =
|
|
||||||
Some(entry_path.clone());
|
|
||||||
self.app_state.hotkey_capture_active = true;
|
|
||||||
ui.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_hotkey_badge(&self, path: &Path) -> Option<String> {
|
|
||||||
for slot in &self.app_state.hotkey_config.slots {
|
|
||||||
if slot.action.name == "play"
|
|
||||||
&& let Some(file_path_str) = slot.action.args.get("file_path")
|
|
||||||
&& Path::new(file_path_str) == path
|
|
||||||
{
|
|
||||||
if let Some(chord) = &slot.key_chord {
|
|
||||||
return Some(format!("[{}]", chord));
|
|
||||||
} else {
|
|
||||||
return Some(format!("[{}]", slot.slot));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_footer(&mut self, ui: &mut Ui) {
|
|
||||||
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("Choose microphone")
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
// --------------------------------
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-233
@@ -1,233 +0,0 @@
|
|||||||
mod draw;
|
|
||||||
mod input;
|
|
||||||
mod update;
|
|
||||||
|
|
||||||
use eframe::{HardwareAcceleration, NativeOptions, icon_data::from_png_bytes, run_native};
|
|
||||||
use egui::{Context, Vec2, ViewportBuilder};
|
|
||||||
use itertools::Itertools;
|
|
||||||
use pwsp::{
|
|
||||||
types::{
|
|
||||||
audio_player::PlayerState,
|
|
||||||
config::GuiConfig,
|
|
||||||
config::HotkeyConfig,
|
|
||||||
gui::{AppState, AudioPlayerState},
|
|
||||||
socket::Request,
|
|
||||||
},
|
|
||||||
utils::{
|
|
||||||
daemon::get_daemon_config,
|
|
||||||
gui::{get_gui_config, make_request_async, make_request_sync, start_app_state_thread},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
use rfd::FileDialog;
|
|
||||||
use std::{
|
|
||||||
error::Error,
|
|
||||||
path::{Path, PathBuf},
|
|
||||||
sync::{Arc, Mutex},
|
|
||||||
};
|
|
||||||
|
|
||||||
const SUPPORTED_EXTENSIONS: [&str; 13] = [
|
|
||||||
"mp3", "wav", "ogg", "flac", "mp4", "m4a", "aac", "mov", "mkv", "mka", "webm", "avi", "opus",
|
|
||||||
];
|
|
||||||
|
|
||||||
struct SoundpadGui {
|
|
||||||
pub app_state: AppState,
|
|
||||||
pub config: GuiConfig,
|
|
||||||
pub audio_player_state: AudioPlayerState,
|
|
||||||
pub audio_player_state_shared: Arc<Mutex<AudioPlayerState>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SoundpadGui {
|
|
||||||
fn new(ctx: &Context) -> Self {
|
|
||||||
let audio_player_state = Arc::new(Mutex::new(AudioPlayerState::default()));
|
|
||||||
start_app_state_thread(audio_player_state.clone());
|
|
||||||
|
|
||||||
let config = get_gui_config();
|
|
||||||
|
|
||||||
ctx.set_zoom_factor(config.scale_factor);
|
|
||||||
|
|
||||||
let mut soundpad_gui = SoundpadGui {
|
|
||||||
app_state: AppState::default(),
|
|
||||||
config: config.clone(),
|
|
||||||
audio_player_state: AudioPlayerState::default(),
|
|
||||||
audio_player_state_shared: audio_player_state.clone(),
|
|
||||||
};
|
|
||||||
|
|
||||||
soundpad_gui.app_state.dirs = config.dirs;
|
|
||||||
soundpad_gui.app_state.hotkey_config = HotkeyConfig::load().unwrap_or_default();
|
|
||||||
|
|
||||||
soundpad_gui
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn play_toggle(&mut self) {
|
|
||||||
let (new_state, request) = {
|
|
||||||
let guard = self
|
|
||||||
.audio_player_state_shared
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|e| e.into_inner());
|
|
||||||
match guard.state {
|
|
||||||
PlayerState::Playing => (Some(PlayerState::Paused), Some(Request::pause(None))),
|
|
||||||
PlayerState::Paused => (Some(PlayerState::Playing), Some(Request::resume(None))),
|
|
||||||
PlayerState::Stopped => (None, None),
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(req) = request {
|
|
||||||
make_request_async(req);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(state) = new_state {
|
|
||||||
let mut guard = self
|
|
||||||
.audio_player_state_shared
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|e| e.into_inner());
|
|
||||||
guard.new_state = Some(state.clone());
|
|
||||||
guard.state = state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn open_file(&mut self) {
|
|
||||||
let file_dialog = FileDialog::new().add_filter("Audio File", &SUPPORTED_EXTENSIONS);
|
|
||||||
if let Some(path) = file_dialog.pick_file() {
|
|
||||||
self.play_file(&path, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_dirs(&mut self) {
|
|
||||||
let file_dialog = FileDialog::new();
|
|
||||||
if let Some(paths) = file_dialog.pick_folders() {
|
|
||||||
for path in paths {
|
|
||||||
self.app_state.dirs.push(path);
|
|
||||||
}
|
|
||||||
self.app_state.dirs = self.app_state.dirs.iter().unique().cloned().collect();
|
|
||||||
self.config.dirs = self.app_state.dirs.clone();
|
|
||||||
self.config.save_to_file().ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn open_dir(&mut self, path: &PathBuf) {
|
|
||||||
self.app_state.current_dir = Some(path.clone());
|
|
||||||
match path.read_dir() {
|
|
||||||
Ok(read_dir) => {
|
|
||||||
self.app_state.files = read_dir
|
|
||||||
.filter_map(|res| res.ok())
|
|
||||||
.map(|entry| entry.path())
|
|
||||||
.collect();
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Failed to read directory {:?}: {}", path, e);
|
|
||||||
self.app_state.files.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn play_file(&mut self, path: &Path, concurrent: bool) {
|
|
||||||
make_request_async(Request::play(&path.to_string_lossy(), concurrent));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_input(&mut self, name: String) {
|
|
||||||
make_request_async(Request::set_input(&name));
|
|
||||||
|
|
||||||
if self.config.save_input {
|
|
||||||
let mut daemon_config = get_daemon_config();
|
|
||||||
daemon_config.default_input_name = Some(name);
|
|
||||||
daemon_config.save_to_file().ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn toggle_loop(&mut self, id: Option<u32>) {
|
|
||||||
make_request_async(Request::toggle_loop(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn pause(&mut self, id: Option<u32>) {
|
|
||||||
make_request_async(Request::pause(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn resume(&mut self, id: Option<u32>) {
|
|
||||||
make_request_async(Request::resume(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn stop(&mut self, id: Option<u32>) {
|
|
||||||
make_request_async(Request::stop(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn play_hotkey_slot(&mut self, slot: &str) {
|
|
||||||
make_request_async(Request::play_hotkey(slot));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_filtered_files(&self) -> Vec<PathBuf> {
|
|
||||||
let mut files: Vec<PathBuf> = self.app_state.files.iter().cloned().collect();
|
|
||||||
files.sort();
|
|
||||||
|
|
||||||
let search_query = self.app_state.search_query.to_lowercase();
|
|
||||||
let search_query = search_query.trim();
|
|
||||||
|
|
||||||
files
|
|
||||||
.into_iter()
|
|
||||||
.filter(|entry_path| {
|
|
||||||
if entry_path.is_dir() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if !SUPPORTED_EXTENSIONS.contains(
|
|
||||||
&entry_path
|
|
||||||
.extension()
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_str()
|
|
||||||
.unwrap_or_default(),
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if !search_query.is_empty() {
|
|
||||||
let file_name = entry_path
|
|
||||||
.file_name()
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_string_lossy()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
if !file_name.to_lowercase().contains(search_query) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
true
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn run() -> Result<(), Box<dyn Error>> {
|
|
||||||
const ICON: &[u8] = include_bytes!("../../assets/icon.png");
|
|
||||||
|
|
||||||
let options = NativeOptions {
|
|
||||||
vsync: true,
|
|
||||||
centered: true,
|
|
||||||
hardware_acceleration: HardwareAcceleration::Preferred,
|
|
||||||
|
|
||||||
viewport: ViewportBuilder::default()
|
|
||||||
.with_app_id("ru.arabianq.pwsp")
|
|
||||||
.with_inner_size(Vec2::new(1200.0, 800.0))
|
|
||||||
.with_min_inner_size(Vec2::new(800.0, 600.0))
|
|
||||||
.with_icon(from_png_bytes(ICON)?),
|
|
||||||
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
match run_native(
|
|
||||||
"Pipewire Soundpad",
|
|
||||||
options,
|
|
||||||
Box::new(|cc| {
|
|
||||||
egui_material_icons::initialize(&cc.egui_ctx);
|
|
||||||
Ok(Box::new(SoundpadGui::new(&cc.egui_ctx)))
|
|
||||||
}),
|
|
||||||
) {
|
|
||||||
Ok(_) => {
|
|
||||||
let config = get_gui_config();
|
|
||||||
if config.pause_on_exit {
|
|
||||||
make_request_sync(Request::pause(None)).ok();
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(e) => Err(e.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
use crate::gui::SoundpadGui;
|
|
||||||
use eframe::{App, Frame as EFrame};
|
|
||||||
use egui::{CentralPanel, Context};
|
|
||||||
use pwsp::{
|
|
||||||
types::socket::Request,
|
|
||||||
utils::{daemon::get_daemon_config, gui::make_request_async},
|
|
||||||
};
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
impl App for SoundpadGui {
|
|
||||||
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
|
|
||||||
{
|
|
||||||
self.app_state.current_dir = None;
|
|
||||||
self.app_state.files.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save directories if changed
|
|
||||||
if !self.config.dirs.eq(&self.app_state.dirs) {
|
|
||||||
self.config.dirs = self.app_state.dirs.clone();
|
|
||||||
self.config.save_to_file().ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seek and volume requests
|
|
||||||
let mut seek_requests = vec![];
|
|
||||||
let mut volume_requests = vec![];
|
|
||||||
|
|
||||||
for (id, ui_state) in &mut self.app_state.track_ui_states {
|
|
||||||
if ui_state.position_dragged {
|
|
||||||
seek_requests.push((*id, ui_state.position_slider_value));
|
|
||||||
}
|
|
||||||
if ui_state.volume_dragged {
|
|
||||||
volume_requests.push((*id, ui_state.volume_slider_value));
|
|
||||||
ui_state.volume_dragged = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (id, pos) in seek_requests {
|
|
||||||
make_request_async(Request::seek(pos, Some(id)));
|
|
||||||
if let Some(ui_state) = self.app_state.track_ui_states.get_mut(&id) {
|
|
||||||
ui_state.position_dragged = false;
|
|
||||||
ui_state.ignore_position_update_until =
|
|
||||||
Some(Instant::now() + Duration::from_millis(300));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (id, vol) in volume_requests {
|
|
||||||
make_request_async(Request::set_volume(vol, Some(id)));
|
|
||||||
if let Some(ui_state) = self.app_state.track_ui_states.get_mut(&id) {
|
|
||||||
ui_state.volume_dragged = false;
|
|
||||||
ui_state.ignore_volume_update_until =
|
|
||||||
Some(Instant::now() + Duration::from_millis(300));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.app_state.volume_dragged {
|
|
||||||
make_request_async(Request::set_volume(
|
|
||||||
self.app_state.volume_slider_value,
|
|
||||||
None,
|
|
||||||
));
|
|
||||||
|
|
||||||
self.app_state.volume_dragged = false;
|
|
||||||
self.app_state.ignore_volume_update_until =
|
|
||||||
Some(Instant::now() + Duration::from_millis(300));
|
|
||||||
|
|
||||||
if self.config.save_volume {
|
|
||||||
let mut daemon_config = get_daemon_config();
|
|
||||||
daemon_config.default_volume = Some(self.app_state.volume_slider_value);
|
|
||||||
daemon_config.save_to_file().ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync audio player state
|
|
||||||
{
|
|
||||||
let mut guard = self
|
|
||||||
.audio_player_state_shared
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|e| e.into_inner());
|
|
||||||
if let Some(config) = guard.hotkey_config.take() {
|
|
||||||
self.app_state.hotkey_config = config;
|
|
||||||
}
|
|
||||||
self.audio_player_state = guard.clone();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle scale factor changes
|
|
||||||
let old_scale_factor = self.config.scale_factor;
|
|
||||||
let new_scale_factor = ctx.zoom_factor().clamp(0.5, 2.0);
|
|
||||||
|
|
||||||
ctx.set_zoom_factor(new_scale_factor);
|
|
||||||
self.config.scale_factor = new_scale_factor;
|
|
||||||
|
|
||||||
if new_scale_factor != old_scale_factor && self.config.save_scale_factor {
|
|
||||||
self.config.save_to_file().ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle input
|
|
||||||
self.handle_input(ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut EFrame) {
|
|
||||||
// Draw UI
|
|
||||||
CentralPanel::default().show_inside(ui, |ui| {
|
|
||||||
if !self.audio_player_state.is_daemon_running {
|
|
||||||
self.draw_waiting_for_daemon(ui);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.app_state.hotkey_capture_active {
|
|
||||||
self.draw_hotkey_capture(ui);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.app_state.show_settings {
|
|
||||||
self.draw_settings(ui);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.app_state.show_hotkeys {
|
|
||||||
self.draw_hotkeys(ui);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.draw(ui);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Request repaint
|
|
||||||
ui.request_repaint_after_secs(1.0 / 60.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
mod gui;
|
|
||||||
|
|
||||||
use std::error::Error;
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> Result<(), Box<dyn Error>> {
|
|
||||||
gui::run().await
|
|
||||||
}
|
|
||||||
@@ -1,488 +0,0 @@
|
|||||||
use crate::{
|
|
||||||
types::pipewire::{DeviceType, Terminate},
|
|
||||||
utils::{
|
|
||||||
daemon::get_daemon_config,
|
|
||||||
pipewire::{create_link, get_device, link_player_to_virtual_mic},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
use rodio::{Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::{
|
|
||||||
collections::HashMap,
|
|
||||||
error::Error,
|
|
||||||
fs,
|
|
||||||
path::{Path, PathBuf},
|
|
||||||
time::Duration,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq, Default, Clone, Serialize, Deserialize)]
|
|
||||||
pub enum PlayerState {
|
|
||||||
#[default]
|
|
||||||
Stopped,
|
|
||||||
Paused,
|
|
||||||
Playing,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct TrackInfo {
|
|
||||||
pub id: u32,
|
|
||||||
pub path: PathBuf,
|
|
||||||
pub duration: Option<f32>,
|
|
||||||
pub position: f32,
|
|
||||||
pub volume: f32,
|
|
||||||
pub looped: bool,
|
|
||||||
pub paused: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct FullState {
|
|
||||||
pub state: PlayerState,
|
|
||||||
pub tracks: Vec<TrackInfo>,
|
|
||||||
pub 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 {
|
|
||||||
stream_handle: Option<MixerDeviceSink>,
|
|
||||||
pub tracks: HashMap<u32, PlayingSound>,
|
|
||||||
pub next_id: u32,
|
|
||||||
|
|
||||||
input_link_sender: Option<pipewire::channel::Sender<Terminate>>,
|
|
||||||
player_link_sender: Option<pipewire::channel::Sender<Terminate>>,
|
|
||||||
pub input_device_name: Option<String>,
|
|
||||||
|
|
||||||
pub volume: f32, // Master volume
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AudioPlayer {
|
|
||||||
pub async fn new() -> Result<Self, Box<dyn Error>> {
|
|
||||||
let daemon_config = get_daemon_config();
|
|
||||||
let default_volume = daemon_config.default_volume.unwrap_or(1.0);
|
|
||||||
|
|
||||||
let mut audio_player = AudioPlayer {
|
|
||||||
stream_handle: None,
|
|
||||||
tracks: HashMap::new(),
|
|
||||||
next_id: 1,
|
|
||||||
|
|
||||||
input_link_sender: None,
|
|
||||||
player_link_sender: None,
|
|
||||||
input_device_name: daemon_config.default_input_name.clone(),
|
|
||||||
|
|
||||||
volume: default_volume,
|
|
||||||
};
|
|
||||||
|
|
||||||
if audio_player.input_device_name.is_some() {
|
|
||||||
audio_player.link_devices().await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(audio_player)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_stream(&mut self) -> Result<&MixerDeviceSink, Box<dyn Error>> {
|
|
||||||
if self.stream_handle.is_none() {
|
|
||||||
let mut sink = DeviceSinkBuilder::open_default_sink()?;
|
|
||||||
sink.log_on_drop(false);
|
|
||||||
self.stream_handle = Some(sink);
|
|
||||||
}
|
|
||||||
Ok(self.stream_handle.as_ref().unwrap())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn drop_stream(&mut self) {
|
|
||||||
if self.stream_handle.is_some() {
|
|
||||||
self.stream_handle = None;
|
|
||||||
self.abort_player_link_thread();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn abort_link_thread(&mut self) {
|
|
||||||
if let Some(sender) = &self.input_link_sender {
|
|
||||||
if sender.send(Terminate {}).is_ok() {
|
|
||||||
println!("Sent terminate signal to input link thread");
|
|
||||||
self.input_link_sender = None;
|
|
||||||
} else {
|
|
||||||
eprintln!("Failed to send terminate signal to input link thread");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn abort_player_link_thread(&mut self) {
|
|
||||||
if let Some(sender) = &self.player_link_sender {
|
|
||||||
if sender.send(Terminate {}).is_ok() {
|
|
||||||
println!("Sent terminate signal to player link thread");
|
|
||||||
self.player_link_sender = None;
|
|
||||||
} else {
|
|
||||||
eprintln!("Failed to send terminate signal to player link thread");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn link_player(&mut self) -> Result<(), Box<dyn Error>> {
|
|
||||||
if self.player_link_sender.is_some() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
match link_player_to_virtual_mic().await {
|
|
||||||
Ok(sender) => {
|
|
||||||
self.player_link_sender = Some(sender);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(_) => Ok(()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn link_devices(&mut self) -> Result<(), Box<dyn Error>> {
|
|
||||||
self.abort_link_thread();
|
|
||||||
|
|
||||||
let input_device;
|
|
||||||
if let Some(input_device_name) = &self.input_device_name {
|
|
||||||
if let Ok(device) = get_device(input_device_name).await {
|
|
||||||
input_device = device;
|
|
||||||
} else {
|
|
||||||
eprintln!(
|
|
||||||
"Could not find selected input device {}, skipping device linking",
|
|
||||||
input_device_name
|
|
||||||
);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
eprintln!("No input device selected, skipping device linking");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let daemon_input;
|
|
||||||
if let Ok(device) = get_device("pwsp-virtual-mic").await {
|
|
||||||
daemon_input = device;
|
|
||||||
} else {
|
|
||||||
eprintln!("Could not find pwsp-virtual-mic device, skipping device linking");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(output_fl) = input_device.output_fl.clone() else {
|
|
||||||
eprintln!("Failed to get 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(());
|
|
||||||
};
|
|
||||||
|
|
||||||
self.input_link_sender = Some(create_link(output_fl, output_fr, input_fl, input_fr)?);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn pause(&mut self, id: Option<u32>) {
|
|
||||||
if let Some(id) = id {
|
|
||||||
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, id: Option<u32>) {
|
|
||||||
if let Some(id) = id {
|
|
||||||
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, id: Option<u32>) {
|
|
||||||
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 {
|
|
||||||
if self.tracks.is_empty() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
self.tracks.values().all(|s| s.sink.is_paused())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_state(&self) -> PlayerState {
|
|
||||||
if self.tracks.is_empty() {
|
|
||||||
return PlayerState::Stopped;
|
|
||||||
}
|
|
||||||
|
|
||||||
if self
|
|
||||||
.tracks
|
|
||||||
.values()
|
|
||||||
.any(|s| !s.sink.is_paused() && !s.sink.empty())
|
|
||||||
{
|
|
||||||
return PlayerState::Playing;
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.is_paused() {
|
|
||||||
return PlayerState::Paused;
|
|
||||||
}
|
|
||||||
|
|
||||||
PlayerState::Stopped
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
for sound in self.tracks.values_mut() {
|
|
||||||
sound.sink.set_volume(self.volume * sound.volume);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_position(&self, id: Option<u32>) -> f32 {
|
|
||||||
if let Some(id) = id {
|
|
||||||
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<(), Box<dyn Error>> {
|
|
||||||
let position = if position < 0.0 { 0.0 } else { position };
|
|
||||||
|
|
||||||
if let Some(id) = id {
|
|
||||||
if let Some(sound) = self.tracks.get_mut(&id) {
|
|
||||||
sound.sink.try_seek(Duration::from_secs_f32(position))?;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Seek all? Or last? Let's seek all for now if no ID provided
|
|
||||||
for sound in self.tracks.values_mut() {
|
|
||||||
sound.sink.try_seek(Duration::from_secs_f32(position)).ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_duration(&mut self, id: Option<u32>) -> Result<f32, Box<dyn Error>> {
|
|
||||||
if let Some(id) = id {
|
|
||||||
if let Some(sound) = self.tracks.get(&id) {
|
|
||||||
return sound.duration.ok_or("Unknown duration".into());
|
|
||||||
}
|
|
||||||
} else if let Some(sound) = self.tracks.values().last() {
|
|
||||||
return sound.duration.ok_or("Unknown duration".into());
|
|
||||||
}
|
|
||||||
Err("No track playing".into())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn play(
|
|
||||||
&mut self,
|
|
||||||
file_path: &Path,
|
|
||||||
concurrent: bool,
|
|
||||||
) -> Result<u32, Box<dyn Error>> {
|
|
||||||
let path_buf = file_path.to_path_buf();
|
|
||||||
|
|
||||||
let decoder_result =
|
|
||||||
tokio::task::spawn_blocking(move || -> Result<_, Box<dyn Error + Send + Sync>> {
|
|
||||||
if !path_buf.exists() {
|
|
||||||
return Err(format!("File does not exist: {}", path_buf.display()).into());
|
|
||||||
}
|
|
||||||
|
|
||||||
let file = fs::File::open(&path_buf)?;
|
|
||||||
let decoder = Decoder::try_from(file)
|
|
||||||
.map_err(|e| Box::new(e) as Box<dyn Error + Send + Sync>)?;
|
|
||||||
Ok(decoder)
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
match decoder_result {
|
|
||||||
Ok(source) => {
|
|
||||||
if !concurrent {
|
|
||||||
self.tracks.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
self.ensure_stream()?;
|
|
||||||
self.link_player().await.ok();
|
|
||||||
|
|
||||||
let id = self.next_id;
|
|
||||||
self.next_id += 1;
|
|
||||||
|
|
||||||
let duration = source.total_duration().map(|d| d.as_secs_f32());
|
|
||||||
|
|
||||||
let mixer = self.stream_handle.as_ref().unwrap().mixer();
|
|
||||||
let sink = Player::connect_new(mixer);
|
|
||||||
sink.set_volume(self.volume); // Default volume is 1.0 * master
|
|
||||||
sink.append(source);
|
|
||||||
sink.play();
|
|
||||||
|
|
||||||
let sound = PlayingSound {
|
|
||||||
id,
|
|
||||||
sink,
|
|
||||||
path: file_path.to_path_buf(),
|
|
||||||
duration,
|
|
||||||
looped: false,
|
|
||||||
volume: 1.0,
|
|
||||||
};
|
|
||||||
|
|
||||||
self.tracks.insert(id, sound);
|
|
||||||
|
|
||||||
Ok(id)
|
|
||||||
}
|
|
||||||
Err(err) => Err(err as Box<dyn Error>),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_loop(&mut self, enabled: bool, id: Option<u32>) {
|
|
||||||
if let Some(id) = id {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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<(), Box<dyn Error>> {
|
|
||||||
let input_device = get_device(name).await?;
|
|
||||||
|
|
||||||
if input_device.device_type != DeviceType::Input {
|
|
||||||
return Err("Selected device is not an input device".into());
|
|
||||||
}
|
|
||||||
|
|
||||||
self.input_device_name = Some(name.to_string());
|
|
||||||
|
|
||||||
self.link_devices().await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
use crate::{types::socket::Request, utils::config::get_config_path};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::{collections::HashMap, error::Error, fs, path::PathBuf};
|
|
||||||
|
|
||||||
#[derive(Default, Clone, Serialize, Deserialize)]
|
|
||||||
#[serde(default)]
|
|
||||||
pub struct DaemonConfig {
|
|
||||||
pub default_input_name: Option<String>,
|
|
||||||
pub default_volume: Option<f32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DaemonConfig {
|
|
||||||
pub fn save_to_file(&self) -> Result<(), Box<dyn Error>> {
|
|
||||||
let config_path = get_config_path()?.join("daemon.json");
|
|
||||||
|
|
||||||
if let Some(config_dir) = config_path.parent()
|
|
||||||
&& !config_path.exists()
|
|
||||||
{
|
|
||||||
fs::create_dir_all(config_dir)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let config_json = serde_json::to_string_pretty(self)?;
|
|
||||||
fs::write(config_path, config_json.as_bytes())?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load_from_file() -> Result<DaemonConfig, Box<dyn Error>> {
|
|
||||||
let config_path = get_config_path()?.join("daemon.json");
|
|
||||||
let bytes = fs::read(config_path)?;
|
|
||||||
match serde_json::from_slice::<DaemonConfig>(&bytes) {
|
|
||||||
Ok(config) => Ok(config),
|
|
||||||
Err(_) => Ok(DaemonConfig::default()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
|
||||||
#[serde(default)]
|
|
||||||
pub struct GuiConfig {
|
|
||||||
pub scale_factor: f32,
|
|
||||||
pub left_panel_width: f32,
|
|
||||||
|
|
||||||
pub save_volume: bool,
|
|
||||||
pub save_input: bool,
|
|
||||||
pub save_scale_factor: bool,
|
|
||||||
pub pause_on_exit: bool,
|
|
||||||
|
|
||||||
pub dirs: Vec<PathBuf>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for GuiConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
GuiConfig {
|
|
||||||
scale_factor: 1.0,
|
|
||||||
left_panel_width: 280.0,
|
|
||||||
|
|
||||||
save_volume: false,
|
|
||||||
save_input: false,
|
|
||||||
save_scale_factor: false,
|
|
||||||
pause_on_exit: false,
|
|
||||||
|
|
||||||
dirs: vec![],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GuiConfig {
|
|
||||||
pub fn save_to_file(&mut self) -> Result<(), Box<dyn Error>> {
|
|
||||||
let config_path = get_config_path()?.join("gui.json");
|
|
||||||
|
|
||||||
if let Some(config_dir) = config_path.parent()
|
|
||||||
&& !config_path.exists()
|
|
||||||
{
|
|
||||||
fs::create_dir_all(config_dir)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Do not save scale factor if user does not want to
|
|
||||||
if !self.save_scale_factor {
|
|
||||||
self.scale_factor = 1.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let config_json = serde_json::to_string_pretty(self)?;
|
|
||||||
fs::write(config_path, config_json.as_bytes())?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load_from_file() -> Result<GuiConfig, Box<dyn Error>> {
|
|
||||||
let config_path = get_config_path()?.join("gui.json");
|
|
||||||
let bytes = fs::read(config_path)?;
|
|
||||||
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, Box<dyn Error>> {
|
|
||||||
Ok(get_config_path()?.join("hotkeys.json"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load() -> Result<HotkeyConfig, Box<dyn Error>> {
|
|
||||||
let path = Self::config_path()?;
|
|
||||||
if !path.exists() {
|
|
||||||
return Ok(HotkeyConfig::default());
|
|
||||||
}
|
|
||||||
let bytes = fs::read(&path)?;
|
|
||||||
match serde_json::from_slice::<HotkeyConfig>(&bytes) {
|
|
||||||
Ok(config) => Ok(config),
|
|
||||||
Err(e) => Err(e.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn save(&self) -> Result<(), Box<dyn Error>> {
|
|
||||||
let path = Self::config_path()?;
|
|
||||||
if let Some(dir) = path.parent()
|
|
||||||
&& !dir.exists()
|
|
||||||
{
|
|
||||||
fs::create_dir_all(dir)?;
|
|
||||||
}
|
|
||||||
let json = serde_json::to_string_pretty(self)?;
|
|
||||||
fs::write(path, json.as_bytes())?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn find_slot(&self, slot: &str) -> Option<&HotkeySlot> {
|
|
||||||
self.slots.iter().find(|s| s.slot == slot)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn find_slot_mut(&mut self, slot: &str) -> Option<&mut HotkeySlot> {
|
|
||||||
self.slots.iter_mut().find(|s| s.slot == slot)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_slot(&mut self, slot: String, action: Request) {
|
|
||||||
if let Some(existing) = self.find_slot_mut(&slot) {
|
|
||||||
existing.action = action;
|
|
||||||
} else {
|
|
||||||
self.slots.push(HotkeySlot {
|
|
||||||
slot,
|
|
||||||
action,
|
|
||||||
key_chord: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_key_chord(&mut self, slot: &str, key_chord: Option<String>) -> bool {
|
|
||||||
if let Some(existing) = self.find_slot_mut(slot) {
|
|
||||||
existing.key_chord = key_chord;
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remove_slot(&mut self, slot: &str) -> bool {
|
|
||||||
let len = self.slots.len();
|
|
||||||
self.slots.retain(|s| s.slot != slot);
|
|
||||||
self.slots.len() != len
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns pairs of slot names that share the same key chord.
|
|
||||||
pub fn find_conflicts(&self) -> Vec<(&str, &str)> {
|
|
||||||
let mut conflicts = vec![];
|
|
||||||
let mut chord_map: HashMap<&str, Vec<&str>> = HashMap::new();
|
|
||||||
|
|
||||||
for s in &self.slots {
|
|
||||||
if let Some(chord) = &s.key_chord {
|
|
||||||
chord_map.entry(chord.as_str()).or_default().push(&s.slot);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for slots in chord_map.values() {
|
|
||||||
if slots.len() > 1 {
|
|
||||||
for i in 0..slots.len() {
|
|
||||||
for j in (i + 1)..slots.len() {
|
|
||||||
conflicts.push((slots[i], slots[j]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
conflicts
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find which slot(s) have the given key chord.
|
|
||||||
pub fn slots_for_chord(&self, chord: &str) -> Vec<&HotkeySlot> {
|
|
||||||
self.slots
|
|
||||||
.iter()
|
|
||||||
.filter(|s| s.key_chord.as_deref() == Some(chord))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
use crate::types::{
|
|
||||||
audio_player::{PlayerState, TrackInfo},
|
|
||||||
config::HotkeyConfig,
|
|
||||||
};
|
|
||||||
|
|
||||||
use egui::Id;
|
|
||||||
|
|
||||||
use std::{
|
|
||||||
collections::{HashMap, HashSet},
|
|
||||||
path::PathBuf,
|
|
||||||
time::Instant,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Default, Debug)]
|
|
||||||
pub struct TrackUiState {
|
|
||||||
pub position_slider_value: f32,
|
|
||||||
pub volume_slider_value: f32,
|
|
||||||
|
|
||||||
pub position_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 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 dirs: Vec<PathBuf>,
|
|
||||||
pub dirs_to_remove: HashSet<PathBuf>,
|
|
||||||
|
|
||||||
pub selected_file: Option<PathBuf>,
|
|
||||||
pub files: HashSet<PathBuf>,
|
|
||||||
|
|
||||||
pub show_hotkeys: bool,
|
|
||||||
pub hotkey_config: HotkeyConfig,
|
|
||||||
pub hotkey_search_query: String,
|
|
||||||
pub assigning_hotkey_slot: Option<String>,
|
|
||||||
pub assigning_hotkey_for_file: Option<PathBuf>,
|
|
||||||
pub hotkey_capture_active: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone)]
|
|
||||||
pub struct AudioPlayerState {
|
|
||||||
pub state: PlayerState,
|
|
||||||
pub new_state: Option<PlayerState>,
|
|
||||||
|
|
||||||
pub tracks: Vec<TrackInfo>,
|
|
||||||
|
|
||||||
pub volume: f32, // Master volume
|
|
||||||
|
|
||||||
pub current_input: String,
|
|
||||||
pub all_inputs: HashMap<String, String>,
|
|
||||||
pub all_inputs_sorted: Vec<(String, String)>,
|
|
||||||
|
|
||||||
pub is_daemon_running: bool,
|
|
||||||
|
|
||||||
pub hotkey_config: Option<HotkeyConfig>,
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
#[derive(Debug)]
|
|
||||||
pub struct Terminate {}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Ord, PartialOrd, Eq, PartialEq)]
|
|
||||||
pub struct Port {
|
|
||||||
pub node_id: u32,
|
|
||||||
pub port_id: u32,
|
|
||||||
|
|
||||||
pub name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Ord, PartialOrd, Eq, PartialEq)]
|
|
||||||
pub enum DeviceType {
|
|
||||||
Input,
|
|
||||||
Output,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Ord, PartialOrd, Eq, PartialEq)]
|
|
||||||
pub struct AudioDevice {
|
|
||||||
pub id: u32,
|
|
||||||
|
|
||||||
pub nick: String,
|
|
||||||
pub name: String,
|
|
||||||
|
|
||||||
pub device_type: DeviceType,
|
|
||||||
|
|
||||||
pub input_fl: Option<Port>,
|
|
||||||
pub input_fr: Option<Port>,
|
|
||||||
pub output_fl: Option<Port>,
|
|
||||||
pub output_fr: Option<Port>,
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
use std::{error::Error, path::PathBuf};
|
|
||||||
|
|
||||||
pub fn get_config_path() -> Result<PathBuf, Box<dyn Error>> {
|
|
||||||
let config_path = dirs::config_dir().expect("Failed to obtain config dir");
|
|
||||||
Ok(config_path.join("pwsp"))
|
|
||||||
}
|
|
||||||
@@ -1,426 +0,0 @@
|
|||||||
use crate::types::pipewire::{AudioDevice, DeviceType, Port, Terminate};
|
|
||||||
use pipewire::{
|
|
||||||
context::ContextRc, link::Link, main_loop::MainLoopRc, properties::properties,
|
|
||||||
registry::GlobalObject, spa::utils::dict::DictRef,
|
|
||||||
};
|
|
||||||
use std::{collections::HashMap, error::Error, thread};
|
|
||||||
use tokio::{
|
|
||||||
sync::mpsc,
|
|
||||||
time::{Duration, timeout},
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn setup_pipewire_context() -> Result<(MainLoopRc, ContextRc), String> {
|
|
||||||
pipewire::init();
|
|
||||||
let main_loop = MainLoopRc::new(None).map_err(|e| e.to_string())?;
|
|
||||||
let context = ContextRc::new(&main_loop, None).map_err(|e| e.to_string())?;
|
|
||||||
Ok((main_loop, context))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_global_object(
|
|
||||||
global_object: &GlobalObject<&DictRef>,
|
|
||||||
) -> (Option<AudioDevice>, Option<Port>) {
|
|
||||||
// Only objects with props can be devices/ports
|
|
||||||
if let Some(props) = global_object.props {
|
|
||||||
// Only objects with media.class can be devices
|
|
||||||
if let Some(media_class) = props.get("media.class") {
|
|
||||||
let node_id = global_object.id;
|
|
||||||
let node_nick = props.get("node.nick");
|
|
||||||
let node_name = props.get("node.name");
|
|
||||||
let node_description = props.get("node.description");
|
|
||||||
|
|
||||||
// Check if the device is an input or output
|
|
||||||
return if media_class.starts_with("Audio/Source") {
|
|
||||||
let input_device = AudioDevice {
|
|
||||||
id: node_id,
|
|
||||||
nick: node_nick
|
|
||||||
.unwrap_or(node_description.unwrap_or(node_name.unwrap_or_default()))
|
|
||||||
.to_string(),
|
|
||||||
name: node_name.unwrap_or_default().to_string(),
|
|
||||||
device_type: DeviceType::Input,
|
|
||||||
|
|
||||||
input_fl: None,
|
|
||||||
input_fr: None,
|
|
||||||
output_fl: None,
|
|
||||||
output_fr: None,
|
|
||||||
};
|
|
||||||
(Some(input_device), None)
|
|
||||||
} else if media_class.starts_with("Stream/Output/Audio") {
|
|
||||||
let output_device = AudioDevice {
|
|
||||||
id: node_id,
|
|
||||||
nick: node_nick
|
|
||||||
.unwrap_or(node_description.unwrap_or(node_name.unwrap_or_default()))
|
|
||||||
.to_string(),
|
|
||||||
name: node_name.unwrap_or_default().to_string(),
|
|
||||||
device_type: DeviceType::Output,
|
|
||||||
|
|
||||||
input_fl: None,
|
|
||||||
input_fr: None,
|
|
||||||
output_fl: None,
|
|
||||||
output_fr: None,
|
|
||||||
};
|
|
||||||
(Some(output_device), None)
|
|
||||||
} else {
|
|
||||||
(None, None)
|
|
||||||
};
|
|
||||||
// Check if the object is a port
|
|
||||||
} else if props.get("port.direction").is_some()
|
|
||||||
&& let (Some(node_id), Some(port_id), Some(port_name)) = (
|
|
||||||
props.get("node.id").and_then(|id| id.parse::<u32>().ok()),
|
|
||||||
props.get("port.id").and_then(|id| id.parse::<u32>().ok()),
|
|
||||||
props.get("port.name"),
|
|
||||||
)
|
|
||||||
{
|
|
||||||
let port = Port {
|
|
||||||
node_id,
|
|
||||||
port_id,
|
|
||||||
name: port_name.to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
return (None, Some(port));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(None, None)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn pw_get_global_objects_thread(
|
|
||||||
main_sender: mpsc::Sender<(Option<AudioDevice>, Option<Port>)>,
|
|
||||||
pw_receiver: pipewire::channel::Receiver<Terminate>,
|
|
||||||
init_sender: std::sync::mpsc::SyncSender<Result<(), String>>,
|
|
||||||
) {
|
|
||||||
let (main_loop, context) = match setup_pipewire_context() {
|
|
||||||
Ok(res) => res,
|
|
||||||
Err(e) => {
|
|
||||||
let _ = init_sender.send(Err(e));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Stop main loop on Terminate message
|
|
||||||
let _receiver = pw_receiver.attach(main_loop.loop_(), {
|
|
||||||
let _main_loop = main_loop.clone();
|
|
||||||
move |_| _main_loop.quit()
|
|
||||||
});
|
|
||||||
|
|
||||||
let core = match context.connect(None) {
|
|
||||||
Ok(core) => core,
|
|
||||||
Err(e) => {
|
|
||||||
let _ = init_sender.send(Err(format!("Failed to connect to pipewire context: {}", e)));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let registry = match core.get_registry() {
|
|
||||||
Ok(registry) => registry,
|
|
||||||
Err(e) => {
|
|
||||||
let _ = init_sender.send(Err(format!(
|
|
||||||
"Failed to get registry from pipewire context: {}",
|
|
||||||
e
|
|
||||||
)));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let _listener = registry
|
|
||||||
.add_listener_local()
|
|
||||||
.global(move |global| {
|
|
||||||
// Try to parse every global object pipewire finds
|
|
||||||
let (device, port) = parse_global_object(global);
|
|
||||||
|
|
||||||
// Send message to the main thread
|
|
||||||
let sender_clone = main_sender.clone();
|
|
||||||
tokio::task::spawn(async move {
|
|
||||||
sender_clone.send((device, port)).await.ok();
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.register();
|
|
||||||
|
|
||||||
// Signal successful initialization
|
|
||||||
if init_sender.send(Ok(())).is_err() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
main_loop.run();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>), Box<dyn Error>> {
|
|
||||||
// Channels to communicate with pipewire thread
|
|
||||||
let (main_sender, mut main_receiver) = mpsc::channel(10);
|
|
||||||
let (pw_sender, pw_receiver) = pipewire::channel::channel();
|
|
||||||
let (init_sender, init_receiver) = std::sync::mpsc::sync_channel(0);
|
|
||||||
|
|
||||||
// Spawn pipewire thread in background
|
|
||||||
let _pw_thread = tokio::spawn(async move {
|
|
||||||
pw_get_global_objects_thread(main_sender, pw_receiver, init_sender).await
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for initialization to complete
|
|
||||||
if let Err(e) = init_receiver.recv()? {
|
|
||||||
return Err(e.into());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut input_devices: HashMap<u32, AudioDevice> = HashMap::new();
|
|
||||||
let mut output_devices: HashMap<u32, AudioDevice> = HashMap::new();
|
|
||||||
let mut ports: Vec<Port> = vec![];
|
|
||||||
|
|
||||||
loop {
|
|
||||||
// If we don't receive a message in 100ms, we can assume that pipewire thread is finished
|
|
||||||
match timeout(Duration::from_millis(100), main_receiver.recv()).await {
|
|
||||||
Ok(Some((device, port))) => {
|
|
||||||
if let Some(device) = device {
|
|
||||||
match device.device_type {
|
|
||||||
DeviceType::Input => {
|
|
||||||
input_devices.insert(device.id, device);
|
|
||||||
}
|
|
||||||
DeviceType::Output => {
|
|
||||||
output_devices.insert(device.id, device);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if let Some(port) = port {
|
|
||||||
ports.push(port);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(None) | Err(_) => {
|
|
||||||
// Pipewire thread is finished and we can collect our devices
|
|
||||||
let _ = pw_sender.send(Terminate {});
|
|
||||||
|
|
||||||
for port in ports {
|
|
||||||
let node_id = port.node_id;
|
|
||||||
|
|
||||||
if let Some(input_device) = input_devices.get_mut(&node_id) {
|
|
||||||
match port.name.as_str() {
|
|
||||||
"input_FL" => input_device.input_fl = Some(port),
|
|
||||||
"input_FR" => input_device.input_fr = Some(port),
|
|
||||||
"output_FL" => input_device.output_fl = Some(port),
|
|
||||||
"output_FR" => input_device.output_fr = Some(port),
|
|
||||||
"capture_FL" => input_device.output_fl = Some(port),
|
|
||||||
"capture_FR" => input_device.output_fr = Some(port),
|
|
||||||
"input_MONO" => {
|
|
||||||
input_device.input_fl = Some(port.clone());
|
|
||||||
input_device.input_fr = Some(port)
|
|
||||||
}
|
|
||||||
"capture_MONO" => {
|
|
||||||
input_device.output_fl = Some(port.clone());
|
|
||||||
input_device.output_fr = Some(port);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
} else if let Some(output_device) = output_devices.get_mut(&node_id) {
|
|
||||||
match port.name.as_str() {
|
|
||||||
"input_FL" => output_device.input_fl = Some(port),
|
|
||||||
"input_FR" => output_device.input_fr = Some(port),
|
|
||||||
"output_FL" => output_device.output_fl = Some(port),
|
|
||||||
"output_FR" => output_device.output_fr = Some(port),
|
|
||||||
"capture_FL" => output_device.output_fl = Some(port),
|
|
||||||
"capture_FR" => output_device.output_fr = Some(port),
|
|
||||||
"output_MONO" => {
|
|
||||||
output_device.output_fl = Some(port.clone());
|
|
||||||
output_device.output_fr = Some(port)
|
|
||||||
}
|
|
||||||
"capture_MONO" => {
|
|
||||||
output_device.output_fl = Some(port.clone());
|
|
||||||
output_device.output_fr = Some(port)
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut input_devices: Vec<AudioDevice> = input_devices.values().cloned().collect();
|
|
||||||
let mut output_devices: Vec<AudioDevice> =
|
|
||||||
output_devices.values().cloned().collect();
|
|
||||||
|
|
||||||
input_devices.sort_by_key(|a| a.id);
|
|
||||||
output_devices.sort_by_key(|a| a.id);
|
|
||||||
|
|
||||||
return Ok((input_devices, output_devices));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_device(device_name: &str) -> Result<AudioDevice, Box<dyn Error>> {
|
|
||||||
let (input_devices, output_devices) = get_all_devices().await?;
|
|
||||||
|
|
||||||
input_devices
|
|
||||||
.into_iter()
|
|
||||||
.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(|| "Device not found".into())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn create_virtual_mic() -> Result<pipewire::channel::Sender<Terminate>, Box<dyn Error>> {
|
|
||||||
let (pw_sender, pw_receiver) = pipewire::channel::channel::<Terminate>();
|
|
||||||
let (init_sender, init_receiver) = std::sync::mpsc::sync_channel(0);
|
|
||||||
|
|
||||||
let _pw_thread = thread::spawn(move || {
|
|
||||||
let (main_loop, context) = match setup_pipewire_context() {
|
|
||||||
Ok(res) => res,
|
|
||||||
Err(e) => {
|
|
||||||
let _ = init_sender.send(Err(e));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let core = match context.connect(None) {
|
|
||||||
Ok(core) => core,
|
|
||||||
Err(e) => {
|
|
||||||
let _ =
|
|
||||||
init_sender.send(Err(format!("Failed to connect to pipewire context: {}", e)));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let props = properties!(
|
|
||||||
"factory.name" => "support.null-audio-sink",
|
|
||||||
"node.name" => "pwsp-virtual-mic",
|
|
||||||
"node.description" => "PWSP Virtual Mic",
|
|
||||||
"media.class" => "Audio/Source/Virtual",
|
|
||||||
"audio.position" => "[ FL FR ]",
|
|
||||||
"audio.channels" => "2",
|
|
||||||
"object.linger" => "false", // Destroy the node on app exit
|
|
||||||
);
|
|
||||||
|
|
||||||
let _node = match core.create_object::<pipewire::node::Node>("adapter", &props) {
|
|
||||||
Ok(node) => node,
|
|
||||||
Err(e) => {
|
|
||||||
let _ = init_sender.send(Err(format!("Failed to create virtual mic: {}", e)));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let _receiver = pw_receiver.attach(main_loop.loop_(), {
|
|
||||||
let _main_loop = main_loop.clone();
|
|
||||||
move |_| _main_loop.quit()
|
|
||||||
});
|
|
||||||
|
|
||||||
println!("Virtual mic created");
|
|
||||||
if init_sender.send(Ok(())).is_err() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
main_loop.run();
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Err(e) = init_receiver.recv()? {
|
|
||||||
return Err(e.into());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(pw_sender)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn link_player_to_virtual_mic()
|
|
||||||
-> Result<pipewire::channel::Sender<Terminate>, Box<dyn Error>> {
|
|
||||||
let pwsp_daemon_output = match get_device("pwsp-daemon").await {
|
|
||||||
Ok(device) => device,
|
|
||||||
Err(_) => {
|
|
||||||
return Err(
|
|
||||||
"Could not find alsa_playback.pwsp-daemon device, skipping device linking".into(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let pwsp_daemon_input = match get_device("pwsp-virtual-mic").await {
|
|
||||||
Ok(device) => device,
|
|
||||||
Err(_) => {
|
|
||||||
return Err("Could not find pwsp-virtual-mic device, skipping device linking".into());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let output_fl = match pwsp_daemon_output.output_fl {
|
|
||||||
Some(port) => port,
|
|
||||||
None => return Err("Failed to get pwsp-daemon output_fl".into()),
|
|
||||||
};
|
|
||||||
let output_fr = match pwsp_daemon_output.output_fr {
|
|
||||||
Some(port) => port,
|
|
||||||
None => return Err("Failed to get pwsp-daemon output_fr".into()),
|
|
||||||
};
|
|
||||||
let input_fl = match pwsp_daemon_input.input_fl {
|
|
||||||
Some(port) => port,
|
|
||||||
None => return Err("Failed to get pwsp-virtual-mic input_fl".into()),
|
|
||||||
};
|
|
||||||
let input_fr = match pwsp_daemon_input.input_fr {
|
|
||||||
Some(port) => port,
|
|
||||||
None => return Err("Failed to get pwsp-virtual-mic input_fr".into()),
|
|
||||||
};
|
|
||||||
|
|
||||||
create_link(output_fl, output_fr, input_fl, input_fr)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn create_link(
|
|
||||||
output_fl: Port,
|
|
||||||
output_fr: Port,
|
|
||||||
input_fl: Port,
|
|
||||||
input_fr: Port,
|
|
||||||
) -> Result<pipewire::channel::Sender<Terminate>, Box<dyn Error>> {
|
|
||||||
let (pw_sender, pw_receiver) = pipewire::channel::channel::<Terminate>();
|
|
||||||
let (init_sender, init_receiver) = std::sync::mpsc::sync_channel(0);
|
|
||||||
|
|
||||||
let _pw_thread = thread::spawn(move || {
|
|
||||||
let (main_loop, context) = match setup_pipewire_context() {
|
|
||||||
Ok(res) => res,
|
|
||||||
Err(e) => {
|
|
||||||
let _ = init_sender.send(Err(e));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let core = match context.connect(None) {
|
|
||||||
Ok(core) => core,
|
|
||||||
Err(e) => {
|
|
||||||
let _ =
|
|
||||||
init_sender.send(Err(format!("Failed to connect to pipewire context: {}", e)));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let props_fl = properties! {
|
|
||||||
"link.output.node" => format!("{}", output_fl.node_id).as_str(),
|
|
||||||
"link.output.port" => format!("{}", output_fl.port_id).as_str(),
|
|
||||||
"link.input.node" => format!("{}", input_fl.node_id).as_str(),
|
|
||||||
"link.input.port" => format!("{}", input_fl.port_id).as_str(),
|
|
||||||
};
|
|
||||||
let props_fr = properties! {
|
|
||||||
"link.output.node" => format!("{}", output_fr.node_id).as_str(),
|
|
||||||
"link.output.port" => format!("{}", output_fr.port_id).as_str(),
|
|
||||||
"link.input.node" => format!("{}", input_fr.node_id).as_str(),
|
|
||||||
"link.input.port" => format!("{}", input_fr.port_id).as_str(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let _link_fl = match core.create_object::<Link>("link-factory", &props_fl) {
|
|
||||||
Ok(link) => link,
|
|
||||||
Err(e) => {
|
|
||||||
let _ = init_sender.send(Err(format!("Failed to create link FL: {}", e)));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let _link_fr = match core.create_object::<Link>("link-factory", &props_fr) {
|
|
||||||
Ok(link) => link,
|
|
||||||
Err(e) => {
|
|
||||||
let _ = init_sender.send(Err(format!("Failed to create link FR: {}", e)));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let _receiver = pw_receiver.attach(main_loop.loop_(), {
|
|
||||||
let _main_loop = main_loop.clone();
|
|
||||||
move |_| _main_loop.quit()
|
|
||||||
});
|
|
||||||
|
|
||||||
println!(
|
|
||||||
"Link created: FL: {}-{} FR: {}-{}",
|
|
||||||
output_fl.node_id, input_fl.node_id, output_fr.node_id, input_fr.node_id
|
|
||||||
);
|
|
||||||
if init_sender.send(Ok(())).is_err() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
main_loop.run();
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Err(e) = init_receiver.recv()? {
|
|
||||||
return Err(e.into());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(pw_sender)
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user