mirror of
https://github.com/arabianq/pipewire-soundpad.git
synced 2026-06-19 20:23:33 +00:00
Compare commits
69 Commits
930857312d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| caf8ac19a1 | |||
| f563680ceb | |||
| 78ca2ddf68 | |||
| 838fc1ce29 | |||
| 622cf39fa2 | |||
| 809b1a8490 | |||
| d1a5275173 | |||
| e67f174a59 | |||
| 6545431ac2 | |||
| 026ef97a72 | |||
| 9f833cc30b | |||
| 410a2c7959 | |||
| c173e602ad | |||
| 3576c634fd | |||
| 5747f39ace | |||
| c501033834 | |||
| c0a27e0c3b | |||
| 3c2882ef1f | |||
| 36aed3f55d | |||
| c48a425bb0 | |||
| 9a5436cd35 | |||
| 2ce243e896 | |||
| 57fb3fd7a3 | |||
| 82b02bf520 | |||
| 3d4dbbe866 | |||
| 70bd3a889a | |||
| a7b5bdd2a9 | |||
| 99fef4a167 | |||
| ae08f7ddc3 | |||
| 0060d0bdee | |||
| f91a49cb70 | |||
| 8d513ff65b | |||
| 226dfd91ff | |||
| 344ea60fa5 | |||
| 8411cb3528 | |||
| ad8f22a359 | |||
| 4ec49d822b | |||
| ec2fa2a478 | |||
| e91465365d | |||
| 0476329798 | |||
| 6c59137639 | |||
| 3693a678ea | |||
| 5511d23c3e | |||
| 818cd8b50d | |||
| 6f7d631e28 | |||
| 18904052c7 | |||
| 6841d8d1c3 | |||
| 105be87222 | |||
| 0f8abbc443 | |||
| 54011e7ff1 | |||
| dac9d53cef | |||
| 9da3799cd3 | |||
| d66369884c | |||
| 5e47e7d6fb | |||
| 695c83c9e6 | |||
| 798a6d1887 | |||
| bb18175a30 | |||
| 6ef3f8d76e | |||
| bec77f59bd | |||
| dad1a62798 | |||
| 84a4a01282 | |||
| 88995f6fd1 | |||
| 660ece9866 | |||
| f2dcf2e0fe | |||
| fe655be59a | |||
| 78960cdc10 | |||
| 0439cf815e | |||
| 5ae82ef28c | |||
| 5f69345d45 |
+101
-18
@@ -13,7 +13,15 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
linux-build:
|
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:
|
steps:
|
||||||
- name: Install apt deps (jq/zip + dev-libs)
|
- name: Install apt deps (jq/zip + dev-libs)
|
||||||
@@ -33,35 +41,47 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup Rust toolchain
|
- name: Setup Rust toolchain
|
||||||
uses: actions-rs/toolchain@v1
|
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||||
with:
|
with:
|
||||||
toolchain: 1.94.1
|
toolchain: 1.96.0
|
||||||
|
|
||||||
|
- name: Rust Cache
|
||||||
|
uses: swatinem/rust-cache@v2
|
||||||
|
|
||||||
|
- 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
|
- name: Extract all binary names
|
||||||
id: cargo-meta
|
id: cargo-meta
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
BIN_NAMES=$(cargo metadata --no-deps --format-version 1 \
|
BIN_NAMES=$(cargo metadata --no-deps --format-version 1 \
|
||||||
| jq -r '.packages[0].targets[] | select(.kind[] | contains("bin")) | .name')
|
| jq -r '.packages[].targets[] | select(.kind[] | contains("bin")) | .name')
|
||||||
echo "bin_names<<EOF" >> $GITHUB_OUTPUT
|
echo "bin_names<<EOF" >> $GITHUB_OUTPUT
|
||||||
echo "$BIN_NAMES" >> $GITHUB_OUTPUT
|
echo "$BIN_NAMES" >> $GITHUB_OUTPUT
|
||||||
echo "EOF" >> $GITHUB_OUTPUT
|
echo "EOF" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Build all binaries
|
|
||||||
run: cargo build --locked
|
|
||||||
|
|
||||||
- name: Package all binaries into one archive
|
- name: Package all binaries into one archive
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
COMMIT_SHA="${{ github.sha }}"
|
COMMIT_SHA="${{ github.sha }}"
|
||||||
ARCHIVE_NAME="pwsp-${COMMIT_SHA}-linux-x64.zip"
|
ARCHIVE_NAME="pwsp-${COMMIT_SHA}-linux-${{ matrix.arch }}.zip"
|
||||||
echo "Creating archive: $ARCHIVE_NAME"
|
echo "Creating archive: $ARCHIVE_NAME"
|
||||||
|
|
||||||
FILES=()
|
FILES=()
|
||||||
while IFS= read -r BIN; do
|
while IFS= read -r BIN; do
|
||||||
[ -z "$BIN" ] && continue
|
[ -z "$BIN" ] && continue
|
||||||
FILES+=("target/debug/$BIN")
|
FILES+=("target/release/$BIN")
|
||||||
done <<< "${{ steps.cargo-meta.outputs.bin_names }}"
|
done <<< "${{ steps.cargo-meta.outputs.bin_names }}"
|
||||||
|
|
||||||
if [ "${#FILES[@]}" -eq 0 ]; then
|
if [ "${#FILES[@]}" -eq 0 ]; then
|
||||||
@@ -82,28 +102,90 @@ jobs:
|
|||||||
- name: Upload archive as artifact
|
- name: Upload archive as artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: archive
|
name: archive-${{ matrix.arch }}
|
||||||
path: pwsp-*.zip
|
path: pwsp-*.zip
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
- name: Install cargo-deb and create .deb
|
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: Rust Cache
|
||||||
|
uses: swatinem/rust-cache@v2
|
||||||
|
|
||||||
|
- 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
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cargo install --locked cargo-deb
|
|
||||||
export PATH="$HOME/.cargo/bin:$PATH"
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
cargo-deb -p pwsp-gui --no-build --no-strip
|
||||||
cargo-deb
|
|
||||||
|
|
||||||
- name: Upload .deb(s) as artifacts
|
- name: Upload .deb(s) as artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: deb-packages
|
name: deb-packages-${{ matrix.arch }}
|
||||||
path: target/debian/*.deb
|
path: target/debian/*.deb
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
flatpak-build:
|
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:
|
container:
|
||||||
image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-25.08
|
image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-25.08
|
||||||
options: --privileged
|
options: --privileged
|
||||||
@@ -114,8 +196,9 @@ jobs:
|
|||||||
- name: Build Flatpak
|
- name: Build Flatpak
|
||||||
uses: flatpak/flatpak-github-actions/flatpak-builder@v6
|
uses: flatpak/flatpak-github-actions/flatpak-builder@v6
|
||||||
with:
|
with:
|
||||||
bundle: ru.arabianq.pwsp.flatpak
|
bundle: ru.arabianq.pwsp_${{ matrix.arch }}.flatpak
|
||||||
manifest-path: packages/flatpak/ru.arabianq.pwsp.yaml
|
manifest-path: packages/flatpak/ru.arabianq.pwsp.yaml
|
||||||
cache: true
|
cache: true
|
||||||
branch: master
|
branch: master
|
||||||
build-bundle: true
|
build-bundle: true
|
||||||
|
arch: ${{ matrix.arch }}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ name: Flatter
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main, master ]
|
branches: [main, master]
|
||||||
release:
|
release:
|
||||||
types: [ published ]
|
types: [published]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
tag_name:
|
tag_name:
|
||||||
@@ -21,8 +21,8 @@ on:
|
|||||||
default: "stable"
|
default: "stable"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
flatter:
|
flatter-x64:
|
||||||
name: Flatter
|
name: Flatter (x86_64)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -60,8 +60,63 @@ jobs:
|
|||||||
echo "default-branch: ${{ steps.set_branch.outputs.branch }}" >> packages/flatpak/ru.arabianq.pwsp.yaml
|
echo "default-branch: ${{ steps.set_branch.outputs.branch }}" >> packages/flatpak/ru.arabianq.pwsp.yaml
|
||||||
|
|
||||||
- name: Install SDK Extensions
|
- name: Install SDK Extensions
|
||||||
run: flatpak install -y flathub org.freedesktop.Sdk.Extension.rust-stable//25.08
|
run: |
|
||||||
org.freedesktop.Sdk.Extension.llvm20//25.08
|
flatpak install -y flathub org.freedesktop.Sdk.Extension.rust-stable//25.08
|
||||||
|
flatpak install -y flathub org.freedesktop.Sdk.Extension.llvm20//25.08
|
||||||
|
|
||||||
|
- name: Build Flatpak
|
||||||
|
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
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
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
|
||||||
|
flatpak install -y flathub org.freedesktop.Sdk.Extension.llvm20//25.08
|
||||||
|
|
||||||
- name: Build Flatpak
|
- name: Build Flatpak
|
||||||
uses: andyholmes/flatter@main
|
uses: andyholmes/flatter@main
|
||||||
@@ -70,11 +125,13 @@ jobs:
|
|||||||
gpg-sign: ${{ steps.gpg.outputs.fingerprint }}
|
gpg-sign: ${{ steps.gpg.outputs.fingerprint }}
|
||||||
upload-bundles: false
|
upload-bundles: false
|
||||||
upload-pages-artifact: true
|
upload-pages-artifact: true
|
||||||
|
arch: aarch64
|
||||||
|
cache: true
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
name: Deploy to GitHub Pages
|
name: Deploy to GitHub Pages
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: flatter
|
needs: flatter-arm64
|
||||||
permissions:
|
permissions:
|
||||||
pages: write
|
pages: write
|
||||||
id-token: write
|
id-token: write
|
||||||
|
|||||||
+124
-35
@@ -64,7 +64,15 @@ jobs:
|
|||||||
|
|
||||||
linux-release:
|
linux-release:
|
||||||
needs: prepare
|
needs: prepare
|
||||||
runs-on: ubuntu-latest
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- arch: x64
|
||||||
|
runner: ubuntu-latest
|
||||||
|
- arch: arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
fail-fast: false
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Install apt deps (jq/zip + dev-libs)
|
- name: Install apt deps (jq/zip + dev-libs)
|
||||||
@@ -85,16 +93,19 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup Rust toolchain
|
- name: Setup Rust toolchain
|
||||||
uses: actions-rs/toolchain@v1
|
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||||
with:
|
with:
|
||||||
toolchain: 1.94.1
|
toolchain: 1.96.0
|
||||||
|
|
||||||
|
- name: Rust Cache
|
||||||
|
uses: swatinem/rust-cache@v2
|
||||||
|
|
||||||
- name: Extract all binary names
|
- name: Extract all binary names
|
||||||
id: cargo-meta
|
id: cargo-meta
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
BIN_NAMES=$(cargo metadata --no-deps --format-version 1 \
|
BIN_NAMES=$(cargo metadata --no-deps --format-version 1 \
|
||||||
| jq -r '.packages[0].targets[] | select(.kind[] | contains("bin")) | .name')
|
| jq -r '.packages[].targets[] | select(.kind[] | contains("bin")) | .name')
|
||||||
echo "bin_names<<EOF" >> $GITHUB_OUTPUT
|
echo "bin_names<<EOF" >> $GITHUB_OUTPUT
|
||||||
echo "$BIN_NAMES" >> $GITHUB_OUTPUT
|
echo "$BIN_NAMES" >> $GITHUB_OUTPUT
|
||||||
echo "EOF" >> $GITHUB_OUTPUT
|
echo "EOF" >> $GITHUB_OUTPUT
|
||||||
@@ -107,7 +118,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
TAG="${{ needs.prepare.outputs.tag }}"
|
TAG="${{ needs.prepare.outputs.tag }}"
|
||||||
ARCHIVE_NAME="pwsp-${TAG}-linux-x64.zip"
|
ARCHIVE_NAME="pwsp-${TAG}-linux-${{ matrix.arch }}.zip"
|
||||||
echo "Creating archive: $ARCHIVE_NAME"
|
echo "Creating archive: $ARCHIVE_NAME"
|
||||||
|
|
||||||
FILES=()
|
FILES=()
|
||||||
@@ -131,48 +142,126 @@ jobs:
|
|||||||
|
|
||||||
zip -j "$ARCHIVE_NAME" "${FILES[@]}"
|
zip -j "$ARCHIVE_NAME" "${FILES[@]}"
|
||||||
|
|
||||||
- name: Upload release archive
|
- name: Upload zip archive
|
||||||
uses: softprops/action-gh-release@v2
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
name: zip-archive-${{ matrix.arch }}
|
||||||
tag_name: ${{ needs.prepare.outputs.tag }}
|
path: pwsp-*.zip
|
||||||
files: |
|
retention-days: 1
|
||||||
pwsp-*.zip
|
|
||||||
|
|
||||||
- name: Install cargo-deb and create .deb
|
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: Rust Cache
|
||||||
|
uses: swatinem/rust-cache@v2
|
||||||
|
|
||||||
|
- 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
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cargo install --locked cargo-deb
|
|
||||||
export PATH="$HOME/.cargo/bin:$PATH"
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
cargo-deb -p pwsp-gui --no-build
|
||||||
|
|
||||||
cargo-deb
|
- name: Upload deb 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
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
tag_name: ${{ needs.prepare.outputs.tag }}
|
tag_name: ${{ needs.prepare.outputs.tag }}
|
||||||
files: |
|
files: |
|
||||||
target/debian/*.deb
|
./dist/pwsp-*.zip
|
||||||
|
./dist/*.deb
|
||||||
|
|
||||||
flatpak-release:
|
- name: Install copr-cli
|
||||||
needs: prepare
|
run: pip install copr-cli
|
||||||
runs-on: ubuntu-latest
|
|
||||||
container:
|
|
||||||
image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-25.08
|
|
||||||
options: --privileged
|
|
||||||
|
|
||||||
steps:
|
- name: Trigger Copr Build
|
||||||
- uses: actions/checkout@v4
|
env:
|
||||||
with:
|
COPR_CONFIG: ${{ secrets.COPR_CONFIG }}
|
||||||
ref: ${{ needs.prepare.outputs.tag }}
|
run: |
|
||||||
|
mkdir -p ~/.config
|
||||||
- name: Build Flatpak
|
echo "$COPR_CONFIG" > ~/.config/copr
|
||||||
uses: flatpak/flatpak-github-actions/flatpak-builder@v6
|
copr-cli buildscm --nowait \
|
||||||
with:
|
--clone-url https://github.com/arabianq/pipewire-soundpad.git \
|
||||||
bundle: ru.arabianq.pwsp.flatpak
|
--commit ${{ needs.prepare.outputs.tag }} \
|
||||||
manifest-path: packages/flatpak/ru.arabianq.pwsp.yaml
|
--spec packages/rpm/pwsp.spec \
|
||||||
cache: true
|
arabianq/pwsp
|
||||||
branch: master
|
|
||||||
build-bundle: true
|
|
||||||
|
|||||||
Generated
+842
-544
File diff suppressed because it is too large
Load Diff
+24
-56
@@ -1,18 +1,29 @@
|
|||||||
[package]
|
[workspace]
|
||||||
name = "pwsp"
|
members = [
|
||||||
version = "1.9.0"
|
"pwsp-lib",
|
||||||
|
"pwsp-daemon",
|
||||||
|
"pwsp-cli",
|
||||||
|
"pwsp-gui"
|
||||||
|
]
|
||||||
|
resolver = "2"
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "1.12.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
authors = ["arabian"]
|
authors = ["arabian"]
|
||||||
description = "PWSP lets you play audio files through your microphone. Has both CLI and GUI clients."
|
|
||||||
readme = "README.md"
|
|
||||||
homepage = "https://pwsp.arabianq.ru"
|
homepage = "https://pwsp.arabianq.ru"
|
||||||
repository = "https://github.com/arabianq/pipewire-soundpad"
|
repository = "https://github.com/arabianq/pipewire-soundpad"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
description = "PWSP lets you play audio files through your microphone. Has both CLI and GUI clients."
|
||||||
keywords = ["soundpad", "pipewire", "linux", "cli", "gui"]
|
keywords = ["soundpad", "pipewire", "linux", "cli", "gui"]
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
pwsp-lib = { path = "pwsp-lib" }
|
||||||
|
pwsp-daemon = { path = "pwsp-daemon" }
|
||||||
|
pwsp-cli = { path = "pwsp-cli" }
|
||||||
|
pwsp-gui = { path = "pwsp-gui" }
|
||||||
|
|
||||||
[dependencies]
|
tokio = { version = "1.52.3", features = ["full"] }
|
||||||
tokio = { version = "1.52.1", features = ["full"] }
|
|
||||||
async-trait = "0.1.89"
|
async-trait = "0.1.89"
|
||||||
|
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
@@ -31,23 +42,22 @@ dirs = "6.0.0"
|
|||||||
itertools = "0.14.0"
|
itertools = "0.14.0"
|
||||||
evdev = { version = "0.13.2", features = ["tokio"] }
|
evdev = { version = "0.13.2", features = ["tokio"] }
|
||||||
rfd = { version = "0.17.2", default-features = false, features = [
|
rfd = { version = "0.17.2", default-features = false, features = [
|
||||||
|
|
||||||
"xdg-portal",
|
"xdg-portal",
|
||||||
|
|
||||||
] }
|
] }
|
||||||
opener = { version = "0.8.4", features = ["reveal"] }
|
opener = { version = "0.8.4", features = ["reveal"] }
|
||||||
system-fonts = "0.1.0"
|
system-fonts = "0.1.1"
|
||||||
anyhow = "1.0.102"
|
anyhow = "1.0.102"
|
||||||
|
rustix = { version = "1.1.4", features = ["process"] }
|
||||||
|
|
||||||
rust-i18n = "4.0.0"
|
rust-i18n = "4.0.0"
|
||||||
sys-locale = "0.3.2"
|
sys-locale = "0.3.2"
|
||||||
|
|
||||||
rodio = { git = "https://github.com/arabianq/rodio.git", rev = "1a08f281c352622bd82b87b8731585245802d9cf", default-features = false, features = [
|
rodio = { git = "https://github.com/arabianq/rodio.git", rev = "c6a81b5a46e00a6a682c0c431dff62e86f57d819", default-features = false, features = [
|
||||||
"symphonia-all",
|
"symphonia-all",
|
||||||
"symphonia-libopus",
|
"symphonia-libopus",
|
||||||
"playback",
|
"playback",
|
||||||
] }
|
] }
|
||||||
pipewire = "0.9.2"
|
pipewire = "0.10.0"
|
||||||
|
|
||||||
egui = { version = "0.34.2", default-features = false, features = [
|
egui = { version = "0.34.2", default-features = false, features = [
|
||||||
"default_fonts",
|
"default_fonts",
|
||||||
@@ -63,17 +73,8 @@ egui_extras = "0.34.1"
|
|||||||
egui_material_icons = "0.6.0"
|
egui_material_icons = "0.6.0"
|
||||||
egui_dnd = "0.15.0"
|
egui_dnd = "0.15.0"
|
||||||
|
|
||||||
[[bin]]
|
reqwest = "0.13.4"
|
||||||
name = "pwsp-daemon"
|
percent-encoding = "2.3.2"
|
||||||
path = "src/bin/daemon.rs"
|
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "pwsp-cli"
|
|
||||||
path = "src/bin/cli.rs"
|
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "pwsp-gui"
|
|
||||||
path = "src/main.rs"
|
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
strip = true
|
strip = true
|
||||||
@@ -82,36 +83,3 @@ codegen-units = 1
|
|||||||
opt-level = "z"
|
opt-level = "z"
|
||||||
panic = "abort"
|
panic = "abort"
|
||||||
|
|
||||||
[package.metadata.deb]
|
|
||||||
assets = [
|
|
||||||
[
|
|
||||||
"target/release/pwsp-daemon",
|
|
||||||
"usr/bin/",
|
|
||||||
"755",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"target/release/pwsp-cli",
|
|
||||||
"usr/bin/",
|
|
||||||
"755",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"target/release/pwsp-gui",
|
|
||||||
"usr/bin/",
|
|
||||||
"755",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"assets/pwsp-gui.desktop",
|
|
||||||
"usr/share/applications/pwsp.desktop",
|
|
||||||
"644",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"assets/icon.png",
|
|
||||||
"usr/share/icons/hicolor/256x256/apps/pwsp.png",
|
|
||||||
"644",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"assets/pwsp-daemon.service",
|
|
||||||
"usr/lib/systemd/user/pwsp-daemon.service",
|
|
||||||
"644",
|
|
||||||
],
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<h1>🎵 PipeWire Soundpad (PWSP)</h1>
|
<h1>🎵 PipeWire Soundpad (PWSP)</h1>
|
||||||
<p><b>A simple, modern, and powerful soundboard for Linux, written in Rust.</b></p>
|
<p><b>A simple, modern, and powerful soundboard for Linux, written in Rust.</b></p>
|
||||||
<img src="assets/screenshot.png" alt="PWSP Screenshot" width="700"/>
|
<img src="pwsp-gui/assets/screenshot.png" alt="PWSP Screenshot" width="700"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## 🌟 Overview
|
## 🌟 Overview
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB |
+17
-14
@@ -1,17 +1,20 @@
|
|||||||
pkgbase = pwsp-bin
|
pkgbase = pwsp-bin
|
||||||
pkgdesc = Lets you play audio files through your microphone (Pre-built binaries)
|
pkgdesc = Lets you play audio files through your microphone (Pre-built binaries)
|
||||||
pkgver = 1.9.0
|
pkgver = 1.12.0
|
||||||
pkgrel = 1
|
pkgrel = 2
|
||||||
url = https://github.com/arabianq/pipewire-soundpad
|
url = https://github.com/arabianq/pipewire-soundpad
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
license = MIT
|
arch = aarch64
|
||||||
depends = pipewire
|
license = MIT
|
||||||
depends = alsa-lib
|
depends = pipewire
|
||||||
provides = pwsp
|
depends = alsa-lib
|
||||||
conflicts = pwsp
|
provides = pwsp
|
||||||
source = pwsp-bin-1.9.0.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.9.0/pwsp-v1.9.0-linux-x64.zip
|
conflicts = pwsp
|
||||||
source = pipewire-soundpad-1.9.0.tar.gz :: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.9.0.tar.gz
|
source = pipewire-soundpad-1.12.0.tar.gz :: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.12.0.tar.gz
|
||||||
sha256sums = SKIP
|
sha256sums = SKIP
|
||||||
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
|
pkgname = pwsp-bin
|
||||||
|
|||||||
+12
-10
@@ -1,21 +1,23 @@
|
|||||||
# Maintainer: Alexander Tarasov <a.tevg@ya.ru>
|
# Maintainer: Alexander Tarasov <a.tevg@ya.ru>
|
||||||
pkgname=pwsp-bin
|
pkgname=pwsp-bin
|
||||||
_pkgname=pipewire-soundpad
|
_pkgname=pipewire-soundpad
|
||||||
pkgver=1.9.0
|
pkgver=1.12.0
|
||||||
pkgrel=1
|
pkgrel=2
|
||||||
pkgdesc="Lets you play audio files through your microphone (Pre-built binaries)"
|
pkgdesc="Lets you play audio files through your microphone (Pre-built binaries)"
|
||||||
arch=('x86_64')
|
arch=('x86_64' 'aarch64')
|
||||||
url="https://github.com/arabianq/pipewire-soundpad"
|
url="https://github.com/arabianq/pipewire-soundpad"
|
||||||
license=('MIT')
|
license=('MIT')
|
||||||
depends=('pipewire' 'alsa-lib')
|
depends=('pipewire' 'alsa-lib')
|
||||||
provides=('pwsp')
|
provides=('pwsp')
|
||||||
conflicts=('pwsp')
|
conflicts=('pwsp')
|
||||||
|
|
||||||
source=("${pkgname}-${pkgver}.zip::https://github.com/arabianq/$_pkgname/releases/download/v$pkgver/pwsp-v$pkgver-linux-x64.zip"
|
source_x86_64=("pwsp-${pkgver}-x86_64.zip::https://github.com/arabianq/$_pkgname/releases/download/v$pkgver/pwsp-v$pkgver-linux-x64.zip")
|
||||||
"${_pkgname}-${pkgver}.tar.gz::https://github.com/arabianq/$_pkgname/archive/refs/tags/v$pkgver.tar.gz")
|
source_aarch64=("pwsp-${pkgver}-aarch64.zip::https://github.com/arabianq/$_pkgname/releases/download/v$pkgver/pwsp-v$pkgver-linux-arm64.zip")
|
||||||
|
source=("${_pkgname}-${pkgver}.tar.gz::https://github.com/arabianq/$_pkgname/archive/refs/tags/v$pkgver.tar.gz")
|
||||||
|
|
||||||
sha256sums=('SKIP'
|
sha256sums=('SKIP')
|
||||||
'SKIP')
|
sha256sums_x86_64=('SKIP')
|
||||||
|
sha256sums_aarch64=('SKIP')
|
||||||
|
|
||||||
package() {
|
package() {
|
||||||
_srcsrc="${srcdir}/${_pkgname}-${pkgver}"
|
_srcsrc="${srcdir}/${_pkgname}-${pkgver}"
|
||||||
@@ -24,9 +26,9 @@ package() {
|
|||||||
install -Dm755 "${srcdir}/pwsp-daemon" "${pkgdir}/usr/bin/pwsp-daemon"
|
install -Dm755 "${srcdir}/pwsp-daemon" "${pkgdir}/usr/bin/pwsp-daemon"
|
||||||
install -Dm755 "${srcdir}/pwsp-gui" "${pkgdir}/usr/bin/pwsp-gui"
|
install -Dm755 "${srcdir}/pwsp-gui" "${pkgdir}/usr/bin/pwsp-gui"
|
||||||
|
|
||||||
install -Dm644 "$_srcsrc/assets/pwsp-gui.desktop" "${pkgdir}/usr/share/applications/pwsp-gui.desktop"
|
install -Dm644 "$_srcsrc/pwsp-gui/assets/pwsp-gui.desktop" "${pkgdir}/usr/share/applications/pwsp-gui.desktop"
|
||||||
install -Dm644 "$_srcsrc/assets/icon.png" "${pkgdir}/usr/share/icons/hicolor/256x256/apps/icon.png"
|
install -Dm644 "$_srcsrc/pwsp-gui/assets/icon.png" "${pkgdir}/usr/share/icons/hicolor/256x256/apps/pwsp.png"
|
||||||
install -Dm644 "$_srcsrc/assets/pwsp-daemon.service" "${pkgdir}/usr/lib/systemd/user/pwsp-daemon.service"
|
install -Dm644 "$_srcsrc/pwsp-gui/assets/pwsp-daemon.service" "${pkgdir}/usr/lib/systemd/user/pwsp-daemon.service"
|
||||||
|
|
||||||
install -Dm644 "$_srcsrc/LICENSE" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
install -Dm644 "$_srcsrc/LICENSE" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
pkgbase = pwsp
|
pkgbase = pwsp
|
||||||
pkgdesc = Lets you play audio files through your microphone
|
pkgdesc = Lets you play audio files through your microphone
|
||||||
pkgver = 1.9.0
|
pkgver = 1.12.0
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://github.com/arabianq/pipewire-soundpad
|
url = https://github.com/arabianq/pipewire-soundpad
|
||||||
arch = any
|
arch = x86_64
|
||||||
|
arch = aarch64
|
||||||
license = MIT
|
license = MIT
|
||||||
makedepends = clang
|
makedepends = clang
|
||||||
makedepends = rust
|
makedepends = rust
|
||||||
@@ -11,7 +12,7 @@ pkgbase = pwsp
|
|||||||
makedepends = cmake
|
makedepends = cmake
|
||||||
makedepends = pipewire
|
makedepends = pipewire
|
||||||
makedepends = alsa-lib
|
makedepends = alsa-lib
|
||||||
source = https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.9.0.tar.gz
|
source = https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.12.0.tar.gz
|
||||||
sha256sums = SKIP
|
sha256sums = SKIP
|
||||||
|
|
||||||
pkgname = pwsp
|
pkgname = pwsp
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Maintainer: Alexander Tarasov <a.tevg@ya.ru>
|
# Maintainer: Alexander Tarasov <a.tevg@ya.ru>
|
||||||
pkgsubn=pwsp
|
pkgsubn=pwsp
|
||||||
pkgname=pwsp
|
pkgname=pwsp
|
||||||
pkgver=1.9.0
|
pkgver=1.12.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Lets you play audio files through your microphone"
|
pkgdesc="Lets you play audio files through your microphone"
|
||||||
arch=('any')
|
arch=('x86_64' 'aarch64')
|
||||||
url="https://github.com/arabianq/pipewire-soundpad"
|
url="https://github.com/arabianq/pipewire-soundpad"
|
||||||
license=('MIT')
|
license=('MIT')
|
||||||
makedepends=(clang rust cargo cmake pipewire alsa-lib)
|
makedepends=(clang rust cargo cmake pipewire alsa-lib)
|
||||||
@@ -40,8 +40,8 @@ package() {
|
|||||||
install -Dm755 "target/release/pwsp-daemon" "${pkgdir}/usr/bin/pwsp-daemon"
|
install -Dm755 "target/release/pwsp-daemon" "${pkgdir}/usr/bin/pwsp-daemon"
|
||||||
install -Dm755 "target/release/pwsp-gui" "${pkgdir}/usr/bin/pwsp-gui"
|
install -Dm755 "target/release/pwsp-gui" "${pkgdir}/usr/bin/pwsp-gui"
|
||||||
|
|
||||||
install -Dm644 "assets/pwsp-gui.desktop" "${pkgdir}/usr/share/applications/pwsp-gui.desktop"
|
install -Dm644 "pwsp-gui/assets/pwsp-gui.desktop" "${pkgdir}/usr/share/applications/pwsp-gui.desktop"
|
||||||
install -Dm644 "assets/icon.png" "${pkgdir}/usr/share/icons/hicolor/256x256/apps/icon.png"
|
install -Dm644 "pwsp-gui/assets/icon.png" "${pkgdir}/usr/share/icons/hicolor/256x256/apps/pwsp.png"
|
||||||
|
|
||||||
install -Dm644 "assets/pwsp-daemon.service" "${pkgdir}/usr/lib/systemd/user/pwsp-daemon.service"
|
install -Dm644 "pwsp-gui/assets/pwsp-daemon.service" "${pkgdir}/usr/lib/systemd/user/pwsp-daemon.service"
|
||||||
}
|
}
|
||||||
|
|||||||
+959
-712
File diff suppressed because one or more lines are too long
@@ -2,21 +2,26 @@
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) == 2 and sys.argv[1].startswith("soundpad://"):
|
||||||
|
subprocess.Popen(["pwsp-gui", sys.argv[1]])
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="PWSP Flatpak",
|
prog="PWSP Flatpak", add_help=True, exit_on_error=True
|
||||||
add_help=True,
|
|
||||||
exit_on_error=True
|
|
||||||
)
|
)
|
||||||
subparsers = parser.add_subparsers(dest="command")
|
subparsers = parser.add_subparsers(dest="command")
|
||||||
|
|
||||||
cli_parser = subparsers.add_parser("cli", add_help=False, prefix_chars=" ")
|
cli_parser = subparsers.add_parser("cli", add_help=False, prefix_chars=" ")
|
||||||
cli_parser.add_argument("args", nargs=argparse.REMAINDER, help="Arguments for pwsp-cli")
|
cli_parser.add_argument(
|
||||||
|
"args", nargs=argparse.REMAINDER, help="Arguments for pwsp-cli"
|
||||||
|
)
|
||||||
|
|
||||||
daemon_parser = subparsers.add_parser("daemon", add_help=True)
|
daemon_parser = subparsers.add_parser("daemon", add_help=True)
|
||||||
daemon_group = daemon_parser.add_mutually_exclusive_group(required=True)
|
daemon_group = daemon_parser.add_mutually_exclusive_group(required=True)
|
||||||
daemon_group.add_argument("--start", action="store_true", help="Start pwps-daemon")
|
daemon_group.add_argument("--start", action="store_true", help="Start pwsp-daemon")
|
||||||
daemon_group.add_argument("--kill", action="store_true", help="Kill pwsp-daemon")
|
daemon_group.add_argument("--kill", action="store_true", help="Kill pwsp-daemon")
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
@@ -32,4 +37,4 @@ if __name__ == "__main__":
|
|||||||
if args.start:
|
if args.start:
|
||||||
subprocess.Popen("pwsp-daemon")
|
subprocess.Popen("pwsp-daemon")
|
||||||
elif args.kill:
|
elif args.kill:
|
||||||
subprocess.Popen(["pwsp-cli", "action", "kill"])
|
subprocess.Popen(["pwsp-cli", "action", "kill"])
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ Terminal=false
|
|||||||
Type=Application
|
Type=Application
|
||||||
Categories=AudioVideo;Audio;
|
Categories=AudioVideo;Audio;
|
||||||
Keywords=soundpad;pipewire;audio;
|
Keywords=soundpad;pipewire;audio;
|
||||||
|
MimeType=x-scheme-handler/soundpad;
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
<launchable type="desktop-id">ru.arabianq.pwsp.desktop</launchable>
|
<launchable type="desktop-id">ru.arabianq.pwsp.desktop</launchable>
|
||||||
<screenshots>
|
<screenshots>
|
||||||
<screenshot type="default">
|
<screenshot type="default">
|
||||||
<image>https://raw.githubusercontent.com/arabianq/pipewire-soundpad/master/assets/screenshot.png</image>
|
<image>https://raw.githubusercontent.com/arabianq/pipewire-soundpad/main/pwsp-gui/assets/screenshot.png</image>
|
||||||
</screenshot>
|
</screenshot>
|
||||||
</screenshots>
|
</screenshots>
|
||||||
<url type="homepage">https://pwsp.arabianq.ru</url>
|
<url type="homepage">https://pwsp.arabianq.ru</url>
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
<name>arabian</name>
|
<name>arabian</name>
|
||||||
</developer>
|
</developer>
|
||||||
<releases>
|
<releases>
|
||||||
<release version="1.9.0" date="2026-05-15" />
|
<release version="1.12.0" date="2026-06-04" />
|
||||||
</releases>
|
</releases>
|
||||||
<content_rating type="oars-1.1" />
|
<content_rating type="oars-1.1" />
|
||||||
</component>
|
</component>
|
||||||
@@ -35,7 +35,7 @@ modules:
|
|||||||
- install -Dm755 target/release/pwsp-cli /app/bin/pwsp-cli
|
- install -Dm755 target/release/pwsp-cli /app/bin/pwsp-cli
|
||||||
- install -Dm755 target/release/pwsp-gui /app/bin/pwsp-gui
|
- install -Dm755 target/release/pwsp-gui /app/bin/pwsp-gui
|
||||||
- install -Dm755 packages/flatpak/pwsp-wrapper.py /app/bin/pwsp-wrapper.py
|
- install -Dm755 packages/flatpak/pwsp-wrapper.py /app/bin/pwsp-wrapper.py
|
||||||
- install -Dm644 assets/icon.png /app/share/icons/hicolor/256x256/apps/ru.arabianq.pwsp.png
|
- install -Dm644 pwsp-gui/assets/icon.png /app/share/icons/hicolor/256x256/apps/ru.arabianq.pwsp.png
|
||||||
- install -Dm644 packages/flatpak/ru.arabianq.pwsp.desktop /app/share/applications/ru.arabianq.pwsp.desktop
|
- install -Dm644 packages/flatpak/ru.arabianq.pwsp.desktop /app/share/applications/ru.arabianq.pwsp.desktop
|
||||||
- install -Dm644 packages/flatpak/ru.arabianq.pwsp.metainfo.xml /app/share/metainfo/ru.arabianq.pwsp.metainfo.xml
|
- install -Dm644 packages/flatpak/ru.arabianq.pwsp.metainfo.xml /app/share/metainfo/ru.arabianq.pwsp.metainfo.xml
|
||||||
sources:
|
sources:
|
||||||
|
|||||||
@@ -0,0 +1,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 }}}
|
||||||
|
|
||||||
+32
-8
@@ -1,10 +1,19 @@
|
|||||||
%bcond check 1
|
|
||||||
|
|
||||||
# prevent library files from being installed
|
# prevent library files from being installed
|
||||||
%global cargo_install_lib 0
|
%global cargo_install_lib 0
|
||||||
|
|
||||||
|
# Fallback macros for systems without rpmautospec (e.g. openSUSE)
|
||||||
|
%{!?autorelease: %global autorelease 1}
|
||||||
|
%{!?autochangelog: %global autochangelog \
|
||||||
|
* Tue Jun 02 2026 Arabian <arabianq@github> - %{version}-%{release}\
|
||||||
|
- Release build}
|
||||||
|
|
||||||
|
|
||||||
|
# disable debuginfo package generation (debugsourcefiles.list is empty for Rust)
|
||||||
|
%global debug_package %{nil}
|
||||||
|
|
||||||
|
|
||||||
Name: pwsp
|
Name: pwsp
|
||||||
Version: 1.9.0
|
Version: 1.12.0
|
||||||
Release: %autorelease
|
Release: %autorelease
|
||||||
Summary: Lets you play audio files through your microphone
|
Summary: Lets you play audio files through your microphone
|
||||||
|
|
||||||
@@ -16,11 +25,21 @@ Source: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags
|
|||||||
BuildRequires: rust
|
BuildRequires: rust
|
||||||
BuildRequires: cargo
|
BuildRequires: cargo
|
||||||
BuildRequires: pipewire-devel
|
BuildRequires: pipewire-devel
|
||||||
|
%if 0%{?suse_version}
|
||||||
|
BuildRequires: alsa-devel
|
||||||
|
BuildRequires: dbus-1-devel
|
||||||
|
%else
|
||||||
BuildRequires: alsa-lib-devel
|
BuildRequires: alsa-lib-devel
|
||||||
|
BuildRequires: dbus-devel
|
||||||
|
%endif
|
||||||
BuildRequires: clang-devel
|
BuildRequires: clang-devel
|
||||||
BuildRequires: cmake
|
BuildRequires: cmake
|
||||||
BuildRequires: dbus-devel
|
BuildRequires: pkgconfig
|
||||||
BuildRequires: pkgconf-pkg-config
|
%if 0%{?suse_version} && 0%{?suse_version} <= 1500
|
||||||
|
BuildRequires: gcc13-c++
|
||||||
|
%endif
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
%global _description %{expand:
|
%global _description %{expand:
|
||||||
PWSP lets you play audio files through your microphone. Has both CLI and
|
PWSP lets you play audio files through your microphone. Has both CLI and
|
||||||
@@ -32,17 +51,22 @@ GUI clients.}
|
|||||||
%autosetup -n pipewire-soundpad-%{version} -p1
|
%autosetup -n pipewire-soundpad-%{version} -p1
|
||||||
|
|
||||||
%build
|
%build
|
||||||
|
%if 0%{?suse_version} && 0%{?suse_version} <= 1500
|
||||||
|
export CC=gcc-13
|
||||||
|
export CXX=g++-13
|
||||||
|
%endif
|
||||||
cargo build --release --locked
|
cargo build --release --locked
|
||||||
|
|
||||||
|
|
||||||
%install
|
%install
|
||||||
install -Dm755 target/release/pwsp-cli %{buildroot}%{_bindir}/pwsp-cli
|
install -Dm755 target/release/pwsp-cli %{buildroot}%{_bindir}/pwsp-cli
|
||||||
install -Dm755 target/release/pwsp-daemon %{buildroot}%{_bindir}/pwsp-daemon
|
install -Dm755 target/release/pwsp-daemon %{buildroot}%{_bindir}/pwsp-daemon
|
||||||
install -Dm755 target/release/pwsp-gui %{buildroot}%{_bindir}/pwsp-gui
|
install -Dm755 target/release/pwsp-gui %{buildroot}%{_bindir}/pwsp-gui
|
||||||
|
|
||||||
install -Dm644 assets/pwsp-gui.desktop %{buildroot}%{_datadir}/applications/pwsp.desktop
|
install -Dm644 pwsp-gui/assets/pwsp-gui.desktop %{buildroot}%{_datadir}/applications/pwsp.desktop
|
||||||
install -Dm644 assets/icon.png %{buildroot}%{_datadir}/icons/hicolor/256x256/apps/pwsp.png
|
install -Dm644 pwsp-gui/assets/icon.png %{buildroot}%{_datadir}/icons/hicolor/256x256/apps/pwsp.png
|
||||||
|
|
||||||
install -Dm644 assets/pwsp-daemon.service %{buildroot}/usr/lib/systemd/user/pwsp-daemon.service
|
install -Dm644 pwsp-gui/assets/pwsp-daemon.service %{buildroot}/usr/lib/systemd/user/pwsp-daemon.service
|
||||||
|
|
||||||
%files
|
%files
|
||||||
%license LICENSE
|
%license LICENSE
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "pwsp-cli"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
pwsp-lib.workspace = true
|
||||||
|
|
||||||
|
tokio.workspace = true
|
||||||
|
|
||||||
|
anyhow.workspace = true
|
||||||
|
clap.workspace = true
|
||||||
|
|
||||||
|
serde_json.workspace = true
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use anyhow::{Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use pwsp::{
|
use pwsp_lib::{
|
||||||
types::socket::Request,
|
types::socket::Request,
|
||||||
utils::daemon::{make_request, wait_for_daemon},
|
utils::daemon::{make_request, wait_for_daemon},
|
||||||
};
|
};
|
||||||
@@ -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
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use anyhow::{Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
use pwsp::{
|
use pwsp_lib::{
|
||||||
types::socket::{MAX_MESSAGE_SIZE, Request, Response},
|
types::socket::{MAX_MESSAGE_SIZE, Request, Response},
|
||||||
utils::{
|
utils::{
|
||||||
commands::parse_command,
|
commands::parse_command,
|
||||||
@@ -15,7 +15,7 @@ use std::os::unix::fs::PermissionsExt;
|
|||||||
use std::{fs, time::Duration};
|
use std::{fs, time::Duration};
|
||||||
use tokio::{
|
use tokio::{
|
||||||
io::{AsyncReadExt, AsyncWriteExt},
|
io::{AsyncReadExt, AsyncWriteExt},
|
||||||
net::UnixListener,
|
net::{UnixListener, UnixStream},
|
||||||
time::sleep,
|
time::sleep,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -39,7 +39,11 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
let runtime_dir = get_runtime_dir();
|
let runtime_dir = get_runtime_dir();
|
||||||
|
|
||||||
let lock_file = fs::File::create(runtime_dir.join("daemon.lock"))?;
|
let lock_file = fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.write(true)
|
||||||
|
.truncate(false)
|
||||||
|
.open(runtime_dir.join("daemon.lock"))?;
|
||||||
lock_file.lock()?;
|
lock_file.lock()?;
|
||||||
|
|
||||||
let socket_path = runtime_dir.join("daemon.sock");
|
let socket_path = runtime_dir.join("daemon.sock");
|
||||||
@@ -79,83 +83,92 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
async fn commands_loop(listener: UnixListener) -> Result<()> {
|
async fn commands_loop(listener: UnixListener) -> Result<()> {
|
||||||
loop {
|
loop {
|
||||||
let (mut stream, _addr) = listener.accept().await?;
|
let (stream, _addr) = listener.accept().await?;
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
// ---------- Read request (start) ----------
|
handle_connection(stream).await;
|
||||||
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;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
if request_len > MAX_MESSAGE_SIZE {
|
let request_len = u32::from_le_bytes(len_bytes) as usize;
|
||||||
eprintln!(
|
|
||||||
"Failed to read message from client: request too large ({} bytes)!",
|
|
||||||
request_len
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut buffer = vec![0u8; request_len];
|
if request_len > MAX_MESSAGE_SIZE {
|
||||||
if stream.read_exact(&mut buffer).await.is_err() {
|
eprintln!(
|
||||||
eprintln!("Failed to read message from client!");
|
"Failed to read message from client: request too large ({} bytes)!",
|
||||||
return;
|
request_len
|
||||||
}
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let request: Request = match serde_json::from_slice(&buffer) {
|
let mut buffer = Vec::new();
|
||||||
Ok(req) => req,
|
if (&mut stream)
|
||||||
Err(err) => {
|
.take(request_len as u64)
|
||||||
let response =
|
.read_to_end(&mut buffer)
|
||||||
Response::new(false, format!("Failed to parse request: {}", err));
|
.await
|
||||||
let response_data = match serde_json::to_vec(&response) {
|
.is_err()
|
||||||
Ok(data) => data,
|
|| buffer.len() != request_len
|
||||||
Err(_) => return, // Should not happen with this simple Response
|
{
|
||||||
};
|
eprintln!("Failed to read message from client!");
|
||||||
let response_len = response_data.len() as u32;
|
return;
|
||||||
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 request: Request = match serde_json::from_slice(&buffer) {
|
||||||
let command = parse_command(&request);
|
Ok(req) => req,
|
||||||
let response: Response;
|
Err(err) => {
|
||||||
if let Some(command) = command {
|
let response = Response::new(false, format!("Failed to parse request: {}", err));
|
||||||
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) {
|
let response_data = match serde_json::to_vec(&response) {
|
||||||
Ok(data) => data,
|
Ok(data) => data,
|
||||||
Err(err) => {
|
Err(_) => return, // Should not happen with this simple Response
|
||||||
eprintln!("Failed to serialize response: {}", err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let response_len = response_data.len() as u32;
|
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) ----------
|
||||||
|
|
||||||
if stream.write_all(&response_len.to_le_bytes()).await.is_err() {
|
// ---------- Generate response (start) ----------
|
||||||
eprintln!("Failed to write response length to client!");
|
let command = parse_command(&request);
|
||||||
return;
|
let response: Response;
|
||||||
}
|
if let Some(command) = command {
|
||||||
if stream.write_all(&response_data).await.is_err() {
|
response = command.execute().await;
|
||||||
eprintln!("Failed to write response to client!");
|
} else {
|
||||||
return;
|
response = Response::new(false, "Unknown command");
|
||||||
}
|
}
|
||||||
// ---------- Send response (end) ----------
|
// ---------- Generate response (end) ----------
|
||||||
|
|
||||||
if response.status && response.message.eq("killed") {
|
// ---------- Send response (start) ----------
|
||||||
std::process::exit(0);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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]
|
[Desktop Entry]
|
||||||
Name=PWSP (Soundpad)
|
Name=PWSP (Soundpad)
|
||||||
Comment=Let's you play audio files through you microphone
|
Comment=Let's you play audio files through you microphone
|
||||||
Exec=pwsp-gui %u
|
Exec=/usr/bin/pwsp-gui %u
|
||||||
Icon=pwsp
|
Icon=pwsp
|
||||||
Terminal=false
|
Terminal=false
|
||||||
Type=Application
|
Type=Application
|
||||||
Categories=Audio
|
Categories=Audio
|
||||||
|
MimeType=x-scheme-handler/soundpad;
|
||||||
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
@@ -13,6 +13,7 @@ zh = "播放文件"
|
|||||||
ar = "تشغيل الملف"
|
ar = "تشغيل الملف"
|
||||||
kz = "Файлды ойнату"
|
kz = "Файлды ойнату"
|
||||||
he = "נגן קובץ"
|
he = "נגן קובץ"
|
||||||
|
pt-BR = "Reproduzir arquivo"
|
||||||
|
|
||||||
[gui.choose_mic_select]
|
[gui.choose_mic_select]
|
||||||
en = "Select microphone"
|
en = "Select microphone"
|
||||||
@@ -23,6 +24,7 @@ zh = "选择麦克风"
|
|||||||
ar = "اختر الميكروفون"
|
ar = "اختر الميكروفون"
|
||||||
kz = "Микрофонды таңдау"
|
kz = "Микрофонды таңдау"
|
||||||
he = "בחר מיקרופון"
|
he = "בחר מיקרופון"
|
||||||
|
pt-BR = "Selecionar microfone"
|
||||||
|
|
||||||
[gui.search_placeholder]
|
[gui.search_placeholder]
|
||||||
en = "Search files..."
|
en = "Search files..."
|
||||||
@@ -33,6 +35,7 @@ zh = "搜索文件..."
|
|||||||
ar = "البحث عن ملفات..."
|
ar = "البحث عن ملفات..."
|
||||||
kz = "Файлдарды іздеу..."
|
kz = "Файлдарды іздеу..."
|
||||||
he = "חפש קבצים..."
|
he = "חפש קבצים..."
|
||||||
|
pt-BR = "Buscar arquivos..."
|
||||||
|
|
||||||
[gui.context.dirs.open]
|
[gui.context.dirs.open]
|
||||||
en = "Open"
|
en = "Open"
|
||||||
@@ -43,6 +46,7 @@ zh = "打开"
|
|||||||
ar = "فتح"
|
ar = "فتح"
|
||||||
kz = "Ашу"
|
kz = "Ашу"
|
||||||
he = "פתח"
|
he = "פתח"
|
||||||
|
pt-BR = "Abrir"
|
||||||
|
|
||||||
[gui.context.dirs.open_in_fm]
|
[gui.context.dirs.open_in_fm]
|
||||||
en = "Open in File Manager"
|
en = "Open in File Manager"
|
||||||
@@ -53,6 +57,7 @@ zh = "在文件管理器中打开"
|
|||||||
ar = "فتح في مدير الملفات"
|
ar = "فتح في مدير الملفات"
|
||||||
kz = "Файл менеджерінде ашу"
|
kz = "Файл менеджерінде ашу"
|
||||||
he = "פתח במנהל הקבצים"
|
he = "פתח במנהל הקבצים"
|
||||||
|
pt-BR = "Abrir no gestor de arquivos"
|
||||||
|
|
||||||
[gui.context.dirs.remove]
|
[gui.context.dirs.remove]
|
||||||
en = "Remove"
|
en = "Remove"
|
||||||
@@ -63,6 +68,62 @@ zh = "移除"
|
|||||||
ar = "إزالة"
|
ar = "إزالة"
|
||||||
kz = "Жою"
|
kz = "Жою"
|
||||||
he = "הסר"
|
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]
|
[gui.context.files.play_solo]
|
||||||
en = "Play Solo"
|
en = "Play Solo"
|
||||||
@@ -73,6 +134,7 @@ zh = "单独播放"
|
|||||||
ar = "تشغيل منفرد"
|
ar = "تشغيل منفرد"
|
||||||
kz = "Жалғыз ойнату"
|
kz = "Жалғыз ойнату"
|
||||||
he = "נגן סולו"
|
he = "נגן סולו"
|
||||||
|
pt-BR = "Reproduzir"
|
||||||
|
|
||||||
[gui.context.files.add_new]
|
[gui.context.files.add_new]
|
||||||
en = "Add New"
|
en = "Add New"
|
||||||
@@ -83,6 +145,7 @@ zh = "添加新项"
|
|||||||
ar = "إضافة جديد"
|
ar = "إضافة جديد"
|
||||||
kz = "Жаңасын қосу"
|
kz = "Жаңасын қосу"
|
||||||
he = "הוסף חדש"
|
he = "הוסף חדש"
|
||||||
|
pt-BR = "Adicionar"
|
||||||
|
|
||||||
[gui.context.files.replace_last]
|
[gui.context.files.replace_last]
|
||||||
en = "Replace Last"
|
en = "Replace Last"
|
||||||
@@ -93,6 +156,7 @@ zh = "替换上一个"
|
|||||||
ar = "استبدال الأخير"
|
ar = "استبدال الأخير"
|
||||||
kz = "Соңғысын ауыстыру"
|
kz = "Соңғысын ауыстыру"
|
||||||
he = "החלף אחרון"
|
he = "החלף אחרון"
|
||||||
|
pt-BR = "Substituir"
|
||||||
|
|
||||||
[gui.context.files.show_in_fm]
|
[gui.context.files.show_in_fm]
|
||||||
en = "Show in File Manager"
|
en = "Show in File Manager"
|
||||||
@@ -103,9 +167,10 @@ zh = "在文件管理器中显示"
|
|||||||
ar = "عرض في مدير الملفات"
|
ar = "عرض في مدير الملفات"
|
||||||
kz = "Файл менеджерінде көрсету"
|
kz = "Файл менеджерінде көрсету"
|
||||||
he = "הצג במנהל הקבצים"
|
he = "הצג במנהל הקבצים"
|
||||||
|
pt-BR = "Mostrar no gestor de arquivos"
|
||||||
|
|
||||||
[gui.context.files.asign_hotkey]
|
[gui.context.files.asign_hotkey]
|
||||||
en = "Asign Hotkey"
|
en = "Assign Hotkey"
|
||||||
ru = "Назначить Горячую Клавишу"
|
ru = "Назначить Горячую Клавишу"
|
||||||
es = "Asignar atajo"
|
es = "Asignar atajo"
|
||||||
fr = "Assigner un raccourci"
|
fr = "Assigner un raccourci"
|
||||||
@@ -113,6 +178,18 @@ zh = "分配快捷键"
|
|||||||
ar = "تعيين مفتاح اختصار"
|
ar = "تعيين مفتاح اختصار"
|
||||||
kz = "Ыстық пернені тағайындау"
|
kz = "Ыстық пернені тағайындау"
|
||||||
he = "הקצה מקש קיצור"
|
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
|
# Settings
|
||||||
@@ -127,6 +204,7 @@ zh = "设置"
|
|||||||
ar = "الإعدادات"
|
ar = "الإعدادات"
|
||||||
kz = "Баптаулар"
|
kz = "Баптаулар"
|
||||||
he = "הגדרות"
|
he = "הגדרות"
|
||||||
|
pt-BR = "Configurações"
|
||||||
|
|
||||||
[gui.settings.remember_volume]
|
[gui.settings.remember_volume]
|
||||||
en = "Always remember volume"
|
en = "Always remember volume"
|
||||||
@@ -137,6 +215,7 @@ zh = "始终记住音量"
|
|||||||
ar = "تذكر مستوى الصوت دائمًا"
|
ar = "تذكر مستوى الصوت دائمًا"
|
||||||
kz = "Әрқашан дыбыс деңгейін есте сақтау"
|
kz = "Әрқашан дыбыс деңгейін есте сақтау"
|
||||||
he = "זכור תמיד עוצמת קול"
|
he = "זכור תמיד עוצמת קול"
|
||||||
|
pt-BR = "Lembrar volume"
|
||||||
|
|
||||||
[gui.settings.remember_mic]
|
[gui.settings.remember_mic]
|
||||||
en = "Always remember microphone"
|
en = "Always remember microphone"
|
||||||
@@ -147,6 +226,7 @@ zh = "始终记住麦克风"
|
|||||||
ar = "تذكر الميكروفون دائمًا"
|
ar = "تذكر الميكروفون دائمًا"
|
||||||
kz = "Әрқашан микрофонды есте сақтау"
|
kz = "Әрқашан микрофонды есте сақтау"
|
||||||
he = "זכור תמיד מיקרופון"
|
he = "זכור תמיד מיקרופון"
|
||||||
|
pt-BR = "Lembrar microfone"
|
||||||
|
|
||||||
[gui.settings.remember_ui_scale]
|
[gui.settings.remember_ui_scale]
|
||||||
en = "Always remember UI scale factor"
|
en = "Always remember UI scale factor"
|
||||||
@@ -157,6 +237,7 @@ zh = "始终记住界面缩放比例"
|
|||||||
ar = "تذكر عامل تكبير الواجهة دائمًا"
|
ar = "تذكر عامل تكبير الواجهة دائمًا"
|
||||||
kz = "Әрқашан интерфейс масштабын есте сақтау"
|
kz = "Әрқашан интерфейс масштабын есте сақтау"
|
||||||
he = "זכור תמיד קנה מידה של ממשק משתמש"
|
he = "זכור תמיד קנה מידה של ממשק משתמש"
|
||||||
|
pt-BR = "Lembrar fator de escala da interface"
|
||||||
|
|
||||||
[gui.settings.pause_on_window_close]
|
[gui.settings.pause_on_window_close]
|
||||||
en = "Pause audio playback when the window is closed"
|
en = "Pause audio playback when the window is closed"
|
||||||
@@ -167,6 +248,7 @@ zh = "关闭窗口时暂停音频播放"
|
|||||||
ar = "إيقاف الصوت مؤقتًا عند إغلاق النافذة"
|
ar = "إيقاف الصوت مؤقتًا عند إغلاق النافذة"
|
||||||
kz = "Терезе жабылған кезде дыбысты ойнатуды кідірту"
|
kz = "Терезе жабылған кезде дыбысты ойнатуды кідірту"
|
||||||
he = "השהה השמעת שמע כאשר החלון נסגר"
|
he = "השהה השמעת שמע כאשר החלון נסגר"
|
||||||
|
pt-BR = "Pausar reprodução de aúdio ao fechar a janela"
|
||||||
|
|
||||||
[gui.settings.version]
|
[gui.settings.version]
|
||||||
en = "GUI version: %{version}"
|
en = "GUI version: %{version}"
|
||||||
@@ -177,6 +259,51 @@ zh = "GUI 版本: %{version}"
|
|||||||
ar = "إصدار الواجهة: %{version}"
|
ar = "إصدار الواجهة: %{version}"
|
||||||
kz = "GUI нұсқасы: %{version}"
|
kz = "GUI нұсқасы: %{version}"
|
||||||
he = "גרסת ממשק משתמש: %{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
|
# Hotkeys
|
||||||
@@ -191,6 +318,7 @@ zh = "快捷键"
|
|||||||
ar = "اختصارات لوحة المفاتيح"
|
ar = "اختصارات لوحة المفاتيح"
|
||||||
kz = "Ыстық пернелер"
|
kz = "Ыстық пернелер"
|
||||||
he = "מקשי קיצור"
|
he = "מקשי קיצור"
|
||||||
|
pt-BR = "Atalhos"
|
||||||
|
|
||||||
[gui.hotkeys.search_placeholder]
|
[gui.hotkeys.search_placeholder]
|
||||||
en = "Search hotkeys..."
|
en = "Search hotkeys..."
|
||||||
@@ -201,6 +329,7 @@ zh = "搜索快捷键..."
|
|||||||
ar = "البحث عن الاختصارات..."
|
ar = "البحث عن الاختصارات..."
|
||||||
kz = "Ыстық пернелерді іздеу..."
|
kz = "Ыстық пернелерді іздеу..."
|
||||||
he = "חפש מקשי קיצור..."
|
he = "חפש מקשי קיצור..."
|
||||||
|
pt-BR = "Buscar atalhos..."
|
||||||
|
|
||||||
[gui.hotkeys.add_command_select]
|
[gui.hotkeys.add_command_select]
|
||||||
en = "Add Command"
|
en = "Add Command"
|
||||||
@@ -211,6 +340,7 @@ zh = "添加命令"
|
|||||||
ar = "إضافة أمر"
|
ar = "إضافة أمر"
|
||||||
kz = "Команда қосу"
|
kz = "Команда қосу"
|
||||||
he = "הוסף פקודה"
|
he = "הוסף פקודה"
|
||||||
|
pt-BR = "Adicionar comando"
|
||||||
|
|
||||||
[gui.hotkeys.toggle_pause_command]
|
[gui.hotkeys.toggle_pause_command]
|
||||||
en = "Toggle Pause"
|
en = "Toggle Pause"
|
||||||
@@ -221,6 +351,7 @@ zh = "切换暂停"
|
|||||||
ar = "تبديل الإيقاف المؤقت"
|
ar = "تبديل الإيقاف المؤقت"
|
||||||
kz = "Кідіртуді ауыстыру"
|
kz = "Кідіртуді ауыстыру"
|
||||||
he = "הפעל/השהה"
|
he = "הפעל/השהה"
|
||||||
|
pt-BR = "Alternar reprodução"
|
||||||
|
|
||||||
[gui.hotkeys.stop_playback_command]
|
[gui.hotkeys.stop_playback_command]
|
||||||
en = "Stop Playback"
|
en = "Stop Playback"
|
||||||
@@ -231,6 +362,7 @@ zh = "停止播放"
|
|||||||
ar = "إيقاف التشغيل"
|
ar = "إيقاف التشغيل"
|
||||||
kz = "Ойнатуды тоқтату"
|
kz = "Ойнатуды тоқтату"
|
||||||
he = "עצור השמעה"
|
he = "עצור השמעה"
|
||||||
|
pt-BR = "Parar reprodução"
|
||||||
|
|
||||||
[gui.hotkeys.pause_playback_command]
|
[gui.hotkeys.pause_playback_command]
|
||||||
en = "Pause Playback"
|
en = "Pause Playback"
|
||||||
@@ -241,6 +373,7 @@ zh = "暂停播放"
|
|||||||
ar = "إيقاف التشغيل مؤقتاً"
|
ar = "إيقاف التشغيل مؤقتاً"
|
||||||
kz = "Ойнатуды кідірту"
|
kz = "Ойнатуды кідірту"
|
||||||
he = "השהה השמעה"
|
he = "השהה השמעה"
|
||||||
|
pt-BR = "Pausar reprodução"
|
||||||
|
|
||||||
[gui.hotkeys.resume_playback_command]
|
[gui.hotkeys.resume_playback_command]
|
||||||
en = "Resume Playback"
|
en = "Resume Playback"
|
||||||
@@ -251,6 +384,7 @@ zh = "恢复播放"
|
|||||||
ar = "استئناف التشغيل"
|
ar = "استئناف التشغيل"
|
||||||
kz = "Ойнатуды жалғастыру"
|
kz = "Ойнатуды жалғастыру"
|
||||||
he = "המשך השמעה"
|
he = "המשך השמעה"
|
||||||
|
pt-BR = "Resumir reprodução"
|
||||||
|
|
||||||
[gui.hotkeys.toggle_loop_command]
|
[gui.hotkeys.toggle_loop_command]
|
||||||
en = "Toggle Loop"
|
en = "Toggle Loop"
|
||||||
@@ -261,6 +395,7 @@ zh = "切换循环"
|
|||||||
ar = "تبديل التكرار"
|
ar = "تبديل التكرار"
|
||||||
kz = "Қайталауды ауыстыру"
|
kz = "Қайталауды ауыстыру"
|
||||||
he = "הפעל/כבה לולאה"
|
he = "הפעל/כבה לולאה"
|
||||||
|
pt-BR = "Alternar loop"
|
||||||
|
|
||||||
[gui.hotkeys.column_slot]
|
[gui.hotkeys.column_slot]
|
||||||
en = "Slot"
|
en = "Slot"
|
||||||
@@ -271,6 +406,7 @@ zh = "插槽"
|
|||||||
ar = "الخانة"
|
ar = "الخانة"
|
||||||
kz = "Ұяшық"
|
kz = "Ұяшық"
|
||||||
he = "משבצת"
|
he = "משבצת"
|
||||||
|
pt-BR = "Slot"
|
||||||
|
|
||||||
[gui.hotkeys.column_sound]
|
[gui.hotkeys.column_sound]
|
||||||
en = "Sound"
|
en = "Sound"
|
||||||
@@ -281,6 +417,7 @@ zh = "声音"
|
|||||||
ar = "الصوت"
|
ar = "الصوت"
|
||||||
kz = "Дыбыс"
|
kz = "Дыбыс"
|
||||||
he = "צליל"
|
he = "צליל"
|
||||||
|
pt-BR = "Som"
|
||||||
|
|
||||||
[gui.hotkeys.column_key_chord]
|
[gui.hotkeys.column_key_chord]
|
||||||
en = "Key Chord"
|
en = "Key Chord"
|
||||||
@@ -291,6 +428,7 @@ zh = "组合键"
|
|||||||
ar = "تركيبة المفاتيح"
|
ar = "تركيبة المفاتيح"
|
||||||
kz = "Пернелер тіркесімі"
|
kz = "Пернелер тіркесімі"
|
||||||
he = "צירוף מקשים"
|
he = "צירוף מקשים"
|
||||||
|
pt-BR = "Combinação de teclas"
|
||||||
|
|
||||||
[gui.hotkeys.column_actions]
|
[gui.hotkeys.column_actions]
|
||||||
en = "Actions"
|
en = "Actions"
|
||||||
@@ -301,6 +439,7 @@ zh = "操作"
|
|||||||
ar = "الإجراءات"
|
ar = "الإجراءات"
|
||||||
kz = "Әрекеттер"
|
kz = "Әрекеттер"
|
||||||
he = "פעולות"
|
he = "פעולות"
|
||||||
|
pt-BR = "Ações"
|
||||||
|
|
||||||
[gui.hotkeys.no_hotkeys_configured]
|
[gui.hotkeys.no_hotkeys_configured]
|
||||||
en = "No hotkeys configured"
|
en = "No hotkeys configured"
|
||||||
@@ -311,6 +450,7 @@ zh = "未配置快捷键"
|
|||||||
ar = "لا توجد اختصارات معينة"
|
ar = "لا توجد اختصارات معينة"
|
||||||
kz = "Ыстық пернелер бапталмаған"
|
kz = "Ыстық пернелер бапталмаған"
|
||||||
he = "לא הוגדרו מקשי קיצור"
|
he = "לא הוגדרו מקשי קיצור"
|
||||||
|
pt-BR = "Nenhum atalho configurado"
|
||||||
|
|
||||||
[gui.hotkeys.capture.header]
|
[gui.hotkeys.capture.header]
|
||||||
en = "Press a key combination (e.g. Ctrl+Alt+1)"
|
en = "Press a key combination (e.g. Ctrl+Alt+1)"
|
||||||
@@ -321,6 +461,7 @@ zh = "按下一个组合键 (例如 Ctrl+Alt+1)"
|
|||||||
ar = "اضغط على تركيبة مفاتيح (مثلاً Ctrl+Alt+1)"
|
ar = "اضغط على تركيبة مفاتيح (مثلاً Ctrl+Alt+1)"
|
||||||
kz = "Пернелер тіркесімін басыңыз (мысалы, Ctrl+Alt+1)"
|
kz = "Пернелер тіркесімін басыңыз (мысалы, Ctrl+Alt+1)"
|
||||||
he = "לחץ על צירוף מקשים (למשל Ctrl+Alt+1)"
|
he = "לחץ על צירוף מקשים (למשל Ctrl+Alt+1)"
|
||||||
|
pt-BR = "Pressione uma combinação de tecla (ex: Ctrl+Alt+1)"
|
||||||
|
|
||||||
[gui.hotkeys.capture.for]
|
[gui.hotkeys.capture.for]
|
||||||
en = "for"
|
en = "for"
|
||||||
@@ -331,9 +472,10 @@ zh = "用于"
|
|||||||
ar = "لـ"
|
ar = "لـ"
|
||||||
kz = "үшін"
|
kz = "үшін"
|
||||||
he = "עבור"
|
he = "עבור"
|
||||||
|
pt-BR = "para"
|
||||||
|
|
||||||
[gui.hotkeys.capture.cancel]
|
[gui.hotkeys.capture.cancel]
|
||||||
en = "Press Escape to canel"
|
en = "Press Escape to cancel"
|
||||||
ru = "Нажмите Escape для отмены"
|
ru = "Нажмите Escape для отмены"
|
||||||
es = "Presione Escape para cancelar"
|
es = "Presione Escape para cancelar"
|
||||||
fr = "Appuyez sur Échap pour annuler"
|
fr = "Appuyez sur Échap pour annuler"
|
||||||
@@ -341,3 +483,4 @@ zh = "按 Escape 取消"
|
|||||||
ar = "اضغط Esc للإلغاء"
|
ar = "اضغط Esc للإلغاء"
|
||||||
kz = "Болдырмау үшін Escape пернесін басыңыз"
|
kz = "Болдырмау үшін Escape пернесін басыңыз"
|
||||||
he = "לחץ על Escape לביטול"
|
he = "לחץ על Escape לביטול"
|
||||||
|
pt-BR = "Pressione Esc para cancelar"
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::gui::SoundpadGui;
|
use crate::gui::SoundpadGui;
|
||||||
use egui::{Context, Id, Key, Modifiers};
|
use egui::{Context, Id, Key, Modifiers};
|
||||||
use pwsp::types::socket::Request;
|
use pwsp_lib::types::socket::Request;
|
||||||
use pwsp::utils::gui::make_request_async;
|
use pwsp_lib::utils::gui::make_request_async;
|
||||||
|
|
||||||
/// Convert an egui Key + Modifiers to a normalized chord string like "Ctrl+Shift+A".
|
/// Convert an egui Key + Modifiers to a normalized chord string like "Ctrl+Shift+A".
|
||||||
fn chord_from_event(modifiers: &Modifiers, key: &Key) -> Option<String> {
|
fn chord_from_event(modifiers: &Modifiers, key: &Key) -> Option<String> {
|
||||||
@@ -217,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,12 +1,12 @@
|
|||||||
mod draw;
|
|
||||||
mod input;
|
mod input;
|
||||||
mod update;
|
mod update;
|
||||||
|
mod views;
|
||||||
|
|
||||||
use anyhow::{Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
use eframe::{HardwareAcceleration, NativeOptions, icon_data::from_png_bytes, run_native};
|
use eframe::{HardwareAcceleration, NativeOptions, icon_data::from_png_bytes, run_native};
|
||||||
use egui::{Context, FontData, FontDefinitions, FontFamily, FontTweak, Vec2, ViewportBuilder};
|
use egui::{Context, FontData, FontDefinitions, FontFamily, FontTweak, Vec2, ViewportBuilder};
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use pwsp::{
|
use pwsp_lib::{
|
||||||
types::{
|
types::{
|
||||||
audio_player::PlayerState,
|
audio_player::PlayerState,
|
||||||
config::GuiConfig,
|
config::GuiConfig,
|
||||||
@@ -159,6 +159,13 @@ impl SoundpadGui {
|
|||||||
|
|
||||||
pub fn get_filtered_files(&self) -> Vec<PathBuf> {
|
pub fn get_filtered_files(&self) -> Vec<PathBuf> {
|
||||||
let mut files: Vec<PathBuf> = self.app_state.listed_files.iter().cloned().collect();
|
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| {
|
files.sort_by(|a, b| {
|
||||||
let a_is_dir = a.is_dir();
|
let a_is_dir = a.is_dir();
|
||||||
let b_is_dir = b.is_dir();
|
let b_is_dir = b.is_dir();
|
||||||
@@ -167,7 +174,7 @@ impl SoundpadGui {
|
|||||||
} else if !a_is_dir && b_is_dir {
|
} else if !a_is_dir && b_is_dir {
|
||||||
Ordering::Greater
|
Ordering::Greater
|
||||||
} else {
|
} else {
|
||||||
a.cmp(b)
|
sort_order.compare(a, b)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -294,3 +301,59 @@ pub async fn run() -> Result<()> {
|
|||||||
Err(e) => Err(anyhow!(e.to_string())),
|
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 crate::gui::SoundpadGui;
|
||||||
use eframe::{App, Frame as EFrame};
|
use eframe::{App, Frame as EFrame};
|
||||||
use egui::{CentralPanel, Context};
|
use egui::{CentralPanel, Context, ThemePreference};
|
||||||
use pwsp::{
|
use pwsp_lib::{
|
||||||
types::socket::Request,
|
types::{config::PreferredTheme, socket::Request},
|
||||||
utils::{daemon::get_daemon_config, gui::make_request_async},
|
utils::{daemon::get_daemon_config, gui::make_request_async},
|
||||||
};
|
};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
impl App for SoundpadGui {
|
impl App for SoundpadGui {
|
||||||
fn logic(&mut self, ctx: &Context, _frame: &mut EFrame) {
|
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
|
// Remove directories
|
||||||
for path in self.app_state.dirs_to_remove.drain() {
|
for path in self.app_state.dirs_to_remove.drain() {
|
||||||
self.app_state.dirs.retain(|x| x != &path);
|
self.app_state.dirs.retain(|x| x != &path);
|
||||||
@@ -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
|
||||||
@@ -95,7 +95,9 @@ impl AudioPlayer {
|
|||||||
sink.log_on_drop(false);
|
sink.log_on_drop(false);
|
||||||
self.stream_handle = Some(sink);
|
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) {
|
fn drop_stream(&mut self) {
|
||||||
@@ -347,7 +349,11 @@ impl AudioPlayer {
|
|||||||
|
|
||||||
let duration = source.total_duration().map(|d| d.as_secs_f32());
|
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);
|
let sink = Player::connect_new(mixer);
|
||||||
sink.set_volume(self.volume); // Default volume is 1.0 * master
|
sink.set_volume(self.volume); // Default volume is 1.0 * master
|
||||||
sink.append(source);
|
sink.append(source);
|
||||||
@@ -1,7 +1,16 @@
|
|||||||
use crate::{types::socket::Request, utils::config::get_config_path};
|
use crate::{
|
||||||
|
types::socket::Request,
|
||||||
|
utils::{config::get_config_path, gui::ensure_pwsp_audio_dir},
|
||||||
|
};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::{collections::HashMap, fs, path::PathBuf};
|
use std::{
|
||||||
|
cmp::Ordering,
|
||||||
|
collections::HashMap,
|
||||||
|
fs,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
time::SystemTime,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Default, Clone, Serialize, Deserialize)]
|
#[derive(Default, Clone, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -35,6 +44,28 @@ impl DaemonConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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)]
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct GuiConfig {
|
pub struct GuiConfig {
|
||||||
@@ -47,6 +78,36 @@ pub struct GuiConfig {
|
|||||||
pub pause_on_exit: bool,
|
pub pause_on_exit: bool,
|
||||||
|
|
||||||
pub dirs: Vec<PathBuf>,
|
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 {
|
impl Default for GuiConfig {
|
||||||
@@ -60,12 +121,26 @@ impl Default for GuiConfig {
|
|||||||
save_scale_factor: false,
|
save_scale_factor: false,
|
||||||
pause_on_exit: false,
|
pause_on_exit: false,
|
||||||
|
|
||||||
dirs: vec![],
|
dirs: vec![ensure_pwsp_audio_dir()],
|
||||||
|
|
||||||
|
preferred_theme: PreferredTheme::System,
|
||||||
|
dirs_settings: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GuiConfig {
|
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<()> {
|
pub fn save_to_file(&mut self) -> Result<()> {
|
||||||
let config_path = get_config_path()?.join("gui.json");
|
let config_path = get_config_path()?.join("gui.json");
|
||||||
|
|
||||||
@@ -204,3 +279,82 @@ impl HotkeyConfig {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_gui_config_default() {
|
||||||
|
let config = GuiConfig::default();
|
||||||
|
assert_eq!(config.scale_factor, 1.0);
|
||||||
|
assert_eq!(config.left_panel_width, 280.0);
|
||||||
|
assert!(!config.save_volume);
|
||||||
|
assert_eq!(config.preferred_theme, PreferredTheme::System);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_hotkey_config_operations() {
|
||||||
|
let mut config = HotkeyConfig::default();
|
||||||
|
assert!(config.slots.is_empty());
|
||||||
|
|
||||||
|
let req = Request::ping();
|
||||||
|
config.set_slot("slot1".to_string(), req.clone());
|
||||||
|
assert_eq!(config.slots.len(), 1);
|
||||||
|
assert_eq!(config.slots[0].slot, "slot1");
|
||||||
|
assert_eq!(config.slots[0].action, req);
|
||||||
|
assert!(config.slots[0].key_chord.is_none());
|
||||||
|
|
||||||
|
// Test find_slot
|
||||||
|
let found = config.find_slot("slot1");
|
||||||
|
assert!(found.is_some());
|
||||||
|
assert_eq!(found.unwrap().slot, "slot1");
|
||||||
|
|
||||||
|
// Test set_key_chord
|
||||||
|
let updated = config.set_key_chord("slot1", Some("Ctrl+A".to_string()));
|
||||||
|
assert!(updated);
|
||||||
|
assert_eq!(config.slots[0].key_chord.as_deref(), Some("Ctrl+A"));
|
||||||
|
|
||||||
|
// Test set_key_chord for non-existent slot
|
||||||
|
let updated_non_existent = config.set_key_chord("slot2", Some("Ctrl+B".to_string()));
|
||||||
|
assert!(!updated_non_existent);
|
||||||
|
|
||||||
|
// Test find_slot_mut
|
||||||
|
let found_mut = config.find_slot_mut("slot1");
|
||||||
|
assert!(found_mut.is_some());
|
||||||
|
found_mut.unwrap().key_chord = Some("Ctrl+B".to_string());
|
||||||
|
assert_eq!(config.slots[0].key_chord.as_deref(), Some("Ctrl+B"));
|
||||||
|
|
||||||
|
// Test slots_for_chord
|
||||||
|
let slots = config.slots_for_chord("Ctrl+B");
|
||||||
|
assert_eq!(slots.len(), 1);
|
||||||
|
assert_eq!(slots[0].slot, "slot1");
|
||||||
|
|
||||||
|
let empty_slots = config.slots_for_chord("Ctrl+A");
|
||||||
|
assert!(empty_slots.is_empty());
|
||||||
|
|
||||||
|
// Test remove_slot
|
||||||
|
let removed = config.remove_slot("slot1");
|
||||||
|
assert!(removed);
|
||||||
|
assert!(config.slots.is_empty());
|
||||||
|
|
||||||
|
let removed_non_existent = config.remove_slot("slot1");
|
||||||
|
assert!(!removed_non_existent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_hotkey_config_conflicts() {
|
||||||
|
let mut config = HotkeyConfig::default();
|
||||||
|
config.set_slot("slot1".to_string(), Request::ping());
|
||||||
|
config.set_slot("slot2".to_string(), Request::ping());
|
||||||
|
config.set_slot("slot3".to_string(), Request::ping());
|
||||||
|
|
||||||
|
config.set_key_chord("slot1", Some("Ctrl+A".to_string()));
|
||||||
|
config.set_key_chord("slot2", Some("Ctrl+A".to_string())); // Conflict with slot1
|
||||||
|
config.set_key_chord("slot3", Some("Ctrl+B".to_string()));
|
||||||
|
|
||||||
|
let conflicts = config.find_conflicts();
|
||||||
|
assert_eq!(conflicts.len(), 1);
|
||||||
|
assert!(conflicts.contains(&("slot1", "slot2")) || conflicts.contains(&("slot2", "slot1")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,9 @@ use crate::types::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::{error::Error, fs};
|
use std::{env, error::Error, fs};
|
||||||
use tokio::{
|
use tokio::{
|
||||||
io::{AsyncReadExt, AsyncWriteExt},
|
io::{AsyncReadExt, AsyncWriteExt},
|
||||||
net::UnixStream,
|
net::UnixStream,
|
||||||
@@ -37,22 +37,52 @@ pub fn get_daemon_config() -> DaemonConfig {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_current_uid() -> u32 {
|
||||||
|
rustix::process::geteuid().as_raw()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_runtime_dir() -> PathBuf {
|
pub fn get_runtime_dir() -> PathBuf {
|
||||||
dirs::runtime_dir().unwrap_or(PathBuf::from("/run/pwsp"))
|
dirs::runtime_dir().unwrap_or_else(|| {
|
||||||
|
let uid = get_current_uid();
|
||||||
|
env::temp_dir().join(format!("pwsp-{}", uid))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_runtime_dir() -> Result<()> {
|
pub fn create_runtime_dir() -> Result<()> {
|
||||||
let runtime_dir = get_runtime_dir();
|
let runtime_dir = get_runtime_dir();
|
||||||
if !runtime_dir.exists() {
|
|
||||||
fs::create_dir_all(&runtime_dir)?;
|
if runtime_dir.exists() {
|
||||||
|
let meta = fs::symlink_metadata(&runtime_dir)?;
|
||||||
|
if meta.is_symlink() {
|
||||||
|
return Err(anyhow::anyhow!("Runtime directory is a symlink"));
|
||||||
|
}
|
||||||
|
let uid = get_current_uid();
|
||||||
|
if meta.uid() != uid {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Runtime directory is owned by another user"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if meta.permissions().mode() & 0o777 != 0o700 {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Runtime directory has incorrect permissions"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fs::DirBuilder::new()
|
||||||
|
.recursive(true)
|
||||||
|
.mode(0o700)
|
||||||
|
.create(&runtime_dir)?;
|
||||||
}
|
}
|
||||||
fs::set_permissions(&runtime_dir, fs::Permissions::from_mode(0o700))?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_daemon_running() -> Result<bool> {
|
pub fn is_daemon_running() -> Result<bool> {
|
||||||
let lock_file = fs::File::create(get_runtime_dir().join("daemon.lock"))?;
|
let lock_file = fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.write(true)
|
||||||
|
.truncate(false)
|
||||||
|
.open(get_runtime_dir().join("daemon.lock"))?;
|
||||||
match lock_file.try_lock() {
|
match lock_file.try_lock() {
|
||||||
Ok(_) => Ok(false),
|
Ok(_) => Ok(false),
|
||||||
Err(_) => Ok(true),
|
Err(_) => Ok(true),
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ use crate::{
|
|||||||
};
|
};
|
||||||
use anyhow::{Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
use std::{
|
use std::{
|
||||||
|
path::PathBuf,
|
||||||
sync::{Arc, Mutex},
|
sync::{Arc, Mutex},
|
||||||
time::Instant,
|
time::Instant,
|
||||||
};
|
};
|
||||||
@@ -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 {
|
pub fn format_time_pair(position: f32, duration: f32) -> String {
|
||||||
fn format_time(seconds: f32) -> String {
|
fn format_time(seconds: f32) -> String {
|
||||||
let total_seconds = seconds.round() as u32;
|
let total_seconds = seconds.round() as u32;
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,73 +20,61 @@ pub fn setup_pipewire_context() -> Result<(MainLoopRc, ContextRc), String> {
|
|||||||
fn parse_global_object(
|
fn parse_global_object(
|
||||||
global_object: &GlobalObject<&DictRef>,
|
global_object: &GlobalObject<&DictRef>,
|
||||||
) -> (Option<AudioDevice>, Option<Port>) {
|
) -> (Option<AudioDevice>, Option<Port>) {
|
||||||
// Only objects with props can be devices/ports
|
let props = match global_object.props {
|
||||||
if let Some(props) = global_object.props {
|
Some(p) => p,
|
||||||
// Only objects with media.class can be devices
|
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");
|
|
||||||
|
|
||||||
// Check if the device is an input or output
|
if let Some(media_class) = props.get("media.class") {
|
||||||
return if media_class.starts_with("Audio/Source") {
|
let node_id = global_object.id;
|
||||||
let input_device = AudioDevice {
|
let node_nick = props.get("node.nick");
|
||||||
id: node_id,
|
let node_name = props.get("node.name");
|
||||||
nick: node_nick
|
let node_description = props.get("node.description");
|
||||||
.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,
|
if media_class.starts_with("Audio/Source") {
|
||||||
input_fr: None,
|
let input_device = AudioDevice::new(
|
||||||
output_fl: None,
|
|
||||||
output_fr: None,
|
|
||||||
};
|
|
||||||
(Some(input_device), None)
|
|
||||||
} else if media_class.starts_with("Stream/Output/Audio") {
|
|
||||||
let output_device = AudioDevice {
|
|
||||||
id: node_id,
|
|
||||||
nick: node_nick
|
|
||||||
.unwrap_or(node_description.unwrap_or(node_name.unwrap_or_default()))
|
|
||||||
.to_string(),
|
|
||||||
name: node_name.unwrap_or_default().to_string(),
|
|
||||||
device_type: DeviceType::Output,
|
|
||||||
|
|
||||||
input_fl: None,
|
|
||||||
input_fr: None,
|
|
||||||
output_fl: None,
|
|
||||||
output_fr: None,
|
|
||||||
};
|
|
||||||
(Some(output_device), None)
|
|
||||||
} else {
|
|
||||||
(None, None)
|
|
||||||
};
|
|
||||||
// Check if the object is a port
|
|
||||||
} else if props.get("port.direction").is_some()
|
|
||||||
&& let (Some(node_id), Some(port_id), Some(port_name)) = (
|
|
||||||
props.get("node.id").and_then(|id| id.parse::<u32>().ok()),
|
|
||||||
props.get("port.id").and_then(|id| id.parse::<u32>().ok()),
|
|
||||||
props.get("port.name"),
|
|
||||||
)
|
|
||||||
{
|
|
||||||
let port = Port {
|
|
||||||
node_id,
|
node_id,
|
||||||
port_id,
|
node_nick,
|
||||||
name: port_name.to_string(),
|
node_description,
|
||||||
};
|
node_name,
|
||||||
|
DeviceType::Input,
|
||||||
return (None, Some(port));
|
);
|
||||||
|
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)
|
(None, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn pw_get_global_objects_thread(
|
async fn pw_get_global_objects_thread(
|
||||||
main_sender: mpsc::Sender<(Option<AudioDevice>, Option<Port>)>,
|
main_sender: mpsc::Sender<(Option<AudioDevice>, Option<Port>)>,
|
||||||
pw_receiver: pipewire::channel::Receiver<Terminate>,
|
pw_receiver: pipewire::channel::Receiver<Terminate>,
|
||||||
init_sender: std::sync::mpsc::SyncSender<Result<(), String>>,
|
init_sender: tokio::sync::oneshot::Sender<Result<(), String>>,
|
||||||
) {
|
) {
|
||||||
let (main_loop, context) = match setup_pipewire_context() {
|
let (main_loop, context) = match setup_pipewire_context() {
|
||||||
Ok(res) => res,
|
Ok(res) => res,
|
||||||
@@ -147,7 +135,7 @@ pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
|
|||||||
// Channels to communicate with pipewire thread
|
// Channels to communicate with pipewire thread
|
||||||
let (main_sender, mut main_receiver) = mpsc::channel(10);
|
let (main_sender, mut main_receiver) = mpsc::channel(10);
|
||||||
let (pw_sender, pw_receiver) = pipewire::channel::channel();
|
let (pw_sender, pw_receiver) = pipewire::channel::channel();
|
||||||
let (init_sender, init_receiver) = std::sync::mpsc::sync_channel(0);
|
let (init_sender, init_receiver) = tokio::sync::oneshot::channel();
|
||||||
|
|
||||||
// Spawn pipewire thread in background
|
// Spawn pipewire thread in background
|
||||||
let _pw_thread = tokio::spawn(async move {
|
let _pw_thread = tokio::spawn(async move {
|
||||||
@@ -155,7 +143,7 @@ pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Wait for initialization to complete
|
// Wait for initialization to complete
|
||||||
if let Err(e) = init_receiver.recv()? {
|
if let Err(e) = init_receiver.await {
|
||||||
return Err(anyhow!(e));
|
return Err(anyhow!(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,47 +176,14 @@ pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
|
|||||||
let node_id = port.node_id;
|
let node_id = port.node_id;
|
||||||
|
|
||||||
if let Some(input_device) = input_devices.get_mut(&node_id) {
|
if let Some(input_device) = input_devices.get_mut(&node_id) {
|
||||||
match port.name.as_str() {
|
input_device.add_port(port);
|
||||||
"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) {
|
} else if let Some(output_device) = output_devices.get_mut(&node_id) {
|
||||||
match port.name.as_str() {
|
output_device.add_port(port);
|
||||||
"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 input_devices: Vec<AudioDevice> = input_devices.into_values().collect();
|
||||||
let mut output_devices: Vec<AudioDevice> =
|
let mut output_devices: Vec<AudioDevice> = output_devices.into_values().collect();
|
||||||
output_devices.values().cloned().collect();
|
|
||||||
|
|
||||||
input_devices.sort_by_key(|a| a.id);
|
input_devices.sort_by_key(|a| a.id);
|
||||||
output_devices.sort_by_key(|a| a.id);
|
output_devices.sort_by_key(|a| a.id);
|
||||||
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}!")
|
||||||
-1095
File diff suppressed because it is too large
Load Diff
-14
@@ -1,14 +0,0 @@
|
|||||||
mod gui;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use rust_i18n::i18n;
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
gui::run().await
|
|
||||||
}
|
|
||||||
@@ -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>,
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user