mirror of
https://github.com/arabianq/pipewire-soundpad.git
synced 2026-06-19 20:23:33 +00:00
Compare commits
119 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a56e1db6b | |||
| 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 | |||
| e6c8d720d5 | |||
| a6d93ff528 | |||
| bcf791d84c | |||
| e4b0b10393 | |||
| 11de96db58 | |||
| 7396c0aef8 | |||
| fc2cd5e2da | |||
| 1a37729cf1 | |||
| 86b38a250e | |||
| 54fa278cea | |||
| db040aa820 | |||
| 04449e7525 | |||
| 9f50809a99 | |||
| 7dda4bc2b1 | |||
| 1569955e12 | |||
| 9adc6cfbda | |||
| 76b1d4f345 | |||
| 10f9937dc3 | |||
| 498c09eb50 | |||
| 78e0a133b6 | |||
| 7f8b7194b6 | |||
| 302f153b91 | |||
| f87dcb1564 | |||
| d4d16f6ce7 |
@@ -0,0 +1,6 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
+97
-18
@@ -13,7 +13,15 @@ on:
|
||||
|
||||
jobs:
|
||||
linux-build:
|
||||
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:
|
||||
- name: Install apt deps (jq/zip + dev-libs)
|
||||
@@ -23,7 +31,9 @@ jobs:
|
||||
zip jq \
|
||||
libpipewire-0.3-dev \
|
||||
libclang-dev \
|
||||
libasound2-dev
|
||||
libasound2-dev \
|
||||
libdbus-1-dev \
|
||||
pkg-config
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -31,29 +41,38 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: 1.94.1
|
||||
toolchain: 1.96.0
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --locked
|
||||
|
||||
- name: Build all binaries (debug-speed compilation into target/release)
|
||||
env:
|
||||
CARGO_PROFILE_RELEASE_OPT_LEVEL: 0
|
||||
CARGO_PROFILE_RELEASE_DEBUG: "true"
|
||||
CARGO_PROFILE_RELEASE_STRIP: "false"
|
||||
CARGO_PROFILE_RELEASE_LTO: "false"
|
||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: 256
|
||||
run: cargo build --release --locked
|
||||
|
||||
- name: Extract all binary names
|
||||
id: cargo-meta
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BIN_NAMES=$(cargo metadata --no-deps --format-version 1 \
|
||||
| jq -r '.packages[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" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build all release binaries
|
||||
run: cargo build --release --locked
|
||||
|
||||
- name: Package all binaries into one archive
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
COMMIT_SHA="${{ github.sha }}"
|
||||
ARCHIVE_NAME="pwsp-${COMMIT_SHA}-linux-x64.zip"
|
||||
ARCHIVE_NAME="pwsp-${COMMIT_SHA}-linux-${{ matrix.arch }}.zip"
|
||||
echo "Creating archive: $ARCHIVE_NAME"
|
||||
|
||||
FILES=()
|
||||
@@ -80,28 +99,87 @@ jobs:
|
||||
- name: Upload archive as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: archive
|
||||
name: archive-${{ matrix.arch }}
|
||||
path: pwsp-*.zip
|
||||
retention-days: 7
|
||||
|
||||
- name: Install cargo-deb and create .deb
|
||||
deb-build:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- arch: x64
|
||||
runner: ubuntu-latest
|
||||
- arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
fail-fast: false
|
||||
runs-on: ${{ matrix.runner }}
|
||||
|
||||
steps:
|
||||
- name: Install apt deps (dev-libs)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libpipewire-0.3-dev \
|
||||
libclang-dev \
|
||||
libasound2-dev \
|
||||
libdbus-1-dev \
|
||||
pkg-config
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: 1.96.0
|
||||
|
||||
- name: Build all binaries (debug-speed compilation into target/release)
|
||||
env:
|
||||
CARGO_PROFILE_RELEASE_OPT_LEVEL: 0
|
||||
CARGO_PROFILE_RELEASE_DEBUG: "true"
|
||||
CARGO_PROFILE_RELEASE_STRIP: "false"
|
||||
CARGO_PROFILE_RELEASE_LTO: "false"
|
||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: 256
|
||||
run: cargo build --release --locked
|
||||
|
||||
- name: Cache cargo-deb
|
||||
id: cache-cargo-deb
|
||||
uses: actions/cache@v4
|
||||
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 (debug binaries from target/release)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo install --locked cargo-deb
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
|
||||
cargo-deb
|
||||
cargo-deb -p pwsp-gui --no-build --no-strip
|
||||
|
||||
- name: Upload .deb(s) as artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: deb-packages
|
||||
name: deb-packages-${{ matrix.arch }}
|
||||
path: target/debian/*.deb
|
||||
retention-days: 7
|
||||
|
||||
flatpak-build:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- arch: x86_64
|
||||
runner: ubuntu-latest
|
||||
- arch: aarch64
|
||||
runner: ubuntu-24.04-arm
|
||||
fail-fast: false
|
||||
runs-on: ${{ matrix.runner }}
|
||||
container:
|
||||
image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-25.08
|
||||
options: --privileged
|
||||
@@ -112,8 +190,9 @@ jobs:
|
||||
- name: Build Flatpak
|
||||
uses: flatpak/flatpak-github-actions/flatpak-builder@v6
|
||||
with:
|
||||
bundle: ru.arabianq.pwsp.flatpak
|
||||
bundle: ru.arabianq.pwsp_${{ matrix.arch }}.flatpak
|
||||
manifest-path: packages/flatpak/ru.arabianq.pwsp.yaml
|
||||
cache: true
|
||||
branch: master
|
||||
build-bundle: true
|
||||
build-bundle: true
|
||||
arch: ${{ matrix.arch }}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
name: Flatter
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag_name:
|
||||
description: "TAG (empty to build from current branch)"
|
||||
required: false
|
||||
type: string
|
||||
build_branch:
|
||||
description: "Flatpak branch to build (stable/nightly)"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- stable
|
||||
- nightly
|
||||
default: "stable"
|
||||
|
||||
jobs:
|
||||
flatter-x64:
|
||||
name: Flatter (x86_64)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
container:
|
||||
image: ghcr.io/andyholmes/flatter/freedesktop:25.08
|
||||
options: --privileged
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.tag_name || github.ref }}
|
||||
|
||||
- name: Setup GPG
|
||||
id: gpg
|
||||
uses: crazy-max/ghaction-import-gpg@v6
|
||||
with:
|
||||
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||
passphrase: ${{ secrets.GPG_PASSPHRASE }}
|
||||
|
||||
- name: Set Default Branch
|
||||
id: set_branch
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" == "release" ]; then
|
||||
echo "branch=stable" >> $GITHUB_OUTPUT
|
||||
elif [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
|
||||
echo "branch=${{ inputs.build_branch }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "branch=nightly" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Modify Manifest
|
||||
run: |
|
||||
echo "branch: ${{ steps.set_branch.outputs.branch }}" >> packages/flatpak/ru.arabianq.pwsp.yaml
|
||||
echo "default-branch: ${{ steps.set_branch.outputs.branch }}" >> packages/flatpak/ru.arabianq.pwsp.yaml
|
||||
|
||||
- name: Install SDK Extensions
|
||||
run:
|
||||
flatpak install -y flathub org.freedesktop.Sdk.Extension.rust-stable//25.08
|
||||
org.freedesktop.Sdk.Extension.llvm20//25.08
|
||||
|
||||
- name: Build Flatpak
|
||||
uses: andyholmes/flatter@main
|
||||
with:
|
||||
files: packages/flatpak/ru.arabianq.pwsp.yaml
|
||||
gpg-sign: ${{ steps.gpg.outputs.fingerprint }}
|
||||
upload-bundles: false
|
||||
upload-pages-artifact: false
|
||||
arch: x86_64
|
||||
|
||||
flatter-arm64:
|
||||
name: Flatter (aarch64)
|
||||
needs: flatter-x64
|
||||
runs-on: ubuntu-24.04-arm
|
||||
permissions:
|
||||
contents: read
|
||||
container:
|
||||
image: ghcr.io/andyholmes/flatter/freedesktop:25.08
|
||||
options: --privileged
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.tag_name || github.ref }}
|
||||
|
||||
- name: Setup GPG
|
||||
id: gpg
|
||||
uses: crazy-max/ghaction-import-gpg@v6
|
||||
with:
|
||||
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||
passphrase: ${{ secrets.GPG_PASSPHRASE }}
|
||||
|
||||
- name: Set Default Branch
|
||||
id: set_branch
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" == "release" ]; then
|
||||
echo "branch=stable" >> $GITHUB_OUTPUT
|
||||
elif [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
|
||||
echo "branch=${{ inputs.build_branch }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "branch=nightly" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Modify Manifest
|
||||
run: |
|
||||
echo "branch: ${{ steps.set_branch.outputs.branch }}" >> packages/flatpak/ru.arabianq.pwsp.yaml
|
||||
echo "default-branch: ${{ steps.set_branch.outputs.branch }}" >> packages/flatpak/ru.arabianq.pwsp.yaml
|
||||
|
||||
- name: Install SDK Extensions
|
||||
run:
|
||||
flatpak install -y flathub org.freedesktop.Sdk.Extension.rust-stable//25.08
|
||||
org.freedesktop.Sdk.Extension.llvm20//25.08
|
||||
|
||||
- name: Build Flatpak
|
||||
uses: andyholmes/flatter@main
|
||||
with:
|
||||
files: packages/flatpak/ru.arabianq.pwsp.yaml
|
||||
gpg-sign: ${{ steps.gpg.outputs.fingerprint }}
|
||||
upload-bundles: false
|
||||
upload-pages-artifact: true
|
||||
arch: aarch64
|
||||
|
||||
deploy:
|
||||
name: Deploy to GitHub Pages
|
||||
runs-on: ubuntu-latest
|
||||
needs: flatter-arm64
|
||||
permissions:
|
||||
pages: write
|
||||
id-token: write
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
|
||||
steps:
|
||||
- name: GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
+120
-42
@@ -64,7 +64,15 @@ jobs:
|
||||
|
||||
linux-release:
|
||||
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:
|
||||
- name: Install apt deps (jq/zip + dev-libs)
|
||||
@@ -74,7 +82,9 @@ jobs:
|
||||
zip jq \
|
||||
libpipewire-0.3-dev \
|
||||
libclang-dev \
|
||||
libasound2-dev
|
||||
libasound2-dev \
|
||||
libdbus-1-dev \
|
||||
pkg-config
|
||||
|
||||
- name: Checkout code at tag
|
||||
uses: actions/checkout@v4
|
||||
@@ -83,16 +93,16 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: 1.94.1
|
||||
toolchain: 1.96.0
|
||||
|
||||
- name: Extract all binary names
|
||||
id: cargo-meta
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BIN_NAMES=$(cargo metadata --no-deps --format-version 1 \
|
||||
| jq -r '.packages[0].targets[] | select(.kind[] | contains("bin")) | .name')
|
||||
| jq -r '.packages[].targets[] | select(.kind[] | contains("bin")) | .name')
|
||||
echo "bin_names<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$BIN_NAMES" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
@@ -105,7 +115,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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=()
|
||||
@@ -129,55 +139,123 @@ jobs:
|
||||
|
||||
zip -j "$ARCHIVE_NAME" "${FILES[@]}"
|
||||
|
||||
- name: Upload release archive
|
||||
uses: softprops/action-gh-release@v2
|
||||
- name: Upload zip archive
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
tag_name: ${{ needs.prepare.outputs.tag }}
|
||||
files: |
|
||||
pwsp-*.zip
|
||||
name: zip-archive-${{ matrix.arch }}
|
||||
path: pwsp-*.zip
|
||||
retention-days: 1
|
||||
|
||||
- name: Install cargo-deb and create .deb
|
||||
deb-release:
|
||||
needs: prepare
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- arch: x64
|
||||
runner: ubuntu-latest
|
||||
- arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
fail-fast: false
|
||||
runs-on: ${{ matrix.runner }}
|
||||
|
||||
steps:
|
||||
- name: Install apt deps (dev-libs)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libpipewire-0.3-dev \
|
||||
libclang-dev \
|
||||
libasound2-dev \
|
||||
libdbus-1-dev \
|
||||
pkg-config
|
||||
|
||||
- name: Checkout code at tag
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.tag }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: 1.96.0
|
||||
|
||||
- name: Build all release binaries
|
||||
run: cargo build --release --locked
|
||||
|
||||
- name: Cache cargo-deb
|
||||
id: cache-cargo-deb
|
||||
uses: actions/cache@v4
|
||||
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 (release binaries)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo install --locked cargo-deb
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
cargo-deb -p pwsp-gui --no-build
|
||||
|
||||
cargo-deb
|
||||
- name: Upload deb package
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: deb-package-${{ matrix.arch }}
|
||||
path: target/debian/*.deb
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload .deb(s) to release
|
||||
publish-release:
|
||||
needs: [prepare, linux-release, deb-release]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download zip archive (x64)
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: zip-archive-x64
|
||||
path: ./dist
|
||||
|
||||
- name: Download zip archive (arm64)
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: zip-archive-arm64
|
||||
path: ./dist
|
||||
|
||||
- name: Download deb package (x64)
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: deb-package-x64
|
||||
path: ./dist
|
||||
|
||||
- name: Download deb package (arm64)
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: deb-package-arm64
|
||||
path: ./dist
|
||||
|
||||
- name: Upload artifacts to Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
tag_name: ${{ needs.prepare.outputs.tag }}
|
||||
files: |
|
||||
target/debian/*.deb
|
||||
./dist/pwsp-*.zip
|
||||
./dist/*.deb
|
||||
|
||||
flatpak-release:
|
||||
needs: prepare
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-25.08
|
||||
options: --privileged
|
||||
- name: Install copr-cli
|
||||
run: pip install copr-cli
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.tag }}
|
||||
|
||||
- name: Build Flatpak
|
||||
uses: flatpak/flatpak-github-actions/flatpak-builder@v6
|
||||
with:
|
||||
bundle: ru.arabianq.pwsp.flatpak
|
||||
manifest-path: packages/flatpak/ru.arabianq.pwsp.yaml
|
||||
cache: true
|
||||
branch: master
|
||||
build-bundle: true
|
||||
- name: Trigger Copr Build
|
||||
env:
|
||||
COPR_CONFIG: ${{ secrets.COPR_CONFIG }}
|
||||
run: |
|
||||
mkdir -p ~/.config
|
||||
echo "$COPR_CONFIG" > ~/.config/copr
|
||||
copr-cli buildscm --clone-url https://github.com/arabianq/pipewire-soundpad.git \
|
||||
--commit ${{ needs.prepare.outputs.tag }} \
|
||||
--spec packages/rpm/pwsp.spec \
|
||||
arabianq/pwsp
|
||||
|
||||
- name: Upload Flatpak to release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
tag_name: ${{ needs.prepare.outputs.tag }}
|
||||
files: ru.arabianq.pwsp.flatpak
|
||||
|
||||
Generated
+1348
-374
File diff suppressed because it is too large
Load Diff
+37
-59
@@ -1,18 +1,29 @@
|
||||
[package]
|
||||
name = "pwsp"
|
||||
version = "1.7.2"
|
||||
[workspace]
|
||||
members = [
|
||||
"pwsp-lib",
|
||||
"pwsp-daemon",
|
||||
"pwsp-cli",
|
||||
"pwsp-gui"
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "1.12.0"
|
||||
edition = "2024"
|
||||
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"
|
||||
repository = "https://github.com/arabianq/pipewire-soundpad"
|
||||
license = "MIT"
|
||||
description = "PWSP lets you play audio files through your microphone. Has both CLI and GUI clients."
|
||||
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.1", features = ["full"] }
|
||||
tokio = { version = "1.52.3", features = ["full"] }
|
||||
async-trait = "0.1.89"
|
||||
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
@@ -26,44 +37,44 @@ clap = { version = "4.6.1", default-features = false, features = [
|
||||
"error-context",
|
||||
"derive",
|
||||
] }
|
||||
|
||||
dirs = "6.0.0"
|
||||
itertools = "0.14.0"
|
||||
|
||||
rodio = { version = "0.22.2", default-features = false, features = [
|
||||
"symphonia-all",
|
||||
"playback",
|
||||
] }
|
||||
pipewire = "0.9.2"
|
||||
evdev = { version = "0.13.2", features = ["tokio"] }
|
||||
rfd = { version = "0.17.2", default-features = false, features = [
|
||||
"xdg-portal",
|
||||
] }
|
||||
opener = { version = "0.8.4", features = ["reveal"] }
|
||||
system-fonts = "0.1.1"
|
||||
anyhow = "1.0.102"
|
||||
rustix = { version = "1.1.4", features = ["process"] }
|
||||
|
||||
egui = { version = "0.34.1", default-features = false, features = [
|
||||
rust-i18n = "4.0.0"
|
||||
sys-locale = "0.3.2"
|
||||
|
||||
rodio = { git = "https://github.com/arabianq/rodio.git", rev = "c6a81b5a46e00a6a682c0c431dff62e86f57d819", default-features = false, features = [
|
||||
"symphonia-all",
|
||||
"symphonia-libopus",
|
||||
"playback",
|
||||
] }
|
||||
pipewire = "0.10.0"
|
||||
|
||||
egui = { version = "0.34.2", default-features = false, features = [
|
||||
"default_fonts",
|
||||
"rayon",
|
||||
] }
|
||||
eframe = { version = "0.34.1", default-features = false, features = [
|
||||
eframe = { version = "0.34.2", default-features = false, features = [
|
||||
"default_fonts",
|
||||
"glow",
|
||||
"x11",
|
||||
"wayland",
|
||||
] }
|
||||
egui_extras = "0.34.1"
|
||||
egui_material_icons = "0.6.0"
|
||||
egui_dnd = "0.15.0"
|
||||
|
||||
[[bin]]
|
||||
name = "pwsp-daemon"
|
||||
path = "src/bin/daemon.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "pwsp-cli"
|
||||
path = "src/bin/cli.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "pwsp-gui"
|
||||
path = "src/main.rs"
|
||||
reqwest = "0.13.4"
|
||||
percent-encoding = "2.3.2"
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
@@ -72,36 +83,3 @@ codegen-units = 1
|
||||
opt-level = "z"
|
||||
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
|
||||
|
||||
Copyright (c) 2025 Tarasov Alexander
|
||||
Copyright (c) 2026 arabianq
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,215 +1,118 @@
|
||||
# **🎵 Pipewire Soundpad (PWSP)**
|
||||
<div align="center">
|
||||
<h1>🎵 PipeWire Soundpad (PWSP)</h1>
|
||||
<p><b>A simple, modern, and powerful soundboard for Linux, written in Rust.</b></p>
|
||||
<img src="pwsp-gui/assets/screenshot.png" alt="PWSP Screenshot" width="700"/>
|
||||
</div>
|
||||
|
||||
**PipeWire Soundpad (PWSP)** is a simple yet powerful **soundboard application** written in **Rust**. It provides a
|
||||
user-friendly graphical interface for **managing and playing audio files, directing their output directly to the virtual
|
||||
microphone.** This makes it an ideal tool for gamers, streamers, and anyone looking to inject sound effects into voice
|
||||
chats on platforms like **Discord, Zoom, or Teamspeak**.
|
||||
## 🌟 Overview
|
||||
**PipeWire Soundpad (PWSP)** is a graphical soundboard application that routes audio directly to your virtual microphone using **PipeWire**. It provides an intuitive interface for managing your audio collection, making it an ideal tool for gamers, streamers, and anyone looking to inject sound effects into voice chats on platforms like Discord, Zoom, or TeamSpeak.
|
||||
|
||||

|
||||
## ✨ Key Features
|
||||
* **🎙️ Virtual Microphone Output:** Seamlessly mixes your microphone input with sound effects by automatically managing PipeWire virtual devices.
|
||||
* **🎵 Multi-Format Support:** Plays popular audio formats including `mp3`, `wav`, `ogg`, `flac`, `mp4`, and `aac`.
|
||||
* **⚡ Global Hotkeys:** Trigger sounds instantly from anywhere, even when the app is running in the background.
|
||||
* **📂 Smart Collection Management:** Drag-and-drop folders, quick search, and collapsible tracks to keep your library organized.
|
||||
* **🎛️ Advanced Playback Controls:** Individual volume sliders, play/pause, position scrubbing, and concurrent multi-track playback.
|
||||
* **🔌 Plug & Play:** Automatically detects when an input device is connected or disconnected and handles linking/unlinking on the fly.
|
||||
* **🖥️ Modern GUI:** Clean, responsive, and lightweight interface powered by [egui](https://egui.rs/).
|
||||
|
||||
# **🌟 Key Features**
|
||||
## ⚙️ Architecture
|
||||
PWSP is built with a client-server model to ensure stability and separation of concerns:
|
||||
* **`pwsp-daemon`**: The background engine. It runs silently, managing PipeWire virtual devices, audio routing, and playback.
|
||||
* **`pwsp-gui`**: The graphical interface. Communicates with the daemon via a Unix socket to control playback and settings.
|
||||
* **`pwsp-cli`**: The command-line tool. Perfect for scripting, hotkey binding, or quick terminal-based control.
|
||||
|
||||
* **Multi-Format Support**: Play audio files in popular formats, including _**mp3**_, _**wav**_, _**ogg**_, _**flac**_,
|
||||
_**mp4**_, and _**aac**_.
|
||||
* **Virtual Microphone Output**: The application routes audio through a virtual device created by PipeWire, allowing
|
||||
other users to hear the sounds as if you were speaking into your microphone.
|
||||
* **Modern and Clean GUI**: The interface is built with the [egui](https://egui.rs) library, ensuring an intuitive and
|
||||
responsive user experience.
|
||||
* **Sound Collection Management**: Easily add and remove directories containing your audio files. The application scans
|
||||
these folders and displays all supported files for quick access.
|
||||
* **Quick Search**: Use the built-in search bar to instantly find any sound file within your library.
|
||||
* **Detailed Playback Controls**:
|
||||
* **Play/Pause button**.
|
||||
* **Volume slider** for individual sound adjustment.
|
||||
* **Position slider** to fast-forward or rewind the audio.
|
||||
* **Persistent Configuration**: The list of added directories and your selected audio output device are saved
|
||||
automatically, so you won't need to reconfigure them every time you launch the application.
|
||||
* **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.
|
||||
---
|
||||
|
||||
## 🚀 Installation
|
||||
|
||||
# **⚙️ How It Works**
|
||||
### 📦 Flatpak (Recommended)
|
||||
Install PWSP via Flatpak from our custom repository:
|
||||
```bash
|
||||
flatpak remote-add --user --if-not-exists pwsp-repo https://arabianq.github.io/pipewire-soundpad/index.flatpakrepo
|
||||
|
||||
PWSP is designed with a clear separation of concerns, operating through a client-server architecture. It consists of
|
||||
three main components:
|
||||
# Install stable version
|
||||
flatpak install --user arabianq-repo ru.arabianq.pwsp//stable
|
||||
|
||||
* **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**
|
||||
|
||||
## **Pre-built Packages**
|
||||
|
||||
You can download pre-built binaries and .deb packages from
|
||||
the [releases page](https://github.com/arabianq/pipewire-soundpad/releases).
|
||||
|
||||
## **Fedora Linux (and derivatives)**
|
||||
|
||||
If you're using Fedora, you can install PWSP from a dedicated repository using DNF.
|
||||
|
||||
Add the repository:
|
||||
# Or install the nightly version (latest commit)
|
||||
flatpak install --user arabianq-repo ru.arabianq.pwsp//nightly
|
||||
```
|
||||
|
||||
### 🐧 Linux Packages
|
||||
**Fedora (and derivatives):**
|
||||
```bash
|
||||
sudo dnf copr enable arabianq/pwsp
|
||||
```
|
||||
|
||||
Update cache:
|
||||
|
||||
```bash
|
||||
sudo dnf makecache
|
||||
```
|
||||
|
||||
Install PWSP:
|
||||
|
||||
```bash
|
||||
sudo dnf install pwsp
|
||||
```
|
||||
|
||||
## **Arch Linux**
|
||||
There is pwsp package in AUR.
|
||||
You can install it using yay, paru or any other AUR helper.
|
||||
**Arch Linux (AUR):**
|
||||
```bash
|
||||
paru pwsp-bin # or paru pwsp to build it locally
|
||||
paru -S pwsp-bin # or 'pwsp' to build from source
|
||||
```
|
||||
|
||||
## **Installing using cargo**
|
||||
**Debian / Ubuntu:**
|
||||
Download pre-built `.deb` packages or standalone binaries from the [Releases page](https://github.com/arabianq/pipewire-soundpad/releases).
|
||||
|
||||
### 🦀 Cargo / Source Build
|
||||
```bash
|
||||
cargo install pwsp
|
||||
```
|
||||
|
||||
## **Building from source**
|
||||
|
||||
#### **Requirements**
|
||||
|
||||
* **Rust**: Install [Rust](https://www.rust-lang.org/tools/install) (using rustup is recommended).
|
||||
* **PipeWire**: Ensure that [PipeWire](https://pipewire.org/) is installed and running on your system.
|
||||
|
||||
#### **Build Instructions**
|
||||
|
||||
Clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/arabianq/pipewire-soundpad.git
|
||||
# OR clone and build manually:
|
||||
git clone https://github.com/arabianq/pipewire-soundpad.git
|
||||
cd pipewire-soundpad
|
||||
```
|
||||
|
||||
Build the project:
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
*(Note: Requires Rust toolchain and PipeWire running on your system).*
|
||||
|
||||
Now you have three binary files inside ./target/release/:
|
||||
---
|
||||
|
||||
- **pwsp-gui**
|
||||
- **pwsp-cli**
|
||||
- **pwsp-daemon**
|
||||
## 🎮 Usage
|
||||
|
||||
# **🎮 Usage**
|
||||
|
||||
Before using pwsp-gui or pwsp-cli, you **must** first run the pwsp-daemon in the background.
|
||||
|
||||
### **Running the Daemon**
|
||||
|
||||
You can start the daemon from the terminal or enable the systemd service for automatic startup.
|
||||
|
||||
* **Manual Start:**
|
||||
### 1. Start the Daemon
|
||||
Before using the GUI or CLI, the daemon must be running in the background.
|
||||
|
||||
```bash
|
||||
/path/to/your/pwsp-daemon &
|
||||
# Recommended: Start and enable via systemd (starts on login)
|
||||
systemctl --user enable --now pwsp-daemon
|
||||
|
||||
# Manual start (if not using systemd):
|
||||
pwsp-daemon &
|
||||
```
|
||||
|
||||
* **Using systemd (recommended):**
|
||||
If you installed PWSP using prebuilt packages, the systemd service is added automatically.
|
||||
1. **Start the service:**
|
||||
```bash
|
||||
systemctl --user start pwsp-daemon
|
||||
```
|
||||
2. **Enable autostart (starts on login):**
|
||||
```bash
|
||||
systemctl --user enable --now pwsp-daemon
|
||||
```
|
||||
|
||||
### **Using the GUI**
|
||||
|
||||
1. **Add Sounds**: Click the **"+"** 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:
|
||||
### 2. Using the GUI
|
||||
1. **Add Sounds:** Click the **"+"** button to add a directory containing your audio files.
|
||||
2. **Select Mic:** Choose your physical microphone from the dropdown. PWSP will instantly create a virtual microphone combining your voice and the soundboard.
|
||||
3. **Play:** Click any sound to play it, adjust its volume, or assign a hotkey for quick access.
|
||||
|
||||
### 3. Using the CLI
|
||||
Control the daemon directly from your terminal:
|
||||
```bash
|
||||
pwsp-cli --help
|
||||
pwsp-cli action play /path/to/sound.mp3
|
||||
pwsp-cli get volume
|
||||
pwsp-cli set position 20
|
||||
pwsp-cli --help # View all commands
|
||||
```
|
||||
|
||||
* **Example Commands**:
|
||||
* **Play a file**:
|
||||
---
|
||||
|
||||
```bash
|
||||
pwsp-cli action play <file_path>
|
||||
```
|
||||
## ⌨️ Shortcuts & Controls
|
||||
|
||||
* **Get the current volume**:
|
||||
| Action | Keyboard | Mouse |
|
||||
| :----------------------------------- | :--------------------- | :------------------- |
|
||||
| **Play Track** (Stops others) | | `Left Click` |
|
||||
| **Add Track** (Plays simultaneously) | | `Ctrl + Left Click` |
|
||||
| **Replace Last Track** | | `Shift + Left Click` |
|
||||
| **Pause / Resume** | `Space` | |
|
||||
| **Stop All Tracks** | `Backspace` | |
|
||||
| **Open / Close Settings** | `I` | |
|
||||
| **Search** | `/` | |
|
||||
|
||||
```bash
|
||||
pwsp-cli get volume
|
||||
```
|
||||
---
|
||||
|
||||
* **Set playback position to 20 seconds**:
|
||||
## 🤝 Contributing
|
||||
Contributions, issues, and feature requests are welcome! Feel free to check out the [issues page](https://github.com/arabianq/pipewire-soundpad/issues).
|
||||
|
||||
```bash
|
||||
pwsp-cli set position 20
|
||||
```
|
||||
|
||||
### **Hotkeys & Controls**
|
||||
|
||||
#### **Keyboard Shortcuts**
|
||||
|
||||
| Key | Action |
|
||||
| :----------------------- | :--------------------------------------------------- |
|
||||
| **Space** | Pause / Resume audio |
|
||||
| **Backspace** | Stop all audio tracks |
|
||||
| **Enter** | Play selected file (stops all other tracks) |
|
||||
| **Ctrl + Enter** | Add selected file to playback (plays simultaneously) |
|
||||
| **Shift + Enter** | Replace the last added track with the selected one |
|
||||
| **I** | Open / Close settings |
|
||||
| **/** | Focus search field |
|
||||
| **Ctrl + ↑ / ↓** | Navigate through files |
|
||||
| **Ctrl + Shift + ↑ / ↓** | Navigate through directories |
|
||||
|
||||
#### **Mouse Controls**
|
||||
|
||||
* **Left Click**: Play track (stops all other tracks).
|
||||
* **Ctrl + Left Click**: Add track (plays simultaneously with current tracks).
|
||||
* **Shift + Left Click**: Replace the last added track with the selected one.
|
||||
|
||||
# **🤝 Contributing**
|
||||
|
||||
Contributions are welcome\! If you have ideas for improvements or find a bug, feel free to create
|
||||
an [issue](https://github.com/arabianq/pipewire-soundpad/issues) or submit
|
||||
a [pull request](https://github.com/arabianq/pipewire-soundpad/pulls).
|
||||
|
||||
# **📜 License**
|
||||
|
||||
This project is licensed under
|
||||
the [MIT License](https://github.com/arabianq/pipewire-soundpad/blob/main/LICENSE).
|
||||
|
||||
# **🤖 AI Wiki**
|
||||
[](https://deepwiki.com/arabianq/pipewire-soundpad)
|
||||
|
||||
## 📜 License
|
||||
This project is licensed under the [MIT License](LICENSE).
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 84 KiB |
+17
-14
@@ -1,17 +1,20 @@
|
||||
pkgbase = pwsp-bin
|
||||
pkgdesc = Lets you play audio files through your microphone (Pre-built binaries)
|
||||
pkgver = 1.7.2
|
||||
pkgrel = 2
|
||||
url = https://github.com/arabianq/pipewire-soundpad
|
||||
arch = x86_64
|
||||
license = MIT
|
||||
depends = pipewire
|
||||
depends = alsa-lib
|
||||
provides = pwsp
|
||||
conflicts = pwsp
|
||||
source = pwsp-bin-1.7.2.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.7.2/pwsp-v1.7.2-linux-x64.zip
|
||||
source = pipewire-soundpad-1.7.2.tar.gz :: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.7.2.tar.gz
|
||||
sha256sums = SKIP
|
||||
sha256sums = SKIP
|
||||
pkgdesc = Lets you play audio files through your microphone (Pre-built binaries)
|
||||
pkgver = 1.12.0
|
||||
pkgrel = 2
|
||||
url = https://github.com/arabianq/pipewire-soundpad
|
||||
arch = x86_64
|
||||
arch = aarch64
|
||||
license = MIT
|
||||
depends = pipewire
|
||||
depends = alsa-lib
|
||||
provides = pwsp
|
||||
conflicts = pwsp
|
||||
source = pipewire-soundpad-1.12.0.tar.gz :: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.12.0.tar.gz
|
||||
sha256sums = SKIP
|
||||
source_x86_64 = pwsp-1.12.0-x86_64.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.12.0/pwsp-v1.12.0-linux-x64.zip
|
||||
sha256sums_x86_64 = SKIP
|
||||
source_aarch64 = pwsp-1.12.0-aarch64.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.12.0/pwsp-v1.12.0-linux-arm64.zip
|
||||
sha256sums_aarch64 = SKIP
|
||||
|
||||
pkgname = pwsp-bin
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
# Maintainer: Alexander Tarasov <a.tevg@ya.ru>
|
||||
pkgname=pwsp-bin
|
||||
_pkgname=pipewire-soundpad
|
||||
pkgver=1.7.2
|
||||
pkgver=1.12.0
|
||||
pkgrel=2
|
||||
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"
|
||||
license=('MIT')
|
||||
depends=('pipewire' 'alsa-lib')
|
||||
provides=('pwsp')
|
||||
conflicts=('pwsp')
|
||||
|
||||
source=("${pkgname}-${pkgver}.zip::https://github.com/arabianq/$_pkgname/releases/download/v$pkgver/pwsp-v$pkgver-linux-x64.zip"
|
||||
"${_pkgname}-${pkgver}.tar.gz::https://github.com/arabianq/$_pkgname/archive/refs/tags/v$pkgver.tar.gz")
|
||||
source_x86_64=("pwsp-${pkgver}-x86_64.zip::https://github.com/arabianq/$_pkgname/releases/download/v$pkgver/pwsp-v$pkgver-linux-x64.zip")
|
||||
source_aarch64=("pwsp-${pkgver}-aarch64.zip::https://github.com/arabianq/$_pkgname/releases/download/v$pkgver/pwsp-v$pkgver-linux-arm64.zip")
|
||||
source=("${_pkgname}-${pkgver}.tar.gz::https://github.com/arabianq/$_pkgname/archive/refs/tags/v$pkgver.tar.gz")
|
||||
|
||||
sha256sums=('SKIP'
|
||||
'SKIP')
|
||||
sha256sums=('SKIP')
|
||||
sha256sums_x86_64=('SKIP')
|
||||
sha256sums_aarch64=('SKIP')
|
||||
|
||||
package() {
|
||||
_srcsrc="${srcdir}/${_pkgname}-${pkgver}"
|
||||
@@ -24,9 +26,9 @@ package() {
|
||||
install -Dm755 "${srcdir}/pwsp-daemon" "${pkgdir}/usr/bin/pwsp-daemon"
|
||||
install -Dm755 "${srcdir}/pwsp-gui" "${pkgdir}/usr/bin/pwsp-gui"
|
||||
|
||||
install -Dm644 "$_srcsrc/assets/pwsp-gui.desktop" "${pkgdir}/usr/share/applications/pwsp-gui.desktop"
|
||||
install -Dm644 "$_srcsrc/assets/icon.png" "${pkgdir}/usr/share/icons/hicolor/256x256/apps/icon.png"
|
||||
install -Dm644 "$_srcsrc/assets/pwsp-daemon.service" "${pkgdir}/usr/lib/systemd/user/pwsp-daemon.service"
|
||||
install -Dm644 "$_srcsrc/pwsp-gui/assets/pwsp-gui.desktop" "${pkgdir}/usr/share/applications/pwsp-gui.desktop"
|
||||
install -Dm644 "$_srcsrc/pwsp-gui/assets/icon.png" "${pkgdir}/usr/share/icons/hicolor/256x256/apps/pwsp.png"
|
||||
install -Dm644 "$_srcsrc/pwsp-gui/assets/pwsp-daemon.service" "${pkgdir}/usr/lib/systemd/user/pwsp-daemon.service"
|
||||
|
||||
install -Dm644 "$_srcsrc/LICENSE" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
pkgbase = pwsp
|
||||
pkgdesc = Lets you play audio files through your microphone
|
||||
pkgver = 1.7.2
|
||||
pkgver = 1.12.0
|
||||
pkgrel = 1
|
||||
url = https://github.com/arabianq/pipewire-soundpad
|
||||
arch = any
|
||||
arch = x86_64
|
||||
arch = aarch64
|
||||
license = MIT
|
||||
makedepends = clang
|
||||
makedepends = rust
|
||||
makedepends = cargo
|
||||
makedepends = cmake
|
||||
makedepends = pipewire
|
||||
makedepends = alsa-lib
|
||||
source = https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.7.2.tar.gz
|
||||
source = https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.12.0.tar.gz
|
||||
sha256sums = SKIP
|
||||
|
||||
pkgname = pwsp
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# Maintainer: Alexander Tarasov <a.tevg@ya.ru>
|
||||
pkgsubn=pwsp
|
||||
pkgname=pwsp
|
||||
pkgver=1.7.2
|
||||
pkgver=1.12.0
|
||||
pkgrel=1
|
||||
pkgdesc="Lets you play audio files through your microphone"
|
||||
arch=('any')
|
||||
arch=('x86_64' 'aarch64')
|
||||
url="https://github.com/arabianq/pipewire-soundpad"
|
||||
license=('MIT')
|
||||
makedepends=(clang rust cargo pipewire alsa-lib)
|
||||
makedepends=(clang rust cargo cmake pipewire alsa-lib)
|
||||
source=("$url/archive/refs/tags/v$pkgver.tar.gz")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
@@ -40,8 +40,8 @@ package() {
|
||||
install -Dm755 "target/release/pwsp-daemon" "${pkgdir}/usr/bin/pwsp-daemon"
|
||||
install -Dm755 "target/release/pwsp-gui" "${pkgdir}/usr/bin/pwsp-gui"
|
||||
|
||||
install -Dm644 "assets/pwsp-gui.desktop" "${pkgdir}/usr/share/applications/pwsp-gui.desktop"
|
||||
install -Dm644 "assets/icon.png" "${pkgdir}/usr/share/icons/hicolor/256x256/apps/icon.png"
|
||||
install -Dm644 "pwsp-gui/assets/pwsp-gui.desktop" "${pkgdir}/usr/share/applications/pwsp-gui.desktop"
|
||||
install -Dm644 "pwsp-gui/assets/icon.png" "${pkgdir}/usr/share/icons/hicolor/256x256/apps/pwsp.png"
|
||||
|
||||
install -Dm644 "assets/pwsp-daemon.service" "${pkgdir}/usr/lib/systemd/user/pwsp-daemon.service"
|
||||
install -Dm644 "pwsp-gui/assets/pwsp-daemon.service" "${pkgdir}/usr/lib/systemd/user/pwsp-daemon.service"
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,21 +2,26 @@
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) == 2 and sys.argv[1].startswith("soundpad://"):
|
||||
subprocess.Popen(["pwsp-gui", sys.argv[1]])
|
||||
sys.exit(0)
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="PWSP Flatpak",
|
||||
add_help=True,
|
||||
exit_on_error=True
|
||||
prog="PWSP Flatpak", add_help=True, exit_on_error=True
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
|
||||
cli_parser = subparsers.add_parser("cli", add_help=False, prefix_chars=" ")
|
||||
cli_parser.add_argument("args", nargs=argparse.REMAINDER, help="Arguments for pwsp-cli")
|
||||
cli_parser.add_argument(
|
||||
"args", nargs=argparse.REMAINDER, help="Arguments for pwsp-cli"
|
||||
)
|
||||
|
||||
daemon_parser = subparsers.add_parser("daemon", add_help=True)
|
||||
daemon_group = daemon_parser.add_mutually_exclusive_group(required=True)
|
||||
daemon_group.add_argument("--start", action="store_true", help="Start pwps-daemon")
|
||||
daemon_group.add_argument("--start", action="store_true", help="Start pwsp-daemon")
|
||||
daemon_group.add_argument("--kill", action="store_true", help="Kill pwsp-daemon")
|
||||
|
||||
args = parser.parse_args()
|
||||
@@ -32,4 +37,4 @@ if __name__ == "__main__":
|
||||
if args.start:
|
||||
subprocess.Popen("pwsp-daemon")
|
||||
elif args.kill:
|
||||
subprocess.Popen(["pwsp-cli", "action", "kill"])
|
||||
subprocess.Popen(["pwsp-cli", "action", "kill"])
|
||||
|
||||
@@ -5,5 +5,6 @@ Exec=pwsp-wrapper.py %u
|
||||
Icon=ru.arabianq.pwsp
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Audio;Utility;
|
||||
Categories=AudioVideo;Audio;
|
||||
Keywords=soundpad;pipewire;audio;
|
||||
MimeType=x-scheme-handler/soundpad;
|
||||
@@ -15,11 +15,17 @@
|
||||
<launchable type="desktop-id">ru.arabianq.pwsp.desktop</launchable>
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<image>
|
||||
https://raw.githubusercontent.com/arabianq/pipewire-soundpad/master/assets/screenshot.png</image>
|
||||
<image>https://raw.githubusercontent.com/arabianq/pipewire-soundpad/main/pwsp-gui/assets/screenshot.png</image>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<url type="homepage">https://pwsp.arabianq.ru</url>
|
||||
<developer_name>arabian</developer_name>
|
||||
<url type="bugtracker">https://github.com/arabianq/pipewire-soundpad/issues</url>
|
||||
<url type="vcs-browser">https://github.com/arabianq/pipewire-soundpad</url>
|
||||
<developer id="ru.arabianq">
|
||||
<name>arabian</name>
|
||||
</developer>
|
||||
<releases>
|
||||
<release version="1.12.0" date="2026-06-04" />
|
||||
</releases>
|
||||
<content_rating type="oars-1.1" />
|
||||
</component>
|
||||
@@ -14,10 +14,9 @@ finish-args:
|
||||
- --filesystem=xdg-run/pipewire-0
|
||||
- --filesystem=xdg-run/pwsp:create
|
||||
- --filesystem=xdg-run/app/ru.arabianq.pwsp:create
|
||||
- --filesystem=host
|
||||
- --filesystem=home
|
||||
- --device=all
|
||||
- --device=dri
|
||||
- --share=network
|
||||
- --talk-name=org.freedesktop.portal.Desktop
|
||||
- --talk-name=org.freedesktop.portal.Documents
|
||||
|
||||
@@ -30,18 +29,16 @@ build-options:
|
||||
modules:
|
||||
- name: pwsp
|
||||
buildsystem: simple
|
||||
build-options:
|
||||
build-args:
|
||||
- --share=network
|
||||
build-commands:
|
||||
- cargo build --release
|
||||
- export CARGO_HOME=$PWD/cargo && cargo build --release --offline
|
||||
- install -Dm755 target/release/pwsp-daemon /app/bin/pwsp-daemon
|
||||
- install -Dm755 target/release/pwsp-cli /app/bin/pwsp-cli
|
||||
- install -Dm755 target/release/pwsp-gui /app/bin/pwsp-gui
|
||||
- install -Dm755 packages/flatpak/pwsp-wrapper.py /app/bin/pwsp-wrapper.py
|
||||
- install -Dm644 assets/icon.png /app/share/icons/hicolor/256x256/apps/ru.arabianq.pwsp.png
|
||||
- install -Dm644 pwsp-gui/assets/icon.png /app/share/icons/hicolor/256x256/apps/ru.arabianq.pwsp.png
|
||||
- install -Dm644 packages/flatpak/ru.arabianq.pwsp.desktop /app/share/applications/ru.arabianq.pwsp.desktop
|
||||
- install -Dm644 packages/flatpak/ru.arabianq.pwsp.metainfo.xml /app/share/metainfo/ru.arabianq.pwsp.metainfo.xml
|
||||
sources:
|
||||
- type: dir
|
||||
path: ../../
|
||||
- cargo-sources.json
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
|
||||
# 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
|
||||
%else
|
||||
BuildRequires: alsa-lib-devel
|
||||
BuildRequires: dbus-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-gui/assets/pwsp-daemon.service %{buildroot}/usr/lib/systemd/user/pwsp-daemon.service
|
||||
|
||||
%files
|
||||
%license LICENSE
|
||||
%doc README.md
|
||||
%{_bindir}/pwsp-cli
|
||||
%{_bindir}/pwsp-daemon
|
||||
%{_bindir}/pwsp-gui
|
||||
%{_datadir}/applications/pwsp.desktop
|
||||
%{_datadir}/icons/hicolor/256x256/apps/pwsp.png
|
||||
/usr/lib/systemd/user/pwsp-daemon.service
|
||||
|
||||
%changelog
|
||||
{{{ git_dir_changelog }}}
|
||||
|
||||
+33
-6
@@ -1,10 +1,19 @@
|
||||
%bcond check 1
|
||||
|
||||
# prevent library files from being installed
|
||||
%global cargo_install_lib 0
|
||||
|
||||
# Fallback macros for systems without rpmautospec (e.g. openSUSE)
|
||||
%{!?autorelease: %global autorelease 1}
|
||||
%{!?autochangelog: %global autochangelog \
|
||||
* Tue Jun 02 2026 Arabian <arabianq@github> - %{version}-%{release}\
|
||||
- Release build}
|
||||
|
||||
|
||||
# disable debuginfo package generation (debugsourcefiles.list is empty for Rust)
|
||||
%global debug_package %{nil}
|
||||
|
||||
|
||||
Name: pwsp
|
||||
Version: 1.7.2
|
||||
Version: 1.12.0
|
||||
Release: %autorelease
|
||||
Summary: Lets you play audio files through your microphone
|
||||
|
||||
@@ -16,8 +25,21 @@ Source: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags
|
||||
BuildRequires: rust
|
||||
BuildRequires: cargo
|
||||
BuildRequires: pipewire-devel
|
||||
%if 0%{?suse_version}
|
||||
BuildRequires: alsa-devel
|
||||
BuildRequires: dbus-1-devel
|
||||
%else
|
||||
BuildRequires: alsa-lib-devel
|
||||
BuildRequires: dbus-devel
|
||||
%endif
|
||||
BuildRequires: clang-devel
|
||||
BuildRequires: cmake
|
||||
BuildRequires: pkgconfig
|
||||
%if 0%{?suse_version} && 0%{?suse_version} <= 1500
|
||||
BuildRequires: gcc13-c++
|
||||
%endif
|
||||
|
||||
|
||||
|
||||
%global _description %{expand:
|
||||
PWSP lets you play audio files through your microphone. Has both CLI and
|
||||
@@ -29,17 +51,22 @@ GUI clients.}
|
||||
%autosetup -n pipewire-soundpad-%{version} -p1
|
||||
|
||||
%build
|
||||
%if 0%{?suse_version} && 0%{?suse_version} <= 1500
|
||||
export CC=gcc-13
|
||||
export CXX=g++-13
|
||||
%endif
|
||||
cargo build --release --locked
|
||||
|
||||
|
||||
%install
|
||||
install -Dm755 target/release/pwsp-cli %{buildroot}%{_bindir}/pwsp-cli
|
||||
install -Dm755 target/release/pwsp-daemon %{buildroot}%{_bindir}/pwsp-daemon
|
||||
install -Dm755 target/release/pwsp-gui %{buildroot}%{_bindir}/pwsp-gui
|
||||
|
||||
install -Dm644 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/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 assets/pwsp-daemon.service %{buildroot}/usr/lib/systemd/user/pwsp-daemon.service
|
||||
install -Dm644 pwsp-gui/assets/pwsp-daemon.service %{buildroot}/usr/lib/systemd/user/pwsp-daemon.service
|
||||
|
||||
%files
|
||||
%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 pwsp::{
|
||||
use pwsp_lib::{
|
||||
types::socket::Request,
|
||||
utils::daemon::{make_request, wait_for_daemon},
|
||||
};
|
||||
use std::{error::Error, path::PathBuf};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version, about, long_about = None)]
|
||||
@@ -146,7 +147,7 @@ enum SetCommands {
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn Error>> {
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
wait_for_daemon().await?;
|
||||
@@ -204,9 +205,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
||||
},
|
||||
};
|
||||
|
||||
let response = make_request(request)
|
||||
.await
|
||||
.map_err(|e| e as Box<dyn Error>)?;
|
||||
let response = make_request(request).await.map_err(|e| anyhow!(e))?;
|
||||
println!("{} : {}", response.status, response.message);
|
||||
|
||||
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,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,69 @@
|
||||
[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
|
||||
|
||||
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]
|
||||
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",
|
||||
],
|
||||
]
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
@@ -1,8 +1,9 @@
|
||||
[Desktop Entry]
|
||||
Name=PWSP (Soundpad)
|
||||
Comment=Let's you play audio files through you microphone
|
||||
Exec=pwsp-gui %u
|
||||
Exec=/usr/bin/pwsp-gui %u
|
||||
Icon=pwsp
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Audio
|
||||
Categories=Audio
|
||||
MimeType=x-scheme-handler/soundpad;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
@@ -0,0 +1,486 @@
|
||||
_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_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.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"
|
||||
@@ -1,15 +1,16 @@
|
||||
use crate::gui::SoundpadGui;
|
||||
use egui::{Context, Id, Key, Modifiers};
|
||||
use pwsp::types::socket::Request;
|
||||
use pwsp::utils::gui::make_request_async;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use pwsp_lib::types::socket::Request;
|
||||
use pwsp_lib::utils::gui::make_request_async;
|
||||
|
||||
/// Convert an egui Key + Modifiers to a normalized chord string like "Ctrl+Shift+A".
|
||||
fn chord_from_event(modifiers: &Modifiers, key: &Key) -> Option<String> {
|
||||
let key_name = key.name();
|
||||
let is_valid = (key_name.len() == 1
|
||||
&& key_name.chars().next().unwrap().is_ascii_alphanumeric())
|
||||
&& key_name
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|c| c.is_ascii_alphanumeric()))
|
||||
|| (key_name.starts_with('F')
|
||||
&& key_name.len() > 1
|
||||
&& key_name[1..].chars().all(|c| c.is_ascii_digit()));
|
||||
@@ -60,7 +61,10 @@ pub fn parse_chord(chord: &str) -> Option<(Modifiers, Key)> {
|
||||
|
||||
let key_name = parts[parts.len() - 1];
|
||||
let is_valid = (key_name.len() == 1
|
||||
&& key_name.chars().next().unwrap().is_ascii_alphanumeric())
|
||||
&& key_name
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|c| c.is_ascii_alphanumeric()))
|
||||
|| (key_name.starts_with('F')
|
||||
&& key_name.len() > 1
|
||||
&& key_name[1..].chars().all(|c| c.is_ascii_digit()));
|
||||
@@ -88,7 +92,7 @@ impl SoundpadGui {
|
||||
}
|
||||
|
||||
pub fn handle_input(&mut self, ctx: &Context) {
|
||||
let modifiers = self.modifiers(ctx);
|
||||
let _modifiers = self.modifiers(ctx);
|
||||
let search_focused = {
|
||||
if let Some(focused_id) = self.get_focused(ctx)
|
||||
&& let Some(search_id) = self.app_state.search_field_id
|
||||
@@ -191,74 +195,6 @@ impl SoundpadGui {
|
||||
}
|
||||
}
|
||||
|
||||
// Play selected file on Enter
|
||||
if self.key_pressed(ctx, Key::Enter) {
|
||||
if 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.iter().cloned().collect();
|
||||
dirs.sort();
|
||||
|
||||
let current_dir_index = self
|
||||
.app_state
|
||||
.current_dir
|
||||
.as_ref()
|
||||
.and_then(|cd| dirs.iter().position(|x| x == cd));
|
||||
|
||||
let new_dir_index =
|
||||
match (current_dir_index, arrow_up_pressed, arrow_down_pressed) {
|
||||
(Some(i), true, false) => (i + dirs.len() - 1) % dirs.len(),
|
||||
(Some(i), false, true) => (i + 1) % dirs.len(),
|
||||
(Some(i), true, true) => i,
|
||||
(None, true, _) => dirs.len() - 1,
|
||||
(None, false, true) => 0,
|
||||
_ => return,
|
||||
};
|
||||
|
||||
self.open_dir(&dirs[new_dir_index]);
|
||||
} else if self.app_state.current_dir.is_some() {
|
||||
let files = self.get_filtered_files();
|
||||
|
||||
if files.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let current_files_index = self
|
||||
.app_state
|
||||
.selected_file
|
||||
.as_ref()
|
||||
.and_then(|f| files.iter().position(|x| x == f));
|
||||
|
||||
let new_files_index =
|
||||
match (current_files_index, arrow_up_pressed, arrow_down_pressed) {
|
||||
(Some(i), true, false) => (i + files.len() - 1) % files.len(),
|
||||
(Some(i), false, true) => (i + 1) % files.len(),
|
||||
(Some(i), true, true) => i,
|
||||
(None, true, _) => files.len() - 1,
|
||||
(None, false, true) => 0,
|
||||
_ => return,
|
||||
};
|
||||
|
||||
self.app_state.selected_file = Some(files[new_files_index].clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Check for hotkey chord triggers
|
||||
let slots_to_play: Vec<String> = ctx.input(|i| {
|
||||
let mut result = vec![];
|
||||
@@ -281,3 +217,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());
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
mod draw;
|
||||
mod input;
|
||||
mod update;
|
||||
mod views;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use eframe::{HardwareAcceleration, NativeOptions, icon_data::from_png_bytes, run_native};
|
||||
use egui::{Context, Vec2, ViewportBuilder};
|
||||
use egui::{Context, FontData, FontDefinitions, FontFamily, FontTweak, Vec2, ViewportBuilder};
|
||||
use itertools::Itertools;
|
||||
use pwsp::{
|
||||
use pwsp_lib::{
|
||||
types::{
|
||||
audio_player::PlayerState,
|
||||
config::GuiConfig,
|
||||
@@ -20,13 +21,15 @@ use pwsp::{
|
||||
};
|
||||
use rfd::FileDialog;
|
||||
use std::{
|
||||
error::Error,
|
||||
path::PathBuf,
|
||||
cmp::Ordering,
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use system_fonts::{FontStyle, FoundFontSource, find_for_locale};
|
||||
|
||||
const SUPPORTED_EXTENSIONS: [&str; 12] = [
|
||||
"mp3", "wav", "ogg", "flac", "mp4", "m4a", "aac", "mov", "mkv", "mka", "webm", "avi",
|
||||
const SUPPORTED_EXTENSIONS: [&str; 13] = [
|
||||
"mp3", "wav", "ogg", "flac", "mp4", "m4a", "aac", "mov", "mkv", "mka", "webm", "avi", "opus",
|
||||
];
|
||||
|
||||
struct SoundpadGui {
|
||||
@@ -108,19 +111,19 @@ impl SoundpadGui {
|
||||
self.app_state.current_dir = Some(path.clone());
|
||||
match path.read_dir() {
|
||||
Ok(read_dir) => {
|
||||
self.app_state.files = read_dir
|
||||
self.app_state.listed_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();
|
||||
self.app_state.listed_files.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn play_file(&mut self, path: &PathBuf, concurrent: bool) {
|
||||
pub fn play_file(&mut self, path: &Path, concurrent: bool) {
|
||||
make_request_async(Request::play(&path.to_string_lossy(), concurrent));
|
||||
}
|
||||
|
||||
@@ -155,8 +158,25 @@ impl SoundpadGui {
|
||||
}
|
||||
|
||||
pub fn get_filtered_files(&self) -> Vec<PathBuf> {
|
||||
let mut files: Vec<PathBuf> = self.app_state.files.iter().cloned().collect();
|
||||
files.sort();
|
||||
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 search_query = self.app_state.search_query.to_lowercase();
|
||||
let search_query = search_query.trim();
|
||||
@@ -165,7 +185,7 @@ impl SoundpadGui {
|
||||
.into_iter()
|
||||
.filter(|entry_path| {
|
||||
if entry_path.is_dir() {
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if !SUPPORTED_EXTENSIONS.contains(
|
||||
@@ -196,7 +216,52 @@ impl SoundpadGui {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run() -> Result<(), Box<dyn Error>> {
|
||||
fn add_font(font_name: &str, font_bytes: &[u8], fonts: &mut FontDefinitions) -> Result<()> {
|
||||
let font_data = FontData::from_owned(font_bytes.to_vec()).tweak(FontTweak {
|
||||
scale: 1.0,
|
||||
hinting_override: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
fonts
|
||||
.font_data
|
||||
.insert(font_name.to_owned(), font_data.into());
|
||||
|
||||
fonts
|
||||
.families
|
||||
.entry(FontFamily::Proportional)
|
||||
.or_default()
|
||||
.insert(0, font_name.to_owned());
|
||||
fonts
|
||||
.families
|
||||
.entry(FontFamily::Monospace)
|
||||
.or_default()
|
||||
.insert(0, font_name.to_owned());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_system_fonts(fonts: &mut FontDefinitions) -> Result<()> {
|
||||
let (_, en_sans) = find_for_locale("en", FontStyle::Sans);
|
||||
let (_, en_serif) = find_for_locale("en", FontStyle::Serif);
|
||||
let (_, ja_sans) = find_for_locale("ja", FontStyle::Sans);
|
||||
let (_, ar_sans) = find_for_locale("ar", FontStyle::Sans);
|
||||
|
||||
let system_fonts = [en_sans, en_serif, ja_sans, ar_sans].concat();
|
||||
|
||||
for font in system_fonts.iter().rev() {
|
||||
let font_bytes = match &font.source {
|
||||
FoundFontSource::Path(path) => fs::read(path)?,
|
||||
FoundFontSource::Bytes(bytes) => bytes.to_vec(),
|
||||
};
|
||||
|
||||
add_font(&font.key, &font_bytes, fonts)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn run() -> Result<()> {
|
||||
const ICON: &[u8] = include_bytes!("../../assets/icon.png");
|
||||
|
||||
let options = NativeOptions {
|
||||
@@ -218,6 +283,11 @@ pub async fn run() -> Result<(), Box<dyn Error>> {
|
||||
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)))
|
||||
}),
|
||||
) {
|
||||
@@ -228,6 +298,62 @@ pub async fn run() -> Result<(), Box<dyn Error>> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
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 filtered = gui.get_filtered_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();
|
||||
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();
|
||||
assert_eq!(filtered_desc.len(), 2);
|
||||
assert_eq!(filtered_desc[0], file_c);
|
||||
assert_eq!(filtered_desc[1], file_b);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,31 @@
|
||||
use crate::gui::SoundpadGui;
|
||||
use eframe::{App, Frame as EFrame};
|
||||
use egui::{CentralPanel, Context};
|
||||
use pwsp::{
|
||||
types::socket::Request,
|
||||
use egui::{CentralPanel, Context, ThemePreference};
|
||||
use pwsp_lib::{
|
||||
types::{config::PreferredTheme, 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) {
|
||||
// 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);
|
||||
@@ -16,7 +33,7 @@ impl App for SoundpadGui {
|
||||
&& current_dir == &path
|
||||
{
|
||||
self.app_state.current_dir = None;
|
||||
self.app_state.files.clear();
|
||||
self.app_state.listed_files.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
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, 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
self.app_state.dirs = dirs;
|
||||
|
||||
if let Some(path) = dir_to_open {
|
||||
self.open_dir(&path);
|
||||
}
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
let add_dirs_button = Button::new(ICON_ADD).frame(false);
|
||||
let add_dirs_button_response = ui.add_sized([18.0, 18.0], add_dirs_button);
|
||||
if add_dirs_button_response.clicked() {
|
||||
self.add_dirs();
|
||||
}
|
||||
});
|
||||
|
||||
ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
|
||||
let play_file_button = Button::new(t!("gui.play_file_button"));
|
||||
let play_file_button_response = ui.add(play_file_button);
|
||||
if play_file_button_response.clicked() {
|
||||
self.open_file();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn draw_files_search_field(&mut self, ui: &mut Ui) {
|
||||
ui.horizontal(|ui| {
|
||||
let search_field_response = ui.add_sized(
|
||||
[ui.available_width(), 22.0],
|
||||
TextEdit::singleline(&mut self.app_state.search_query)
|
||||
.hint_text(t!("gui.search_placeholder")),
|
||||
);
|
||||
|
||||
if self.app_state.force_focus_search {
|
||||
search_field_response.request_focus();
|
||||
self.app_state.force_focus_search = false;
|
||||
}
|
||||
|
||||
self.app_state.search_field_id = Some(search_field_response.id);
|
||||
});
|
||||
}
|
||||
|
||||
fn draw_files_list(&mut self, ui: &mut Ui, area_size: Vec2) {
|
||||
ScrollArea::vertical().id_salt(1).show(ui, |ui| {
|
||||
ui.set_min_width(area_size.x);
|
||||
ui.set_min_height(area_size.y);
|
||||
|
||||
ui.vertical(|ui| {
|
||||
let mut actions = Vec::new();
|
||||
let files = self.get_filtered_files();
|
||||
for entry_path in files {
|
||||
Self::draw_tree_node(
|
||||
ui,
|
||||
entry_path,
|
||||
&self.config,
|
||||
&mut self.app_state,
|
||||
&self.audio_player_state,
|
||||
&mut actions,
|
||||
);
|
||||
}
|
||||
|
||||
for action in actions {
|
||||
match action {
|
||||
FileAction::Play(path, concurrent) => self.play_file(&path, concurrent),
|
||||
FileAction::StopAndPlay(id, path, concurrent) => {
|
||||
self.stop(Some(id));
|
||||
self.play_file(&path, concurrent);
|
||||
}
|
||||
FileAction::AssignHotkey(path) => {
|
||||
self.app_state.assigning_hotkey_for_file = Some(path);
|
||||
self.app_state.hotkey_capture_active = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn draw_files(&mut self, ui: &mut Ui, area_size: Vec2) {
|
||||
ui.vertical(|ui| {
|
||||
self.draw_files_search_field(ui);
|
||||
ui.separator();
|
||||
self.draw_files_list(ui, area_size);
|
||||
});
|
||||
}
|
||||
|
||||
fn draw_tree_node_dir(
|
||||
ui: &mut Ui,
|
||||
path: std::path::PathBuf,
|
||||
config: &GuiConfig,
|
||||
app_state: &mut AppState,
|
||||
audio_player_state: &AudioPlayerState,
|
||||
actions: &mut Vec<FileAction>,
|
||||
) {
|
||||
let dir_name = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
CollapsingHeader::new(dir_name)
|
||||
.id_salt(&path)
|
||||
.show(ui, |ui| {
|
||||
let children = if let Some(cached) = app_state.dir_cache.get(&path) {
|
||||
cached.clone()
|
||||
} else {
|
||||
let mut read = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir(&path) {
|
||||
for entry in entries.filter_map(|e| e.ok()) {
|
||||
read.push(entry.path());
|
||||
}
|
||||
}
|
||||
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
|
||||
};
|
||||
|
||||
let search_query = app_state.search_query.to_lowercase();
|
||||
let search_query = search_query.trim();
|
||||
|
||||
for child in children {
|
||||
if !child.is_dir() {
|
||||
if !crate::gui::SUPPORTED_EXTENSIONS.contains(
|
||||
&child
|
||||
.extension()
|
||||
.unwrap_or_default()
|
||||
.to_str()
|
||||
.unwrap_or_default(),
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if !search_query.is_empty() {
|
||||
let file_name = child
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
if !file_name.to_lowercase().contains(search_query) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::draw_tree_node(ui, child, config, app_state, audio_player_state, actions);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn draw_tree_node_file(
|
||||
ui: &mut Ui,
|
||||
path: std::path::PathBuf,
|
||||
app_state: &mut AppState,
|
||||
audio_player_state: &AudioPlayerState,
|
||||
actions: &mut Vec<FileAction>,
|
||||
) {
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
// Hotkey badge
|
||||
let mut hotkey_badge = None;
|
||||
for slot in &app_state.hotkey_config.slots {
|
||||
if slot.action.name == "play"
|
||||
&& let Some(file_path_str) = slot.action.args.get("file_path")
|
||||
&& Path::new(file_path_str) == path
|
||||
{
|
||||
if let Some(chord) = &slot.key_chord {
|
||||
hotkey_badge = Some(format!("[{}]", chord));
|
||||
} else {
|
||||
hotkey_badge = Some(format!("[{}]", slot.slot));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(badge) = &hotkey_badge {
|
||||
ui.label(
|
||||
RichText::new(badge)
|
||||
.small()
|
||||
.monospace()
|
||||
.color(Color32::from_rgb(100, 200, 100)),
|
||||
);
|
||||
}
|
||||
|
||||
let file_button_text = RichText::new(&file_name);
|
||||
|
||||
let file_button = Button::new(file_button_text).frame(false).truncate();
|
||||
let file_button_response = ui.add(file_button);
|
||||
if file_button_response.clicked() {
|
||||
ui.input(|i| {
|
||||
if i.modifiers.ctrl {
|
||||
actions.push(FileAction::Play(path.clone(), true));
|
||||
} else if i.modifiers.shift
|
||||
&& let Some(last_track) = audio_player_state.tracks.last()
|
||||
{
|
||||
actions.push(FileAction::StopAndPlay(last_track.id, path.clone(), true));
|
||||
} else {
|
||||
actions.push(FileAction::Play(path.clone(), false));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Context menu
|
||||
file_button_response.context_menu(|ui| {
|
||||
if ui
|
||||
.button(format!(
|
||||
"{} {}",
|
||||
ICON_BOLT.codepoint,
|
||||
t!("gui.context.files.play_solo")
|
||||
))
|
||||
.clicked()
|
||||
{
|
||||
actions.push(FileAction::Play(path.clone(), false));
|
||||
}
|
||||
|
||||
if ui
|
||||
.button(format!(
|
||||
"{} {}",
|
||||
ICON_ADD.codepoint,
|
||||
t!("gui.context.files.add_new")
|
||||
))
|
||||
.clicked()
|
||||
{
|
||||
actions.push(FileAction::Play(path.clone(), true));
|
||||
}
|
||||
|
||||
if ui
|
||||
.button(format!(
|
||||
"{} {}",
|
||||
ICON_SWAP_HORIZ.codepoint,
|
||||
t!("gui.context.files.replace_last")
|
||||
))
|
||||
.clicked()
|
||||
&& let Some(last_track) = audio_player_state.tracks.last()
|
||||
{
|
||||
actions.push(FileAction::StopAndPlay(last_track.id, path.clone(), true));
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
if ui
|
||||
.button(format!(
|
||||
"{} {}",
|
||||
ICON_OPEN_IN_BROWSER.codepoint,
|
||||
t!("gui.context.files.show_in_fm")
|
||||
))
|
||||
.clicked()
|
||||
&& let Err(e) = opener::reveal(&path)
|
||||
{
|
||||
eprintln!("Failed to open file manager: {}", e);
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
if ui
|
||||
.button(format!(
|
||||
"{} {}",
|
||||
ICON_KEYBOARD.codepoint,
|
||||
t!("gui.context.files.asign_hotkey")
|
||||
))
|
||||
.clicked()
|
||||
{
|
||||
actions.push(FileAction::AssignHotkey(path.clone()));
|
||||
ui.close();
|
||||
}
|
||||
|
||||
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('"', "\\\"")
|
||||
));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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>,
|
||||
) {
|
||||
if path.is_dir() {
|
||||
Self::draw_tree_node_dir(ui, path, config, app_state, audio_player_state, actions);
|
||||
} else {
|
||||
Self::draw_tree_node_file(ui, path, app_state, audio_player_state, actions);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
use crate::gui::SoundpadGui;
|
||||
use egui::{AtomExt, Button, ComboBox, Label, RichText, Slider, Ui, Vec2};
|
||||
use egui_material_icons::icons::*;
|
||||
use rust_i18n::t;
|
||||
use std::time::Instant;
|
||||
|
||||
impl SoundpadGui {
|
||||
pub fn draw_footer(&mut self, ui: &mut Ui) {
|
||||
ui.add_space(5.0);
|
||||
ui.horizontal(|ui| {
|
||||
self.draw_mic_selection(ui);
|
||||
self.draw_master_volume(ui);
|
||||
|
||||
ui.add_space(ui.available_width() - 18.0 * 2.0 - ui.spacing().item_spacing.x * 2.0);
|
||||
|
||||
self.draw_hotkeys_button(ui);
|
||||
self.draw_settings_button(ui);
|
||||
});
|
||||
}
|
||||
|
||||
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_master_volume(&mut self, ui: &mut Ui) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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([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)");
|
||||
}
|
||||
|
||||
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([18.0, 18.0], settings_button);
|
||||
if settings_button_response.clicked() {
|
||||
self.app_state.show_settings = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
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;
|
||||
use std::time::Instant;
|
||||
|
||||
pub(crate) enum TrackAction {
|
||||
Pause(u32),
|
||||
Resume(u32),
|
||||
ToggleLoop(u32),
|
||||
Stop(u32),
|
||||
}
|
||||
|
||||
impl SoundpadGui {
|
||||
pub fn draw_header(&mut self, ui: &mut Ui) {
|
||||
ui.vertical_centered_justified(|ui| {
|
||||
if self.audio_player_state.tracks.is_empty() {
|
||||
ui.label("No tracks playing");
|
||||
return;
|
||||
}
|
||||
|
||||
let mut action = None;
|
||||
|
||||
for track in &self.audio_player_state.tracks {
|
||||
CollapsingHeader::new(
|
||||
RichText::new(
|
||||
track
|
||||
.path
|
||||
.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_str()
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.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_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_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_slider_value, 0.0..=1.0)
|
||||
.show_value(false)
|
||||
.step_by(0.01);
|
||||
|
||||
ui.spacing_mut().slider_width = default_slider_width - 30.0;
|
||||
ui.spacing_mut().item_spacing.x = 0.0;
|
||||
|
||||
if ui.add_sized([30.0, 30.0], volume_slider).drag_stopped() {
|
||||
ui_state.volume_dragged = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_stop_control(ui: &mut Ui, track: &TrackInfo) -> Option<TrackAction> {
|
||||
let stop_button = Button::new(ICON_CLOSE).frame(false);
|
||||
if ui.add_sized([30.0, 30.0], stop_button).clicked() {
|
||||
Some(TrackAction::Stop(track.id))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_track_control(
|
||||
ui: &mut Ui,
|
||||
app_state: &mut AppState,
|
||||
track: &TrackInfo,
|
||||
) -> Option<TrackAction> {
|
||||
let ui_state = app_state.track_ui_states.entry(track.id).or_default();
|
||||
|
||||
let should_update_position = !ui_state.position_dragged
|
||||
&& ui_state
|
||||
.ignore_position_update_until
|
||||
.map(|t| Instant::now() > t)
|
||||
.unwrap_or(true);
|
||||
|
||||
if should_update_position {
|
||||
ui_state.position_slider_value = track.position;
|
||||
}
|
||||
|
||||
let should_update_volume = !ui_state.volume_dragged
|
||||
&& ui_state
|
||||
.ignore_volume_update_until
|
||||
.map(|t| Instant::now() > t)
|
||||
.unwrap_or(true);
|
||||
|
||||
if should_update_volume {
|
||||
ui_state.volume_slider_value = track.volume;
|
||||
}
|
||||
|
||||
let mut action = None;
|
||||
|
||||
ui.horizontal_top(|ui| {
|
||||
if let Some(act) = Self::draw_playback_controls(ui, track) {
|
||||
action = Some(act);
|
||||
}
|
||||
|
||||
let default_slider_width = ui.spacing().slider_width;
|
||||
Self::draw_position_control(ui, ui_state, track, default_slider_width);
|
||||
Self::draw_volume_control(ui, ui_state, track, default_slider_width);
|
||||
|
||||
if let Some(act) = Self::draw_stop_control(ui, track) {
|
||||
action = Some(act);
|
||||
}
|
||||
});
|
||||
|
||||
action
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
use crate::gui::SoundpadGui;
|
||||
use egui::{Color32, RichText, Ui};
|
||||
use rust_i18n::t;
|
||||
|
||||
impl SoundpadGui {
|
||||
pub fn draw_hotkey_capture(&mut self, ui: &mut Ui) {
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.add_space(ui.available_height() / 3.0);
|
||||
ui.label(
|
||||
RichText::new(t!("gui.hotkeys.capture.header"))
|
||||
.size(18.0)
|
||||
.color(Color32::YELLOW)
|
||||
.monospace(),
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
let target = if let Some(slot) = &self.app_state.assigning_hotkey_slot {
|
||||
format!("{} '{}'", t!("gui.hotkeys.capture.for"), slot)
|
||||
} else if let Some(path) = &self.app_state.assigning_hotkey_for_file {
|
||||
format!(
|
||||
"{} '{}'",
|
||||
t!("gui.hotkeys.capture.for"),
|
||||
path.file_name().unwrap_or_default().to_string_lossy()
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
ui.label(RichText::new(target).size(16.0));
|
||||
ui.add_space(10.0);
|
||||
ui.label(t!("gui.hotkeys.capture.cancel"));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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,55 @@
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
use crate::gui::SoundpadGui;
|
||||
use egui::{Align, Button, Color32, ComboBox, Layout, RichText, 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_input_response =
|
||||
ui.checkbox(&mut self.config.save_input, t!("gui.settings.remember_mic"));
|
||||
let save_scale_response = ui.checkbox(
|
||||
&mut self.config.save_scale_factor,
|
||||
t!("gui.settings.remember_ui_scale"),
|
||||
);
|
||||
let pause_on_exit_response = ui.checkbox(
|
||||
&mut self.config.pause_on_exit,
|
||||
t!("gui.settings.pause_on_window_close"),
|
||||
);
|
||||
|
||||
if save_volume_response.changed()
|
||||
|| save_input_response.changed()
|
||||
|| save_scale_response.changed()
|
||||
|| pause_on_exit_response.changed()
|
||||
{
|
||||
self.config.save_to_file().ok();
|
||||
}
|
||||
// --------------------------------
|
||||
|
||||
ui.separator();
|
||||
|
||||
// ---------- Selectors -----------
|
||||
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.with_layout(Layout::bottom_up(Align::Min), |ui| {
|
||||
ui.label(t!(
|
||||
"gui.settings.version",
|
||||
version = env!("CARGO_PKG_VERSION")
|
||||
));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
use crate::gui::SoundpadGui;
|
||||
use egui::{RichText, Ui};
|
||||
|
||||
impl SoundpadGui {
|
||||
pub fn draw_waiting_for_daemon(&mut self, ui: &mut Ui) {
|
||||
ui.centered_and_justified(|ui| {
|
||||
ui.label(
|
||||
RichText::new("Waiting for PWSP daemon to start...")
|
||||
.size(34.0)
|
||||
.monospace(),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
mod gui;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use pwsp_lib::utils::gui::ensure_pwsp_audio_dir;
|
||||
use rust_i18n::i18n;
|
||||
use std::{env, path::PathBuf};
|
||||
|
||||
i18n!("locales", fallback = "en");
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let locale = sys_locale::get_locale().unwrap_or(String::from("en-US"));
|
||||
rust_i18n::set_locale(&locale);
|
||||
|
||||
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::anyhow!("URI does not containt an expected prefix: {}", prefix))?;
|
||||
|
||||
let file_name_encoded = target_url
|
||||
.split('/')
|
||||
.next_back()
|
||||
.unwrap_or("downloaded_audio.mp3");
|
||||
|
||||
let file_name = percent_encoding::percent_decode_str(file_name_encoded)
|
||||
.decode_utf8()
|
||||
.unwrap_or_else(|_| file_name_encoded.into())
|
||||
.into_owned();
|
||||
|
||||
let save_path = ensure_pwsp_audio_dir().join(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
|
||||
@@ -1,10 +1,11 @@
|
||||
use crate::{
|
||||
types::pipewire::{DeviceType, Terminate},
|
||||
types::pipewire::DeviceType,
|
||||
utils::{
|
||||
daemon::get_daemon_config,
|
||||
pipewire::{create_link, get_device, link_player_to_virtual_mic},
|
||||
pipewire::{PwTerminator, create_link, get_device, link_player_to_virtual_mic},
|
||||
},
|
||||
};
|
||||
use anyhow::{Result, anyhow};
|
||||
use rodio::{Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
@@ -57,15 +58,15 @@ pub struct AudioPlayer {
|
||||
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>>,
|
||||
input_link_sender: Option<PwTerminator>,
|
||||
player_link_sender: Option<PwTerminator>,
|
||||
pub input_device_name: Option<String>,
|
||||
|
||||
pub volume: f32, // Master volume
|
||||
}
|
||||
|
||||
impl AudioPlayer {
|
||||
pub async fn new() -> Result<Self, Box<dyn Error>> {
|
||||
pub async fn new() -> Result<Self> {
|
||||
let daemon_config = get_daemon_config();
|
||||
let default_volume = daemon_config.default_volume.unwrap_or(1.0);
|
||||
|
||||
@@ -88,13 +89,15 @@ impl AudioPlayer {
|
||||
Ok(audio_player)
|
||||
}
|
||||
|
||||
fn ensure_stream(&mut self) -> Result<&MixerDeviceSink, Box<dyn Error>> {
|
||||
fn ensure_stream(&mut self) -> Result<&MixerDeviceSink> {
|
||||
if self.stream_handle.is_none() {
|
||||
let mut sink = DeviceSinkBuilder::open_default_sink()?;
|
||||
sink.log_on_drop(false);
|
||||
self.stream_handle = Some(sink);
|
||||
}
|
||||
Ok(self.stream_handle.as_ref().unwrap())
|
||||
self.stream_handle
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("Failed to initialize stream_handle"))
|
||||
}
|
||||
|
||||
fn drop_stream(&mut self) {
|
||||
@@ -105,28 +108,20 @@ impl AudioPlayer {
|
||||
}
|
||||
|
||||
fn abort_link_thread(&mut self) {
|
||||
if let Some(sender) = &self.input_link_sender {
|
||||
if let Ok(_) = sender.send(Terminate {}) {
|
||||
println!("Sent terminate signal to input link thread");
|
||||
self.input_link_sender = None;
|
||||
} else {
|
||||
eprintln!("Failed to send terminate signal to input link thread");
|
||||
}
|
||||
if self.input_link_sender.is_some() {
|
||||
println!("Sent terminate signal to input link thread");
|
||||
self.input_link_sender = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn abort_player_link_thread(&mut self) {
|
||||
if let Some(sender) = &self.player_link_sender {
|
||||
if let Ok(_) = sender.send(Terminate {}) {
|
||||
println!("Sent terminate signal to player link thread");
|
||||
self.player_link_sender = None;
|
||||
} else {
|
||||
eprintln!("Failed to send terminate signal to player link thread");
|
||||
}
|
||||
if self.player_link_sender.is_some() {
|
||||
println!("Sent terminate signal to player link thread");
|
||||
self.player_link_sender = None;
|
||||
}
|
||||
}
|
||||
|
||||
async fn link_player(&mut self) -> Result<(), Box<dyn Error>> {
|
||||
async fn link_player(&mut self) -> Result<()> {
|
||||
if self.player_link_sender.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -140,7 +135,7 @@ impl AudioPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
async fn link_devices(&mut self) -> Result<(), Box<dyn Error>> {
|
||||
async fn link_devices(&mut self) -> Result<()> {
|
||||
self.abort_link_thread();
|
||||
|
||||
let input_device;
|
||||
@@ -184,7 +179,7 @@ impl AudioPlayer {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
self.input_link_sender = Some(create_link(output_fl, output_fr, input_fl, input_fr)?);
|
||||
self.input_link_sender = Some(create_link(output_fl, output_fr, input_fl, input_fr).await?);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -254,12 +249,12 @@ impl AudioPlayer {
|
||||
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) {
|
||||
return Some(sound.sink.volume());
|
||||
Some(sound.sink.volume())
|
||||
} else {
|
||||
return None;
|
||||
None
|
||||
}
|
||||
} else {
|
||||
return Some(self.volume);
|
||||
Some(self.volume)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,7 +284,7 @@ impl AudioPlayer {
|
||||
0.0
|
||||
}
|
||||
|
||||
pub fn seek(&mut self, position: f32, id: Option<u32>) -> Result<(), Box<dyn Error>> {
|
||||
pub fn seek(&mut self, position: f32, id: Option<u32>) -> Result<()> {
|
||||
let position = if position < 0.0 { 0.0 } else { position };
|
||||
|
||||
if let Some(id) = id {
|
||||
@@ -305,22 +300,18 @@ impl AudioPlayer {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_duration(&mut self, id: Option<u32>) -> Result<f32, Box<dyn Error>> {
|
||||
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("Unknown duration".into());
|
||||
return sound.duration.ok_or(anyhow!("Unknown duration"));
|
||||
}
|
||||
} else if let Some(sound) = self.tracks.values().last() {
|
||||
return sound.duration.ok_or("Unknown duration".into());
|
||||
return sound.duration.ok_or(anyhow!("Unknown duration"));
|
||||
}
|
||||
Err("No track playing".into())
|
||||
Err(anyhow!("No track playing"))
|
||||
}
|
||||
|
||||
pub async fn play(
|
||||
&mut self,
|
||||
file_path: &Path,
|
||||
concurrent: bool,
|
||||
) -> Result<u32, Box<dyn Error>> {
|
||||
pub async fn play(&mut self, file_path: &Path, concurrent: bool) -> Result<u32> {
|
||||
let path_buf = file_path.to_path_buf();
|
||||
|
||||
let decoder_result =
|
||||
@@ -350,7 +341,11 @@ impl AudioPlayer {
|
||||
|
||||
let duration = source.total_duration().map(|d| d.as_secs_f32());
|
||||
|
||||
let mixer = self.stream_handle.as_ref().unwrap().mixer();
|
||||
let mixer = self
|
||||
.stream_handle
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("stream_handle is unexpectedly missing"))?
|
||||
.mixer();
|
||||
let sink = Player::connect_new(mixer);
|
||||
sink.set_volume(self.volume); // Default volume is 1.0 * master
|
||||
sink.append(source);
|
||||
@@ -369,7 +364,7 @@ impl AudioPlayer {
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
Err(err) => Err(err as Box<dyn Error>),
|
||||
Err(err) => Err(anyhow!(err)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,10 +438,10 @@ impl AudioPlayer {
|
||||
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) {
|
||||
if let Ok(source) = Decoder::try_from(file) {
|
||||
return Some((id, source));
|
||||
}
|
||||
if let Ok(file) = fs::File::open(&path)
|
||||
&& let Ok(source) = Decoder::try_from(file)
|
||||
{
|
||||
return Some((id, source));
|
||||
}
|
||||
None
|
||||
});
|
||||
@@ -455,13 +450,12 @@ impl AudioPlayer {
|
||||
}
|
||||
|
||||
for handle in restart_futures {
|
||||
if let Ok(res) = handle.await {
|
||||
if let Some((id, source)) = res {
|
||||
if let Some(sound) = self.tracks.get_mut(&id) {
|
||||
sound.sink.append(source);
|
||||
sound.sink.play();
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,11 +467,11 @@ impl AudioPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn set_current_input_device(&mut self, name: &str) -> Result<(), Box<dyn Error>> {
|
||||
pub async fn set_current_input_device(&mut self, name: &str) -> Result<()> {
|
||||
let input_device = get_device(name).await?;
|
||||
|
||||
if input_device.device_type != DeviceType::Input {
|
||||
return Err("Selected device is not an input device".into());
|
||||
return Err(anyhow!("Selected device is not an input device"));
|
||||
}
|
||||
|
||||
self.input_device_name = Some(name.to_string());
|
||||
@@ -673,7 +673,7 @@ impl Executable for PlayHotkeyCommand {
|
||||
if let Some(cmd) = parse_command(&action) {
|
||||
cmd.execute().await
|
||||
} else {
|
||||
Response::new(false, "Unknown command in hotkey slot".to_string())
|
||||
Response::new(false, "Unknown command in hotkey slot")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
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_volume: 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_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,
|
||||
}
|
||||
|
||||
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_input: false,
|
||||
save_scale_factor: false,
|
||||
pause_on_exit: false,
|
||||
|
||||
dirs: vec![ensure_pwsp_audio_dir()],
|
||||
|
||||
preferred_theme: PreferredTheme::System,
|
||||
dirs_settings: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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")));
|
||||
}
|
||||
}
|
||||
@@ -43,15 +43,18 @@ pub struct AppState {
|
||||
pub dirs: Vec<PathBuf>,
|
||||
pub dirs_to_remove: HashSet<PathBuf>,
|
||||
|
||||
pub selected_file: Option<PathBuf>,
|
||||
pub files: HashSet<PathBuf>,
|
||||
pub listed_files: HashSet<PathBuf>,
|
||||
pub listed_dirs: HashSet<PathBuf>,
|
||||
pub dir_cache: HashMap<PathBuf, Vec<PathBuf>>,
|
||||
|
||||
pub show_hotkeys: bool,
|
||||
pub hotkey_capture_active: bool,
|
||||
|
||||
pub hotkey_config: HotkeyConfig,
|
||||
pub hotkey_search_query: String,
|
||||
|
||||
pub assigning_hotkey_slot: Option<String>,
|
||||
pub assigning_hotkey_for_file: Option<PathBuf>,
|
||||
pub hotkey_capture_active: bool,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone)]
|
||||
@@ -0,0 +1,155 @@
|
||||
#[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>,
|
||||
}
|
||||
|
||||
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) {
|
||||
match port.name.as_str() {
|
||||
"input_FL" => self.input_fl = Some(port),
|
||||
"input_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" => {
|
||||
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_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));
|
||||
}
|
||||
}
|
||||
@@ -238,3 +238,88 @@ 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_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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -122,3 +122,88 @@ pub fn parse_command(request: &Request) -> Option<Box<dyn Executable + Send>> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::socket::Request;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn test_parse_set_volume_valid() {
|
||||
let mut args = HashMap::new();
|
||||
args.insert("volume".to_string(), "0.5".to_string());
|
||||
args.insert("id".to_string(), "1".to_string());
|
||||
let request = Request {
|
||||
name: "set_volume".to_string(),
|
||||
args,
|
||||
};
|
||||
|
||||
let cmd = parse_command(&request);
|
||||
assert!(cmd.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_set_volume_missing_volume() {
|
||||
let mut args = HashMap::new();
|
||||
args.insert("id".to_string(), "1".to_string());
|
||||
let request = Request {
|
||||
name: "set_volume".to_string(),
|
||||
args,
|
||||
};
|
||||
|
||||
let cmd = parse_command(&request);
|
||||
assert!(cmd.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_set_volume_invalid_volume() {
|
||||
let mut args = HashMap::new();
|
||||
args.insert("volume".to_string(), "not-a-float".to_string());
|
||||
let request = Request {
|
||||
name: "set_volume".to_string(),
|
||||
args,
|
||||
};
|
||||
|
||||
let cmd = parse_command(&request);
|
||||
assert!(cmd.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_set_volume_missing_id() {
|
||||
let mut args = HashMap::new();
|
||||
args.insert("volume".to_string(), "0.5".to_string());
|
||||
let request = Request {
|
||||
name: "set_volume".to_string(),
|
||||
args,
|
||||
};
|
||||
|
||||
let cmd = parse_command(&request);
|
||||
assert!(cmd.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_set_volume_invalid_id() {
|
||||
let mut args = HashMap::new();
|
||||
args.insert("id".to_string(), "not-an-int".to_string());
|
||||
args.insert("volume".to_string(), "0.5".to_string());
|
||||
let request = Request {
|
||||
name: "set_volume".to_string(),
|
||||
args,
|
||||
};
|
||||
|
||||
let cmd = parse_command(&request);
|
||||
assert!(cmd.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_set_volume_empty_args() {
|
||||
let request = Request {
|
||||
name: "set_volume".to_string(),
|
||||
args: HashMap::new(),
|
||||
};
|
||||
|
||||
let cmd = parse_command(&request);
|
||||
assert!(cmd.is_some());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::{error::Error, path::PathBuf};
|
||||
use anyhow::Result;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn get_config_path() -> Result<PathBuf, Box<dyn Error>> {
|
||||
pub fn get_config_path() -> Result<PathBuf> {
|
||||
let config_path = dirs::config_dir().expect("Failed to obtain config dir");
|
||||
Ok(config_path.join("pwsp"))
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
use crate::{
|
||||
types::{
|
||||
audio_player::AudioPlayer,
|
||||
config::DaemonConfig,
|
||||
socket::{MAX_MESSAGE_SIZE, Request, Response},
|
||||
},
|
||||
use crate::types::{
|
||||
audio_player::AudioPlayer,
|
||||
config::DaemonConfig,
|
||||
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::{error::Error, fs};
|
||||
use std::{env, error::Error, fs};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::UnixStream,
|
||||
@@ -38,29 +37,59 @@ pub fn get_daemon_config() -> DaemonConfig {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_runtime_dir() -> PathBuf {
|
||||
dirs::runtime_dir().unwrap_or(PathBuf::from("/run/pwsp"))
|
||||
fn get_current_uid() -> u32 {
|
||||
rustix::process::geteuid().as_raw()
|
||||
}
|
||||
|
||||
pub fn create_runtime_dir() -> Result<(), Box<dyn Error>> {
|
||||
pub fn get_runtime_dir() -> PathBuf {
|
||||
dirs::runtime_dir().unwrap_or_else(|| {
|
||||
let uid = get_current_uid();
|
||||
env::temp_dir().join(format!("pwsp-{}", uid))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_runtime_dir() -> Result<()> {
|
||||
let runtime_dir = get_runtime_dir();
|
||||
if !runtime_dir.exists() {
|
||||
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(())
|
||||
}
|
||||
|
||||
pub fn is_daemon_running() -> Result<bool, Box<dyn Error>> {
|
||||
let lock_file = fs::File::create(get_runtime_dir().join("daemon.lock"))?;
|
||||
pub fn is_daemon_running() -> Result<bool> {
|
||||
let lock_file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(false)
|
||||
.open(get_runtime_dir().join("daemon.lock"))?;
|
||||
match lock_file.try_lock() {
|
||||
Ok(_) => Ok(false),
|
||||
Err(_) => Ok(true),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn wait_for_daemon() -> Result<(), Box<dyn Error>> {
|
||||
pub async fn wait_for_daemon() -> Result<()> {
|
||||
if is_daemon_running()? {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,9 @@ use crate::{
|
||||
},
|
||||
utils::daemon::{is_daemon_running, make_request},
|
||||
};
|
||||
use anyhow::{Result, anyhow};
|
||||
use std::{
|
||||
error::Error,
|
||||
path::PathBuf,
|
||||
sync::{Arc, Mutex},
|
||||
time::Instant,
|
||||
};
|
||||
@@ -22,11 +23,11 @@ pub fn get_gui_config() -> GuiConfig {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn make_request_sync(request: Request) -> Result<Response, Box<dyn Error>> {
|
||||
pub fn make_request_sync(request: Request) -> Result<Response> {
|
||||
tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current()
|
||||
.block_on(make_request(request))
|
||||
.map_err(|e| e as Box<dyn Error>)
|
||||
.map_err(|e| anyhow!(e))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -36,6 +37,17 @@ pub fn make_request_async(request: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn ensure_pwsp_audio_dir() -> PathBuf {
|
||||
let audio_dir = dirs::audio_dir().unwrap_or("~/Music".into());
|
||||
let pwsp_audio_dir = audio_dir.join("PWSP");
|
||||
|
||||
if !pwsp_audio_dir.exists() {
|
||||
std::fs::create_dir_all(&pwsp_audio_dir).ok();
|
||||
}
|
||||
|
||||
pwsp_audio_dir
|
||||
}
|
||||
|
||||
pub fn format_time_pair(position: f32, duration: f32) -> String {
|
||||
fn format_time(seconds: f32) -> String {
|
||||
let total_seconds = seconds.round() as u32;
|
||||
@@ -112,13 +124,13 @@ pub fn start_app_state_thread(audio_player_state_shared: Arc<Mutex<AudioPlayerSt
|
||||
let hotkey_res = make_request(Request::get_hotkeys())
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if hotkey_res.status {
|
||||
if let Ok(config) = serde_json::from_str::<HotkeyConfig>(&hotkey_res.message) {
|
||||
let mut guard = audio_player_state_shared
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
guard.hotkey_config = Some(config);
|
||||
}
|
||||
if hotkey_res.status
|
||||
&& let Ok(config) = serde_json::from_str::<HotkeyConfig>(&hotkey_res.message)
|
||||
{
|
||||
let mut guard = audio_player_state_shared
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
guard.hotkey_config = Some(config);
|
||||
}
|
||||
last_hotkey_poll = Instant::now();
|
||||
}
|
||||
@@ -127,3 +139,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,407 @@
|
||||
use crate::types::pipewire::{AudioDevice, DeviceType, 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;
|
||||
|
||||
pub enum PwCommand {
|
||||
GetDevices {
|
||||
resp: oneshot::Sender<(Vec<AudioDevice>, Vec<AudioDevice>)>,
|
||||
},
|
||||
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>>,
|
||||
},
|
||||
DestroyObject {
|
||||
id: u32,
|
||||
},
|
||||
}
|
||||
|
||||
struct AppState {
|
||||
input_devices: HashMap<u32, AudioDevice>,
|
||||
output_devices: HashMap<u32, AudioDevice>,
|
||||
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
|
||||
let core = Box::leak(Box::new(
|
||||
context
|
||||
.connect(None)
|
||||
.expect("Failed to connect to pipewire"),
|
||||
));
|
||||
let registry = 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(),
|
||||
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 (device, port) = parse_global_object(global);
|
||||
let mut s = state_for_registry_add.borrow_mut();
|
||||
if let Some(device) = device {
|
||||
match device.device_type {
|
||||
DeviceType::Input => {
|
||||
s.input_devices.insert(device.id, device);
|
||||
}
|
||||
DeviceType::Output => {
|
||||
s.output_devices.insert(device.id, device);
|
||||
}
|
||||
}
|
||||
} else if let Some(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.clone());
|
||||
} else if let Some(d) = s.output_devices.get_mut(&node_id) {
|
||||
d.add_port(port);
|
||||
}
|
||||
}
|
||||
})
|
||||
.global_remove(move |id| {
|
||||
let mut s = state_for_registry_remove.borrow_mut();
|
||||
s.input_devices.remove(&id);
|
||||
s.output_devices.remove(&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();
|
||||
inputs.sort_by_key(|a| a.id);
|
||||
outputs.sort_by_key(|a| a.id);
|
||||
let _ = resp.send((inputs, outputs));
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
fn parse_global_object(
|
||||
global_object: &GlobalObject<&DictRef>,
|
||||
) -> (Option<AudioDevice>, Option<Port>) {
|
||||
let props = match global_object.props {
|
||||
Some(p) => p,
|
||||
None => return (None, None),
|
||||
};
|
||||
|
||||
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");
|
||||
|
||||
if media_class.starts_with("Audio/Source") {
|
||||
let input_device = AudioDevice::new(
|
||||
node_id,
|
||||
node_nick,
|
||||
node_description,
|
||||
node_name,
|
||||
DeviceType::Input,
|
||||
);
|
||||
return (Some(input_device), None);
|
||||
} else if media_class.starts_with("Stream/Output/Audio") {
|
||||
let output_device = AudioDevice::new(
|
||||
node_id,
|
||||
node_nick,
|
||||
node_description,
|
||||
node_name,
|
||||
DeviceType::Output,
|
||||
);
|
||||
return (Some(output_device), None);
|
||||
}
|
||||
return (None, None);
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
|
||||
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_device(device_name: &str) -> Result<AudioDevice> {
|
||||
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(|| anyhow!("Device not found"))
|
||||
}
|
||||
|
||||
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] })
|
||||
}
|
||||
|
||||
pub async fn link_player_to_virtual_mic() -> Result<PwTerminator> {
|
||||
let pwsp_daemon_output = match get_device("pwsp-daemon").await {
|
||||
Ok(device) => device,
|
||||
Err(_) => {
|
||||
return Err(anyhow!(
|
||||
"Could not find alsa_playback.pwsp-daemon device, skipping device linking"
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let pwsp_daemon_input = match get_device("pwsp-virtual-mic").await {
|
||||
Ok(device) => device,
|
||||
Err(_) => {
|
||||
return Err(anyhow!(
|
||||
"Could not find pwsp-virtual-mic device, skipping device linking"
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let output_fl = match pwsp_daemon_output.output_fl {
|
||||
Some(port) => port,
|
||||
None => return Err(anyhow!("Failed to get pwsp-daemon output_fl")),
|
||||
};
|
||||
let output_fr = match pwsp_daemon_output.output_fr {
|
||||
Some(port) => port,
|
||||
None => return Err(anyhow!("Failed to get pwsp-daemon output_fr")),
|
||||
};
|
||||
let input_fl = match pwsp_daemon_input.input_fl {
|
||||
Some(port) => port,
|
||||
None => return Err(anyhow!("Failed to get pwsp-virtual-mic input_fl")),
|
||||
};
|
||||
let input_fr = match pwsp_daemon_input.input_fr {
|
||||
Some(port) => port,
|
||||
None => return Err(anyhow!("Failed to get pwsp-virtual-mic input_fr")),
|
||||
};
|
||||
|
||||
create_link(output_fl, output_fr, input_fl, input_fr).await
|
||||
}
|
||||
|
||||
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
+19
@@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
if [ ! -f "Cargo.lock" ]; then
|
||||
echo "Error: Cargo.lock not found. Please run this script from the project root."
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "Downloading flatpak-cargo-generator.py..."
|
||||
curl -sLO https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/master/cargo/flatpak-cargo-generator.py
|
||||
chmod +x flatpak-cargo-generator.py
|
||||
|
||||
echo "Generating cargo-sources.json..."
|
||||
python3 flatpak-cargo-generator.py Cargo.lock -o packages/flatpak/cargo-sources.json
|
||||
|
||||
echo "Cleaning up..."
|
||||
rm flatpak-cargo-generator.py
|
||||
|
||||
echo "Successfully generated packages/flatpak/cargo-sources.json"
|
||||
Executable
+175
@@ -0,0 +1,175 @@
|
||||
#!/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.")
|
||||
|
||||
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.")
|
||||
|
||||
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) {
|
||||
if 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
-918
@@ -1,918 +0,0 @@
|
||||
use crate::gui::SoundpadGui;
|
||||
use egui::{
|
||||
Align, AtomExt, Button, CollapsingHeader, Color32, ComboBox, CursorIcon, FontFamily, Grid,
|
||||
Label, Layout, RichText, ScrollArea, Sense, Slider, TextEdit, Ui, Vec2,
|
||||
};
|
||||
use egui_dnd::dnd;
|
||||
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, PathBuf},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
enum TrackAction {
|
||||
Pause(u32),
|
||||
Resume(u32),
|
||||
ToggleLoop(u32),
|
||||
Stop(u32),
|
||||
}
|
||||
|
||||
enum HotkeyAction {
|
||||
Remove(String),
|
||||
Capture(String),
|
||||
ClearChord(String),
|
||||
Play(String),
|
||||
}
|
||||
|
||||
impl SoundpadGui {
|
||||
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) {
|
||||
let area_size = ui.available_size();
|
||||
ui.vertical(|ui| {
|
||||
ui.set_min_width(area_size.x);
|
||||
ui.set_min_height(area_size.y);
|
||||
ui.spacing_mut().item_spacing.y = 5.0;
|
||||
|
||||
// Header
|
||||
ui.horizontal_top(|ui| {
|
||||
let back_button = Button::new(ICON_ARROW_BACK).frame(false);
|
||||
if ui.add(back_button).clicked() {
|
||||
self.app_state.show_hotkeys = false;
|
||||
}
|
||||
|
||||
ui.add_space(ui.available_width() / 2.0 - 40.0);
|
||||
ui.label(RichText::new("Hotkeys").color(Color32::WHITE).monospace());
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
|
||||
// Search and Add Command
|
||||
ui.horizontal(|ui| {
|
||||
ui.menu_button(format!("{} Add Command", ICON_ADD.codepoint), |ui| {
|
||||
let mut selected_cmd = None;
|
||||
if ui.button("Toggle Pause").clicked() {
|
||||
selected_cmd = Some(("cmd_toggle_pause", Request::toggle_pause(None)));
|
||||
}
|
||||
if ui.button("Stop Playback").clicked() {
|
||||
selected_cmd = Some(("cmd_stop", Request::stop(None)));
|
||||
}
|
||||
if ui.button("Pause Playback").clicked() {
|
||||
selected_cmd = Some(("cmd_pause", Request::pause(None)));
|
||||
}
|
||||
if ui.button("Resume Playback").clicked() {
|
||||
selected_cmd = Some(("cmd_resume", Request::resume(None)));
|
||||
}
|
||||
if ui.button("Toggle Loop").clicked() {
|
||||
selected_cmd = Some(("cmd_toggle_loop", Request::toggle_loop(None)));
|
||||
}
|
||||
|
||||
if let Some((slot_name, req)) = selected_cmd {
|
||||
make_request_async(Request::set_hotkey_action(slot_name, &req));
|
||||
self.app_state
|
||||
.hotkey_config
|
||||
.set_slot(slot_name.to_string(), req);
|
||||
self.app_state.assigning_hotkey_slot = Some(slot_name.to_string());
|
||||
self.app_state.hotkey_capture_active = true;
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(10.0);
|
||||
|
||||
ui.add_sized(
|
||||
[ui.available_width(), 22.0],
|
||||
TextEdit::singleline(&mut self.app_state.hotkey_search_query)
|
||||
.hint_text("Search hotkeys..."),
|
||||
);
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
ui.add_space(5.0);
|
||||
|
||||
let conflicts = self.app_state.hotkey_config.find_conflicts();
|
||||
let conflict_slots: std::collections::HashSet<String> = conflicts
|
||||
.iter()
|
||||
.flat_map(|(a, b)| vec![a.clone(), b.clone()])
|
||||
.collect();
|
||||
|
||||
let search = self.app_state.hotkey_search_query.to_lowercase();
|
||||
|
||||
// Slots table
|
||||
let mut action: Option<HotkeyAction> = None;
|
||||
let area_size = ui.available_size();
|
||||
|
||||
ScrollArea::vertical().show(ui, |ui| {
|
||||
ui.set_min_width(area_size.x);
|
||||
Grid::new("hotkeys_grid")
|
||||
.striped(true)
|
||||
.num_columns(4)
|
||||
.max_col_width(area_size.x)
|
||||
.min_col_width(area_size.x / 4.0)
|
||||
.spacing([40.0, 10.0])
|
||||
.show(ui, |ui| {
|
||||
// Table header
|
||||
ui.label(
|
||||
RichText::new("Slot")
|
||||
.strong()
|
||||
.monospace()
|
||||
.color(Color32::LIGHT_GRAY),
|
||||
);
|
||||
ui.label(
|
||||
RichText::new("Sound")
|
||||
.strong()
|
||||
.monospace()
|
||||
.color(Color32::LIGHT_GRAY),
|
||||
);
|
||||
ui.label(
|
||||
RichText::new("Key Chord")
|
||||
.strong()
|
||||
.monospace()
|
||||
.color(Color32::LIGHT_GRAY),
|
||||
);
|
||||
ui.label(
|
||||
RichText::new("Actions")
|
||||
.strong()
|
||||
.monospace()
|
||||
.color(Color32::LIGHT_GRAY),
|
||||
);
|
||||
ui.end_row();
|
||||
|
||||
let slots: Vec<_> = self
|
||||
.app_state
|
||||
.hotkey_config
|
||||
.slots
|
||||
.iter()
|
||||
.filter(|s| {
|
||||
if search.is_empty() {
|
||||
return true;
|
||||
}
|
||||
s.slot.to_lowercase().contains(&search)
|
||||
|| format!("{:?}", s.action).to_lowercase().contains(&search)
|
||||
|| s.key_chord
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.to_lowercase()
|
||||
.contains(&search)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
for slot in &slots {
|
||||
ui.horizontal(|ui| {
|
||||
// Conflict badge
|
||||
if conflict_slots.contains(&slot.slot) {
|
||||
ui.label(
|
||||
RichText::new(ICON_WARNING.codepoint)
|
||||
.color(Color32::from_rgb(255, 165, 0)),
|
||||
)
|
||||
.on_hover_text("Key chord conflict");
|
||||
}
|
||||
|
||||
// Slot name
|
||||
let slot_text = RichText::new(&slot.slot).monospace();
|
||||
ui.label(slot_text);
|
||||
});
|
||||
|
||||
// Action description
|
||||
let action_name = match slot.action.name.as_str() {
|
||||
"play" => {
|
||||
if let Some(file_path_str) = slot.action.args.get("file_path") {
|
||||
Path::new(file_path_str)
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
} else {
|
||||
"Play".to_string()
|
||||
}
|
||||
}
|
||||
"toggle_pause" => "Toggle Pause".to_string(),
|
||||
"pause" => "Pause Playback".to_string(),
|
||||
"resume" => "Resume Playback".to_string(),
|
||||
"stop" => "Stop Playback".to_string(),
|
||||
"toggle_loop" => "Toggle Loop".to_string(),
|
||||
other => other.to_string(),
|
||||
};
|
||||
ui.add(Label::new(RichText::new(action_name).monospace()).truncate());
|
||||
|
||||
// Key chord
|
||||
let chord_text = slot.key_chord.as_deref().unwrap_or("(none)");
|
||||
ui.label(RichText::new(chord_text).monospace().color(
|
||||
if slot.key_chord.is_some() {
|
||||
Color32::from_rgb(100, 200, 100)
|
||||
} else {
|
||||
Color32::GRAY
|
||||
},
|
||||
));
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
// Delete button
|
||||
if ui
|
||||
.add(Button::new(ICON_DELETE).frame(false))
|
||||
.on_hover_text("Remove slot")
|
||||
.clicked()
|
||||
{
|
||||
action = Some(HotkeyAction::Remove(slot.slot.clone()));
|
||||
}
|
||||
|
||||
// Set key chord button
|
||||
if ui
|
||||
.add(Button::new(ICON_KEYBOARD).frame(false))
|
||||
.on_hover_text("Set key chord")
|
||||
.clicked()
|
||||
{
|
||||
action = Some(HotkeyAction::Capture(slot.slot.clone()));
|
||||
}
|
||||
|
||||
// Clear key chord
|
||||
if slot.key_chord.is_some()
|
||||
&& ui
|
||||
.add(Button::new(ICON_BACKSPACE).frame(false))
|
||||
.on_hover_text("Clear key chord")
|
||||
.clicked()
|
||||
{
|
||||
action = Some(HotkeyAction::ClearChord(slot.slot.clone()));
|
||||
}
|
||||
|
||||
// Play button
|
||||
if ui
|
||||
.add(Button::new(ICON_PLAY_ARROW).frame(false))
|
||||
.on_hover_text("Play")
|
||||
.clicked()
|
||||
{
|
||||
action = Some(HotkeyAction::Play(slot.slot.clone()));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
|
||||
if slots.is_empty() {
|
||||
ui.label("No hotkey slots configured.");
|
||||
ui.label("");
|
||||
ui.label("");
|
||||
ui.label("");
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if let Some(action) = action {
|
||||
match action {
|
||||
HotkeyAction::Remove(slot) => {
|
||||
make_request_async(Request::clear_hotkey(&slot));
|
||||
self.app_state.hotkey_config.remove_slot(&slot);
|
||||
}
|
||||
HotkeyAction::Capture(slot) => {
|
||||
self.app_state.assigning_hotkey_slot = Some(slot);
|
||||
self.app_state.hotkey_capture_active = true;
|
||||
}
|
||||
HotkeyAction::ClearChord(slot) => {
|
||||
make_request_async(Request::clear_hotkey_key(&slot));
|
||||
self.app_state.hotkey_config.set_key_chord(&slot, None);
|
||||
}
|
||||
HotkeyAction::Play(slot) => {
|
||||
self.play_hotkey_slot(&slot);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn draw_header(&mut self, ui: &mut Ui) {
|
||||
ui.vertical_centered_justified(|ui| {
|
||||
if self.audio_player_state.tracks.is_empty() {
|
||||
ui.label("No tracks playing");
|
||||
return;
|
||||
}
|
||||
|
||||
let tracks = self.audio_player_state.tracks.clone();
|
||||
let mut action = None;
|
||||
|
||||
for track in 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_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| {
|
||||
// ---------- Play Button ----------
|
||||
let play_button = Button::new(if track.paused {
|
||||
ICON_PLAY_ARROW
|
||||
} else {
|
||||
ICON_PAUSE
|
||||
})
|
||||
.corner_radius(15.0);
|
||||
|
||||
let play_button_response = ui.add_sized([30.0, 30.0], play_button);
|
||||
if play_button_response.clicked() {
|
||||
if track.paused {
|
||||
action = Some(TrackAction::Resume(track.id));
|
||||
} else {
|
||||
action = Some(TrackAction::Pause(track.id));
|
||||
}
|
||||
}
|
||||
// --------------------------------
|
||||
|
||||
// ---------- Loop Button ----------
|
||||
let loop_button = Button::new(
|
||||
RichText::new(if track.looped {
|
||||
ICON_REPEAT_ONE
|
||||
} else {
|
||||
ICON_REPEAT
|
||||
})
|
||||
.size(18.0),
|
||||
)
|
||||
.frame(false);
|
||||
|
||||
let loop_button_response = ui.add_sized([15.0, 30.0], loop_button);
|
||||
if loop_button_response.clicked() {
|
||||
action = Some(TrackAction::ToggleLoop(track.id));
|
||||
}
|
||||
// --------------------------------
|
||||
|
||||
// ---------- Position Slider ----------
|
||||
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 default_slider_width = ui.spacing().slider_width;
|
||||
let position_slider_width = ui.available_width()
|
||||
- (30.0 * 3.0)
|
||||
- default_slider_width
|
||||
- (ui.spacing().item_spacing.x * 6.0);
|
||||
ui.spacing_mut().slider_width = position_slider_width;
|
||||
let position_slider_response = ui.add_sized([30.0, 30.0], position_slider);
|
||||
if position_slider_response.drag_stopped() {
|
||||
ui_state.position_dragged = true;
|
||||
}
|
||||
// --------------------------------
|
||||
|
||||
// ---------- Time Label ----------
|
||||
let time_label =
|
||||
Label::new(RichText::new(format_time_pair(track.position, duration)).monospace());
|
||||
ui.add_sized([30.0, 30.0], time_label);
|
||||
// --------------------------------
|
||||
|
||||
// ---------- Volume Icon ----------
|
||||
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));
|
||||
// --------------------------------
|
||||
|
||||
// ---------- Volume Slider ----------
|
||||
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;
|
||||
|
||||
let volume_slider_response = ui.add_sized([30.0, 30.0], volume_slider);
|
||||
if volume_slider_response.drag_stopped() {
|
||||
ui_state.volume_dragged = true;
|
||||
}
|
||||
// --------------------------------
|
||||
|
||||
// ---------- Stop Button ---------
|
||||
let stop_button = Button::new(ICON_CLOSE).frame(false);
|
||||
let stop_button_response = ui.add_sized([30.0, 30.0], stop_button);
|
||||
if stop_button_response.clicked() {
|
||||
action = Some(TrackAction::Stop(track.id));
|
||||
}
|
||||
// --------------------------------
|
||||
});
|
||||
|
||||
action
|
||||
}
|
||||
|
||||
fn draw_body(&mut self, ui: &mut Ui) {
|
||||
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 {
|
||||
if current_dir.eq(&path) {
|
||||
dir_button_text = dir_button_text.color(Color32::WHITE);
|
||||
}
|
||||
}
|
||||
|
||||
let dir_button =
|
||||
Button::new(dir_button_text.atom_max_width(area_size.x)).frame(false);
|
||||
|
||||
let dir_button_response = ui.add(dir_button);
|
||||
if dir_button_response.clicked() {
|
||||
self.open_dir(&path);
|
||||
}
|
||||
|
||||
let delete_dir_button = Button::new(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()
|
||||
{
|
||||
if 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 {
|
||||
if current_file.eq(&entry_path) {
|
||||
file_button_text = file_button_text.color(Color32::WHITE);
|
||||
}
|
||||
}
|
||||
|
||||
let file_button = Button::new(file_button_text).frame(false);
|
||||
let file_button_response = ui.add(file_button);
|
||||
if file_button_response.clicked() {
|
||||
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()
|
||||
{
|
||||
if let Err(e) = opener::reveal(&entry_path) {
|
||||
eprintln!("Failed to open file manager: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
if ui
|
||||
.button(format!(
|
||||
"{} {}",
|
||||
ICON_KEYBOARD.codepoint, "Assign Hotkey"
|
||||
))
|
||||
.clicked()
|
||||
{
|
||||
self.app_state.assigning_hotkey_for_file =
|
||||
Some(entry_path.clone());
|
||||
self.app_state.hotkey_capture_active = true;
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn get_hotkey_badge(&self, path: &PathBuf) -> Option<String> {
|
||||
for slot in &self.app_state.hotkey_config.slots {
|
||||
if slot.action.name == "play" {
|
||||
if let Some(file_path_str) = slot.action.args.get("file_path") {
|
||||
if Path::new(file_path_str) == path.as_path() {
|
||||
if let Some(chord) = &slot.key_chord {
|
||||
return Some(format!("[{}]", chord));
|
||||
} else {
|
||||
return Some(format!("[{}]", slot.slot));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn draw_footer(&mut self, ui: &mut Ui) {
|
||||
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;
|
||||
}
|
||||
// --------------------------------
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
mod gui;
|
||||
|
||||
use std::error::Error;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn Error>> {
|
||||
gui::run().await
|
||||
}
|
||||
@@ -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() {
|
||||
if !config_path.exists() {
|
||||
fs::create_dir_all(config_dir)?;
|
||||
}
|
||||
}
|
||||
|
||||
let config_json = serde_json::to_string_pretty(self)?;
|
||||
fs::write(config_path, config_json.as_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_from_file() -> Result<DaemonConfig, Box<dyn Error>> {
|
||||
let config_path = get_config_path()?.join("daemon.json");
|
||||
let bytes = fs::read(config_path)?;
|
||||
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() {
|
||||
if !config_path.exists() {
|
||||
fs::create_dir_all(config_dir)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Do not save scale factor if user does not want to
|
||||
if !self.save_scale_factor {
|
||||
self.scale_factor = 1.0;
|
||||
}
|
||||
|
||||
let config_json = serde_json::to_string_pretty(self)?;
|
||||
fs::write(config_path, config_json.as_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_from_file() -> Result<GuiConfig, Box<dyn Error>> {
|
||||
let config_path = get_config_path()?.join("gui.json");
|
||||
let bytes = fs::read(config_path)?;
|
||||
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<(String, String)> {
|
||||
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].to_string(), slots[j].to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,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,349 +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},
|
||||
};
|
||||
|
||||
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() {
|
||||
if 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>,
|
||||
) {
|
||||
pipewire::init();
|
||||
|
||||
let main_loop = MainLoopRc::new(None).expect("Failed to initialize pipewire main loop");
|
||||
|
||||
// Stop main loop on Terminate message
|
||||
let _receiver = pw_receiver.attach(main_loop.loop_(), {
|
||||
let _main_loop = main_loop.clone();
|
||||
move |_| _main_loop.quit()
|
||||
});
|
||||
|
||||
let context = ContextRc::new(&main_loop, None).expect("Failed to create pipewire context");
|
||||
let core = context
|
||||
.connect(None)
|
||||
.expect("Failed to connect to pipewire context");
|
||||
let registry = core
|
||||
.get_registry()
|
||||
.expect("Failed to get registry from pipewire context");
|
||||
|
||||
let _listener = registry
|
||||
.add_listener_local()
|
||||
.global(move |global| {
|
||||
// Try to parse every global object pipewire finds
|
||||
let (device, port) = parse_global_object(global);
|
||||
|
||||
// Send message to the main thread
|
||||
let sender_clone = main_sender.clone();
|
||||
tokio::task::spawn(async move {
|
||||
sender_clone.send((device, port)).await.ok();
|
||||
});
|
||||
})
|
||||
.register();
|
||||
|
||||
main_loop.run();
|
||||
}
|
||||
|
||||
pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>), Box<dyn Error>> {
|
||||
// Channels to communicate with pipewire thread
|
||||
let (main_sender, mut main_receiver) = mpsc::channel(10);
|
||||
let (pw_sender, pw_receiver) = pipewire::channel::channel();
|
||||
|
||||
// Spawn pipewire thread in background
|
||||
let _pw_thread =
|
||||
tokio::spawn(async move { pw_get_global_objects_thread(main_sender, pw_receiver).await });
|
||||
|
||||
let mut input_devices: HashMap<u32, AudioDevice> = HashMap::new();
|
||||
let mut output_devices: HashMap<u32, AudioDevice> = HashMap::new();
|
||||
let mut ports: Vec<Port> = vec![];
|
||||
|
||||
loop {
|
||||
// If we don't receive a message in 100ms, we can assume that pipewire thread is finished
|
||||
match timeout(Duration::from_millis(100), main_receiver.recv()).await {
|
||||
Ok(Some((device, port))) => {
|
||||
if let Some(device) = device {
|
||||
match device.device_type {
|
||||
DeviceType::Input => {
|
||||
input_devices.insert(device.id, device);
|
||||
}
|
||||
DeviceType::Output => {
|
||||
output_devices.insert(device.id, device);
|
||||
}
|
||||
}
|
||||
} else if let Some(port) = port {
|
||||
ports.push(port);
|
||||
}
|
||||
}
|
||||
Ok(None) | Err(_) => {
|
||||
// Pipewire thread is finished and we can collect our devices
|
||||
pw_sender
|
||||
.send(Terminate {})
|
||||
.expect("Failed to terminate pipewire thread");
|
||||
|
||||
for port in ports {
|
||||
let node_id = port.node_id;
|
||||
|
||||
if 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(|a, b| a.id.cmp(&b.id));
|
||||
output_devices.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
|
||||
return Ok((input_devices, output_devices));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_device(device_name: &str) -> Result<AudioDevice, Box<dyn Error>> {
|
||||
let (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 _pw_thread = thread::spawn(move || {
|
||||
pipewire::init();
|
||||
|
||||
let main_loop = MainLoopRc::new(None).expect("Failed to initialize pipewire main loop");
|
||||
let context = ContextRc::new(&main_loop, None).expect("Failed to create pipewire context");
|
||||
let core = context
|
||||
.connect(None)
|
||||
.expect("Failed to connect to pipewire context");
|
||||
|
||||
let props = properties!(
|
||||
"factory.name" => "support.null-audio-sink",
|
||||
"node.name" => "pwsp-virtual-mic",
|
||||
"node.description" => "PWSP Virtual Mic",
|
||||
"media.class" => "Audio/Source/Virtual",
|
||||
"audio.position" => "[ FL FR ]",
|
||||
"audio.channels" => "2",
|
||||
"object.linger" => "false", // Destroy the node on app exit
|
||||
);
|
||||
|
||||
let _node = core
|
||||
.create_object::<pipewire::node::Node>("adapter", &props)
|
||||
.expect("Failed to create virtual mic");
|
||||
|
||||
let _receiver = pw_receiver.attach(main_loop.loop_(), {
|
||||
let _main_loop = main_loop.clone();
|
||||
move |_| _main_loop.quit()
|
||||
});
|
||||
|
||||
println!("Virtual mic created");
|
||||
main_loop.run();
|
||||
});
|
||||
|
||||
Ok(pw_sender)
|
||||
}
|
||||
|
||||
pub 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 _pw_thread = thread::spawn(move || {
|
||||
pipewire::init();
|
||||
|
||||
let main_loop = MainLoopRc::new(None).expect("Failed to initialize pipewire main loop");
|
||||
let context = ContextRc::new(&main_loop, None).expect("Failed to create pipewire context");
|
||||
let core = context
|
||||
.connect(None)
|
||||
.expect("Failed to connect to pipewire context");
|
||||
|
||||
let props_fl = properties! {
|
||||
"link.output.node" => format!("{}", output_fl.node_id).as_str(),
|
||||
"link.output.port" => format!("{}", output_fl.port_id).as_str(),
|
||||
"link.input.node" => format!("{}", input_fl.node_id).as_str(),
|
||||
"link.input.port" => format!("{}", input_fl.port_id).as_str(),
|
||||
};
|
||||
let props_fr = properties! {
|
||||
"link.output.node" => format!("{}", output_fr.node_id).as_str(),
|
||||
"link.output.port" => format!("{}", output_fr.port_id).as_str(),
|
||||
"link.input.node" => format!("{}", input_fr.node_id).as_str(),
|
||||
"link.input.port" => format!("{}", input_fr.port_id).as_str(),
|
||||
};
|
||||
|
||||
let _link_fl = core
|
||||
.create_object::<Link>("link-factory", &props_fl)
|
||||
.expect("Failed to create link FL");
|
||||
let _link_fr = core
|
||||
.create_object::<Link>("link-factory", &props_fr)
|
||||
.expect("Failed to create link FR");
|
||||
|
||||
let _receiver = pw_receiver.attach(main_loop.loop_(), {
|
||||
let _main_loop = main_loop.clone();
|
||||
move |_| _main_loop.quit()
|
||||
});
|
||||
|
||||
println!(
|
||||
"Link created: FL: {}-{} FR: {}-{}",
|
||||
output_fl.node_id, input_fl.node_id, output_fr.node_id, input_fr.node_id
|
||||
);
|
||||
main_loop.run();
|
||||
});
|
||||
|
||||
Ok(pw_sender)
|
||||
}
|
||||
Reference in New Issue
Block a user