mirror of
https://github.com/arabianq/pipewire-soundpad.git
synced 2026-04-28 14:31:23 +00:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6c8d720d5 | |||
| a6d93ff528 | |||
| bcf791d84c | |||
| e4b0b10393 | |||
| 11de96db58 | |||
| 7396c0aef8 | |||
| fc2cd5e2da | |||
| 1a37729cf1 | |||
| 86b38a250e | |||
| 54fa278cea | |||
| db040aa820 | |||
| 04449e7525 | |||
| 9f50809a99 | |||
| 7dda4bc2b1 | |||
| 1569955e12 | |||
| 9adc6cfbda | |||
| 76b1d4f345 | |||
| 10f9937dc3 | |||
| 498c09eb50 | |||
| 78e0a133b6 | |||
| 7f8b7194b6 | |||
| 302f153b91 | |||
| f87dcb1564 | |||
| d4d16f6ce7 | |||
| 949307fcf8 | |||
| 2a8fcca06b | |||
| 5c4b8f4b45 | |||
| 70c7e3789b |
@@ -0,0 +1,88 @@
|
|||||||
|
name: Flatter
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main, master ]
|
||||||
|
release:
|
||||||
|
types: [ published ]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag_name:
|
||||||
|
description: "TAG (empty to build from current branch)"
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
build_branch:
|
||||||
|
description: "Flatpak branch to build (stable/nightly)"
|
||||||
|
required: true
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- stable
|
||||||
|
- nightly
|
||||||
|
default: "stable"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
flatter:
|
||||||
|
name: Flatter
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
container:
|
||||||
|
image: ghcr.io/andyholmes/flatter/freedesktop:25.08
|
||||||
|
options: --privileged
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.tag_name || github.ref }}
|
||||||
|
|
||||||
|
- name: Setup GPG
|
||||||
|
id: gpg
|
||||||
|
uses: crazy-max/ghaction-import-gpg@v6
|
||||||
|
with:
|
||||||
|
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||||
|
passphrase: ${{ secrets.GPG_PASSPHRASE }}
|
||||||
|
|
||||||
|
- name: Set Default Branch
|
||||||
|
id: set_branch
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" == "release" ]; then
|
||||||
|
echo "branch=stable" >> $GITHUB_OUTPUT
|
||||||
|
elif [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
|
||||||
|
echo "branch=${{ inputs.build_branch }}" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "branch=nightly" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Modify Manifest
|
||||||
|
run: |
|
||||||
|
echo "branch: ${{ steps.set_branch.outputs.branch }}" >> packages/flatpak/ru.arabianq.pwsp.yaml
|
||||||
|
echo "default-branch: ${{ steps.set_branch.outputs.branch }}" >> packages/flatpak/ru.arabianq.pwsp.yaml
|
||||||
|
|
||||||
|
- name: Install SDK Extensions
|
||||||
|
run: flatpak install -y flathub org.freedesktop.Sdk.Extension.rust-stable//25.08
|
||||||
|
org.freedesktop.Sdk.Extension.llvm20//25.08
|
||||||
|
|
||||||
|
- name: Build Flatpak
|
||||||
|
uses: andyholmes/flatter@main
|
||||||
|
with:
|
||||||
|
files: packages/flatpak/ru.arabianq.pwsp.yaml
|
||||||
|
gpg-sign: ${{ steps.gpg.outputs.fingerprint }}
|
||||||
|
upload-bundles: false
|
||||||
|
upload-pages-artifact: true
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy to GitHub Pages
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: flatter
|
||||||
|
permissions:
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: GitHub Pages
|
||||||
|
id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
@@ -174,10 +174,3 @@ jobs:
|
|||||||
cache: true
|
cache: true
|
||||||
branch: master
|
branch: master
|
||||||
build-bundle: true
|
build-bundle: true
|
||||||
|
|
||||||
- name: Upload Flatpak to release
|
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
tag_name: ${{ needs.prepare.outputs.tag }}
|
|
||||||
files: ru.arabianq.pwsp.flatpak
|
|
||||||
|
|||||||
Generated
+315
-183
File diff suppressed because it is too large
Load Diff
+6
-4
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "pwsp"
|
name = "pwsp"
|
||||||
version = "1.7.1"
|
version = "1.7.6"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
authors = ["arabian"]
|
authors = ["arabian"]
|
||||||
description = "PWSP lets you play audio files through your microphone. Has both CLI and GUI clients."
|
description = "PWSP lets you play audio files through your microphone. Has both CLI and GUI clients."
|
||||||
@@ -12,13 +12,13 @@ keywords = ["soundpad", "pipewire", "linux", "cli", "gui"]
|
|||||||
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tokio = { version = "1.51.1", 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"] }
|
||||||
serde_json = "1.0.149"
|
serde_json = "1.0.149"
|
||||||
|
|
||||||
clap = { version = "4.6.0", default-features = false, features = [
|
clap = { version = "4.6.1", default-features = false, features = [
|
||||||
"std",
|
"std",
|
||||||
"suggestions",
|
"suggestions",
|
||||||
"help",
|
"help",
|
||||||
@@ -29,8 +29,9 @@ clap = { version = "4.6.0", default-features = false, features = [
|
|||||||
dirs = "6.0.0"
|
dirs = "6.0.0"
|
||||||
itertools = "0.14.0"
|
itertools = "0.14.0"
|
||||||
|
|
||||||
rodio = { version = "0.22.2", default-features = false, features = [
|
rodio = { git = "https://github.com/RustAudio/rodio.git", rev = "57ad9d8a9f30398f634fbf8e4e1d53dde7243c21", default-features = false, features = [
|
||||||
"symphonia-all",
|
"symphonia-all",
|
||||||
|
"symphonia-libopus",
|
||||||
"playback",
|
"playback",
|
||||||
] }
|
] }
|
||||||
pipewire = "0.9.2"
|
pipewire = "0.9.2"
|
||||||
@@ -50,6 +51,7 @@ eframe = { version = "0.34.1", default-features = false, features = [
|
|||||||
"x11",
|
"x11",
|
||||||
"wayland",
|
"wayland",
|
||||||
] }
|
] }
|
||||||
|
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"
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,28 @@ three main components:
|
|||||||
You can download pre-built binaries and .deb packages from
|
You can download pre-built binaries and .deb packages from
|
||||||
the [releases page](https://github.com/arabianq/pipewire-soundpad/releases).
|
the [releases page](https://github.com/arabianq/pipewire-soundpad/releases).
|
||||||
|
|
||||||
|
## **Flatpak**
|
||||||
|
|
||||||
|
You can install PWSP via Flatpak from our custom repository hosted on GitHub Pages.
|
||||||
|
|
||||||
|
Add the repository:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flatpak remote-add --user --if-not-exists arabianq-repo https://arabianq.github.io/pipewire-soundpad/index.flatpakrepo
|
||||||
|
```
|
||||||
|
|
||||||
|
Install the stable version:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flatpak install --user arabianq-repo ru.arabianq.pwsp//stable
|
||||||
|
```
|
||||||
|
|
||||||
|
Or install the nightly version (latest commit to `main`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flatpak install --user arabianq-repo ru.arabianq.pwsp//nightly
|
||||||
|
```
|
||||||
|
|
||||||
## **Fedora Linux (and derivatives)**
|
## **Fedora Linux (and derivatives)**
|
||||||
|
|
||||||
If you're using Fedora, you can install PWSP from a dedicated repository using DNF.
|
If you're using Fedora, you can install PWSP from a dedicated repository using DNF.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
pkgbase = pwsp-bin
|
pkgbase = pwsp-bin
|
||||||
pkgdesc = Lets you play audio files through your microphone (Pre-built binaries)
|
pkgdesc = Lets you play audio files through your microphone (Pre-built binaries)
|
||||||
pkgver = 1.7.1
|
pkgver = 1.7.6
|
||||||
pkgrel = 2
|
pkgrel = 1
|
||||||
url = https://github.com/arabianq/pipewire-soundpad
|
url = https://github.com/arabianq/pipewire-soundpad
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
license = MIT
|
license = MIT
|
||||||
@@ -9,8 +9,8 @@ depends = pipewire
|
|||||||
depends = alsa-lib
|
depends = alsa-lib
|
||||||
provides = pwsp
|
provides = pwsp
|
||||||
conflicts = pwsp
|
conflicts = pwsp
|
||||||
source = pwsp-bin-1.7.1.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.7.1/pwsp-v1.7.1-linux-x64.zip
|
source = pwsp-bin-1.7.6.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.7.6/pwsp-v1.7.6-linux-x64.zip
|
||||||
source = pipewire-soundpad-1.7.1.tar.gz :: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.7.1.tar.gz
|
source = pipewire-soundpad-1.7.6.tar.gz :: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.7.6.tar.gz
|
||||||
sha256sums = SKIP
|
sha256sums = SKIP
|
||||||
sha256sums = SKIP
|
sha256sums = SKIP
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Maintainer: Alexander Tarasov <a.tevg@ya.ru>
|
# Maintainer: Alexander Tarasov <a.tevg@ya.ru>
|
||||||
pkgname=pwsp-bin
|
pkgname=pwsp-bin
|
||||||
_pkgname=pipewire-soundpad
|
_pkgname=pipewire-soundpad
|
||||||
pkgver=1.7.1
|
pkgver=1.7.6
|
||||||
pkgrel=2
|
pkgrel=1
|
||||||
pkgdesc="Lets you play audio files through your microphone (Pre-built binaries)"
|
pkgdesc="Lets you play audio files through your microphone (Pre-built binaries)"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
url="https://github.com/arabianq/pipewire-soundpad"
|
url="https://github.com/arabianq/pipewire-soundpad"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
pkgbase = pwsp
|
pkgbase = pwsp
|
||||||
pkgdesc = Lets you play audio files through your microphone
|
pkgdesc = Lets you play audio files through your microphone
|
||||||
pkgver = 1.7.1
|
pkgver = 1.7.6
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://github.com/arabianq/pipewire-soundpad
|
url = https://github.com/arabianq/pipewire-soundpad
|
||||||
arch = any
|
arch = any
|
||||||
@@ -8,9 +8,10 @@ pkgbase = pwsp
|
|||||||
makedepends = clang
|
makedepends = clang
|
||||||
makedepends = rust
|
makedepends = rust
|
||||||
makedepends = cargo
|
makedepends = cargo
|
||||||
|
makedepends = cmake
|
||||||
makedepends = pipewire
|
makedepends = pipewire
|
||||||
makedepends = alsa-lib
|
makedepends = alsa-lib
|
||||||
source = https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.7.1.tar.gz
|
source = https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.7.6.tar.gz
|
||||||
sha256sums = SKIP
|
sha256sums = SKIP
|
||||||
|
|
||||||
pkgname = pwsp
|
pkgname = pwsp
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
# Maintainer: Alexander Tarasov <a.tevg@ya.ru>
|
# Maintainer: Alexander Tarasov <a.tevg@ya.ru>
|
||||||
pkgsubn=pwsp
|
pkgsubn=pwsp
|
||||||
pkgname=pwsp
|
pkgname=pwsp
|
||||||
pkgver=1.7.1
|
pkgver=1.7.6
|
||||||
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=('any')
|
||||||
url="https://github.com/arabianq/pipewire-soundpad"
|
url="https://github.com/arabianq/pipewire-soundpad"
|
||||||
license=('MIT')
|
license=('MIT')
|
||||||
makedepends=(clang rust cargo pipewire alsa-lib)
|
makedepends=(clang rust cargo cmake pipewire alsa-lib)
|
||||||
source=("$url/archive/refs/tags/v$pkgver.tar.gz")
|
source=("$url/archive/refs/tags/v$pkgver.tar.gz")
|
||||||
sha256sums=('SKIP')
|
sha256sums=('SKIP')
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Executable
+19
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ ! -f "Cargo.lock" ]; then
|
||||||
|
echo "Error: Cargo.lock not found. Please run this script from the project root."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Downloading flatpak-cargo-generator.py..."
|
||||||
|
curl -sLO https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/master/cargo/flatpak-cargo-generator.py
|
||||||
|
chmod +x flatpak-cargo-generator.py
|
||||||
|
|
||||||
|
echo "Generating cargo-sources.json..."
|
||||||
|
python3 flatpak-cargo-generator.py Cargo.lock -o packages/flatpak/cargo-sources.json
|
||||||
|
|
||||||
|
echo "Cleaning up..."
|
||||||
|
rm flatpak-cargo-generator.py
|
||||||
|
|
||||||
|
echo "Successfully generated packages/flatpak/cargo-sources.json"
|
||||||
@@ -5,5 +5,5 @@ Exec=pwsp-wrapper.py %u
|
|||||||
Icon=ru.arabianq.pwsp
|
Icon=ru.arabianq.pwsp
|
||||||
Terminal=false
|
Terminal=false
|
||||||
Type=Application
|
Type=Application
|
||||||
Categories=Audio;Utility;
|
Categories=AudioVideo;Audio;
|
||||||
Keywords=soundpad;pipewire;audio;
|
Keywords=soundpad;pipewire;audio;
|
||||||
|
|||||||
@@ -15,11 +15,17 @@
|
|||||||
<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>
|
<image>https://raw.githubusercontent.com/arabianq/pipewire-soundpad/master/assets/screenshot.png</image>
|
||||||
https://raw.githubusercontent.com/arabianq/pipewire-soundpad/master/assets/screenshot.png</image>
|
|
||||||
</screenshot>
|
</screenshot>
|
||||||
</screenshots>
|
</screenshots>
|
||||||
<url type="homepage">https://pwsp.arabianq.ru</url>
|
<url type="homepage">https://pwsp.arabianq.ru</url>
|
||||||
<developer_name>arabian</developer_name>
|
<url type="bugtracker">https://github.com/arabianq/pipewire-soundpad/issues</url>
|
||||||
|
<url type="vcs-browser">https://github.com/arabianq/pipewire-soundpad</url>
|
||||||
|
<developer id="ru.arabianq">
|
||||||
|
<name>arabian</name>
|
||||||
|
</developer>
|
||||||
|
<releases>
|
||||||
|
<release version="1.7.6" date="2026-04-28" />
|
||||||
|
</releases>
|
||||||
<content_rating type="oars-1.1" />
|
<content_rating type="oars-1.1" />
|
||||||
</component>
|
</component>
|
||||||
@@ -14,10 +14,9 @@ finish-args:
|
|||||||
- --filesystem=xdg-run/pipewire-0
|
- --filesystem=xdg-run/pipewire-0
|
||||||
- --filesystem=xdg-run/pwsp:create
|
- --filesystem=xdg-run/pwsp:create
|
||||||
- --filesystem=xdg-run/app/ru.arabianq.pwsp:create
|
- --filesystem=xdg-run/app/ru.arabianq.pwsp:create
|
||||||
- --filesystem=host
|
- --filesystem=home
|
||||||
- --device=all
|
- --device=all
|
||||||
- --device=dri
|
- --device=dri
|
||||||
- --share=network
|
|
||||||
- --talk-name=org.freedesktop.portal.Desktop
|
- --talk-name=org.freedesktop.portal.Desktop
|
||||||
- --talk-name=org.freedesktop.portal.Documents
|
- --talk-name=org.freedesktop.portal.Documents
|
||||||
|
|
||||||
@@ -30,11 +29,8 @@ build-options:
|
|||||||
modules:
|
modules:
|
||||||
- name: pwsp
|
- name: pwsp
|
||||||
buildsystem: simple
|
buildsystem: simple
|
||||||
build-options:
|
|
||||||
build-args:
|
|
||||||
- --share=network
|
|
||||||
build-commands:
|
build-commands:
|
||||||
- cargo build --release
|
- export CARGO_HOME=$PWD/cargo && cargo build --release --offline
|
||||||
- install -Dm755 target/release/pwsp-daemon /app/bin/pwsp-daemon
|
- install -Dm755 target/release/pwsp-daemon /app/bin/pwsp-daemon
|
||||||
- install -Dm755 target/release/pwsp-cli /app/bin/pwsp-cli
|
- install -Dm755 target/release/pwsp-cli /app/bin/pwsp-cli
|
||||||
- install -Dm755 target/release/pwsp-gui /app/bin/pwsp-gui
|
- install -Dm755 target/release/pwsp-gui /app/bin/pwsp-gui
|
||||||
@@ -45,3 +41,4 @@ modules:
|
|||||||
sources:
|
sources:
|
||||||
- type: dir
|
- type: dir
|
||||||
path: ../../
|
path: ../../
|
||||||
|
- cargo-sources.json
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
%global cargo_install_lib 0
|
%global cargo_install_lib 0
|
||||||
|
|
||||||
Name: pwsp
|
Name: pwsp
|
||||||
Version: 1.7.1
|
Version: 1.7.6
|
||||||
Release: %autorelease
|
Release: %autorelease
|
||||||
Summary: Lets you play audio files through your microphone
|
Summary: Lets you play audio files through your microphone
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ BuildRequires: cargo
|
|||||||
BuildRequires: pipewire-devel
|
BuildRequires: pipewire-devel
|
||||||
BuildRequires: alsa-lib-devel
|
BuildRequires: alsa-lib-devel
|
||||||
BuildRequires: clang-devel
|
BuildRequires: clang-devel
|
||||||
|
BuildRequires: cmake
|
||||||
|
|
||||||
%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
|
||||||
|
|||||||
+7
-26
@@ -1,10 +1,10 @@
|
|||||||
use pwsp::{
|
use pwsp::{
|
||||||
types::socket::{Request, Response},
|
types::socket::{MAX_MESSAGE_SIZE, Request, Response},
|
||||||
utils::{
|
utils::{
|
||||||
commands::parse_command,
|
commands::parse_command,
|
||||||
daemon::{
|
daemon::{
|
||||||
create_runtime_dir, get_audio_player, get_daemon_config, get_runtime_dir,
|
create_runtime_dir, get_audio_player, get_daemon_config, get_runtime_dir,
|
||||||
is_daemon_running, link_player_to_virtual_mic,
|
is_daemon_running,
|
||||||
},
|
},
|
||||||
global_hotkeys::start_global_hotkey_listener,
|
global_hotkeys::start_global_hotkey_listener,
|
||||||
pipewire::create_virtual_mic,
|
pipewire::create_virtual_mic,
|
||||||
@@ -32,25 +32,6 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
eprintln!("Failed to initialize audio player: {}", err);
|
eprintln!("Failed to initialize audio player: {}", err);
|
||||||
} // Initialize audio player
|
} // Initialize audio player
|
||||||
|
|
||||||
tokio::spawn(async {
|
|
||||||
let max_retries = 60;
|
|
||||||
for i in 0..=max_retries {
|
|
||||||
match link_player_to_virtual_mic().await {
|
|
||||||
Ok(_) => {
|
|
||||||
println!("Successfully linked player to virtual mic.");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
if i == 0 || i == max_retries {
|
|
||||||
eprintln!("{e} (attempt {i}/{max_retries})");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sleep(Duration::from_millis(1000)).await;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tokio::spawn(async {
|
tokio::spawn(async {
|
||||||
start_global_hotkey_listener().await;
|
start_global_hotkey_listener().await;
|
||||||
});
|
});
|
||||||
@@ -61,10 +42,10 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
lock_file.lock()?;
|
lock_file.lock()?;
|
||||||
|
|
||||||
let socket_path = runtime_dir.join("daemon.sock");
|
let socket_path = runtime_dir.join("daemon.sock");
|
||||||
if let Err(e) = fs::remove_file(&socket_path) {
|
if let Err(e) = fs::remove_file(&socket_path)
|
||||||
if e.kind() != std::io::ErrorKind::NotFound {
|
&& e.kind() != std::io::ErrorKind::NotFound
|
||||||
return Err(e.into());
|
{
|
||||||
}
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let listener = UnixListener::bind(&socket_path)?;
|
let listener = UnixListener::bind(&socket_path)?;
|
||||||
@@ -109,7 +90,7 @@ async fn commands_loop(listener: UnixListener) -> Result<(), Box<dyn Error>> {
|
|||||||
|
|
||||||
let request_len = u32::from_le_bytes(len_bytes) as usize;
|
let request_len = u32::from_le_bytes(len_bytes) as usize;
|
||||||
|
|
||||||
if request_len > 10 * 1024 * 1024 {
|
if request_len > MAX_MESSAGE_SIZE {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"Failed to read message from client: request too large ({} bytes)!",
|
"Failed to read message from client: request too large ({} bytes)!",
|
||||||
request_len
|
request_len
|
||||||
|
|||||||
+330
-285
@@ -1,15 +1,16 @@
|
|||||||
use crate::gui::SoundpadGui;
|
use crate::gui::SoundpadGui;
|
||||||
use egui::{
|
use egui::{
|
||||||
Align, AtomExt, Button, CollapsingHeader, Color32, ComboBox, CursorIcon, FontFamily, Grid,
|
Align, AtomExt, Button, CollapsingHeader, Color32, ComboBox, CursorIcon, FontFamily, Label,
|
||||||
Label, Layout, RichText, ScrollArea, Sense, Slider, TextEdit, Ui, Vec2,
|
Layout, RichText, ScrollArea, Sense, Slider, TextEdit, Ui, Vec2,
|
||||||
};
|
};
|
||||||
use egui_dnd::dnd;
|
use egui_dnd::dnd;
|
||||||
|
use egui_extras::{Column, TableBuilder};
|
||||||
use egui_material_icons::icons::*;
|
use egui_material_icons::icons::*;
|
||||||
use pwsp::types::socket::Request;
|
use pwsp::types::socket::Request;
|
||||||
use pwsp::types::{audio_player::TrackInfo, gui::AppState};
|
use pwsp::types::{audio_player::TrackInfo, gui::AppState};
|
||||||
use pwsp::utils::gui::{format_time_pair, make_request_async};
|
use pwsp::utils::gui::{format_time_pair, make_request_async};
|
||||||
use std::{
|
use std::{
|
||||||
path::{Path, PathBuf},
|
path::Path,
|
||||||
time::Instant,
|
time::Instant,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -133,156 +134,191 @@ impl SoundpadGui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn draw_hotkeys(&mut self, ui: &mut Ui) {
|
pub fn draw_hotkeys(&mut self, ui: &mut Ui) {
|
||||||
let area_size = ui.available_size();
|
|
||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| {
|
||||||
ui.set_min_width(area_size.x);
|
|
||||||
ui.set_min_height(area_size.y);
|
|
||||||
ui.spacing_mut().item_spacing.y = 5.0;
|
ui.spacing_mut().item_spacing.y = 5.0;
|
||||||
|
|
||||||
// Header
|
self.draw_hotkeys_header(ui);
|
||||||
ui.horizontal_top(|ui| {
|
|
||||||
let back_button = Button::new(ICON_ARROW_BACK).frame(false);
|
|
||||||
if ui.add(back_button).clicked() {
|
|
||||||
self.app_state.show_hotkeys = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.add_space(ui.available_width() / 2.0 - 40.0);
|
|
||||||
ui.label(RichText::new("Hotkeys").color(Color32::WHITE).monospace());
|
|
||||||
});
|
|
||||||
|
|
||||||
ui.separator();
|
ui.separator();
|
||||||
|
|
||||||
// Search and Add Command
|
self.draw_hotkeys_search(ui);
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.menu_button(format!("{} Add Command", ICON_ADD.codepoint), |ui| {
|
|
||||||
let mut selected_cmd = None;
|
|
||||||
if ui.button("Toggle Pause").clicked() {
|
|
||||||
selected_cmd = Some(("cmd_toggle_pause", Request::toggle_pause(None)));
|
|
||||||
}
|
|
||||||
if ui.button("Stop Playback").clicked() {
|
|
||||||
selected_cmd = Some(("cmd_stop", Request::stop(None)));
|
|
||||||
}
|
|
||||||
if ui.button("Pause Playback").clicked() {
|
|
||||||
selected_cmd = Some(("cmd_pause", Request::pause(None)));
|
|
||||||
}
|
|
||||||
if ui.button("Resume Playback").clicked() {
|
|
||||||
selected_cmd = Some(("cmd_resume", Request::resume(None)));
|
|
||||||
}
|
|
||||||
if ui.button("Toggle Loop").clicked() {
|
|
||||||
selected_cmd = Some(("cmd_toggle_loop", Request::toggle_loop(None)));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some((slot_name, req)) = selected_cmd {
|
|
||||||
make_request_async(Request::set_hotkey_action(slot_name, &req));
|
|
||||||
self.app_state
|
|
||||||
.hotkey_config
|
|
||||||
.set_slot(slot_name.to_string(), req);
|
|
||||||
self.app_state.assigning_hotkey_slot = Some(slot_name.to_string());
|
|
||||||
self.app_state.hotkey_capture_active = true;
|
|
||||||
ui.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ui.add_space(10.0);
|
|
||||||
|
|
||||||
ui.add_sized(
|
|
||||||
[ui.available_width(), 22.0],
|
|
||||||
TextEdit::singleline(&mut self.app_state.hotkey_search_query)
|
|
||||||
.hint_text("Search hotkeys..."),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
ui.separator();
|
ui.separator();
|
||||||
ui.add_space(5.0);
|
ui.add_space(5.0);
|
||||||
|
|
||||||
let conflicts = self.app_state.hotkey_config.find_conflicts();
|
let action = self.draw_hotkeys_table(ui);
|
||||||
let conflict_slots: std::collections::HashSet<String> = conflicts
|
|
||||||
.iter()
|
|
||||||
.flat_map(|(a, b)| vec![a.clone(), b.clone()])
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let search = self.app_state.hotkey_search_query.to_lowercase();
|
if let Some(action) = action {
|
||||||
|
self.handle_hotkey_action(action);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Slots table
|
fn draw_hotkeys_header(&mut self, ui: &mut Ui) {
|
||||||
let mut action: Option<HotkeyAction> = None;
|
ui.horizontal(|ui| {
|
||||||
let area_size = ui.available_size();
|
let back_button = Button::new(ICON_ARROW_BACK).frame(false);
|
||||||
|
if ui.add(back_button).clicked() {
|
||||||
|
self.app_state.show_hotkeys = false;
|
||||||
|
}
|
||||||
|
|
||||||
ScrollArea::vertical().show(ui, |ui| {
|
ui.vertical_centered(|ui| {
|
||||||
ui.set_min_width(area_size.x);
|
ui.label(RichText::new("Hotkeys").color(Color32::WHITE).monospace());
|
||||||
Grid::new("hotkeys_grid")
|
});
|
||||||
.striped(true)
|
});
|
||||||
.num_columns(4)
|
}
|
||||||
.max_col_width(area_size.x)
|
|
||||||
.min_col_width(area_size.x / 4.0)
|
|
||||||
.spacing([40.0, 10.0])
|
|
||||||
.show(ui, |ui| {
|
|
||||||
// Table header
|
|
||||||
ui.label(
|
|
||||||
RichText::new("Slot")
|
|
||||||
.strong()
|
|
||||||
.monospace()
|
|
||||||
.color(Color32::LIGHT_GRAY),
|
|
||||||
);
|
|
||||||
ui.label(
|
|
||||||
RichText::new("Sound")
|
|
||||||
.strong()
|
|
||||||
.monospace()
|
|
||||||
.color(Color32::LIGHT_GRAY),
|
|
||||||
);
|
|
||||||
ui.label(
|
|
||||||
RichText::new("Key Chord")
|
|
||||||
.strong()
|
|
||||||
.monospace()
|
|
||||||
.color(Color32::LIGHT_GRAY),
|
|
||||||
);
|
|
||||||
ui.label(
|
|
||||||
RichText::new("Actions")
|
|
||||||
.strong()
|
|
||||||
.monospace()
|
|
||||||
.color(Color32::LIGHT_GRAY),
|
|
||||||
);
|
|
||||||
ui.end_row();
|
|
||||||
|
|
||||||
let slots: Vec<_> = self
|
fn draw_hotkeys_search(&mut self, ui: &mut Ui) {
|
||||||
.app_state
|
ui.horizontal(|ui| {
|
||||||
.hotkey_config
|
ui.menu_button(format!("{} Add Command", ICON_ADD.codepoint), |ui| {
|
||||||
.slots
|
let mut selected_cmd = None;
|
||||||
.iter()
|
if ui.button("Toggle Pause").clicked() {
|
||||||
.filter(|s| {
|
selected_cmd = Some(("cmd_toggle_pause", Request::toggle_pause(None)));
|
||||||
if search.is_empty() {
|
}
|
||||||
return true;
|
if ui.button("Stop Playback").clicked() {
|
||||||
}
|
selected_cmd = Some(("cmd_stop", Request::stop(None)));
|
||||||
s.slot.to_lowercase().contains(&search)
|
}
|
||||||
|| format!("{:?}", s.action).to_lowercase().contains(&search)
|
if ui.button("Pause Playback").clicked() {
|
||||||
|| s.key_chord
|
selected_cmd = Some(("cmd_pause", Request::pause(None)));
|
||||||
.as_deref()
|
}
|
||||||
.unwrap_or("")
|
if ui.button("Resume Playback").clicked() {
|
||||||
.to_lowercase()
|
selected_cmd = Some(("cmd_resume", Request::resume(None)));
|
||||||
.contains(&search)
|
}
|
||||||
})
|
if ui.button("Toggle Loop").clicked() {
|
||||||
.cloned()
|
selected_cmd = Some(("cmd_toggle_loop", Request::toggle_loop(None)));
|
||||||
.collect();
|
}
|
||||||
|
|
||||||
for slot in &slots {
|
if let Some((slot_name, req)) = selected_cmd {
|
||||||
|
make_request_async(Request::set_hotkey_action(slot_name, &req));
|
||||||
|
self.app_state
|
||||||
|
.hotkey_config
|
||||||
|
.set_slot(slot_name.to_string(), req);
|
||||||
|
self.app_state.assigning_hotkey_slot = Some(slot_name.to_string());
|
||||||
|
self.app_state.hotkey_capture_active = true;
|
||||||
|
ui.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(10.0);
|
||||||
|
|
||||||
|
ui.add(
|
||||||
|
TextEdit::singleline(&mut self.app_state.hotkey_search_query)
|
||||||
|
.hint_text("Search hotkeys...")
|
||||||
|
.desired_width(f32::INFINITY),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_hotkeys_table(&mut self, ui: &mut Ui) -> Option<HotkeyAction> {
|
||||||
|
let conflicts = self.app_state.hotkey_config.find_conflicts();
|
||||||
|
let conflict_slots: std::collections::HashSet<&str> =
|
||||||
|
conflicts.into_iter().flat_map(|(a, b)| [a, b]).collect();
|
||||||
|
|
||||||
|
let search = self.app_state.hotkey_search_query.to_lowercase();
|
||||||
|
let mut action: Option<HotkeyAction> = None;
|
||||||
|
|
||||||
|
let slots: Vec<_> = self
|
||||||
|
.app_state
|
||||||
|
.hotkey_config
|
||||||
|
.slots
|
||||||
|
.iter()
|
||||||
|
.filter(|s| {
|
||||||
|
if search.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
s.slot.to_lowercase().contains(&search)
|
||||||
|
|| format!("{:?}", s.action).to_lowercase().contains(&search)
|
||||||
|
|| s.key_chord
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_lowercase()
|
||||||
|
.contains(&search)
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let available_width = ui.available_width();
|
||||||
|
let col_width = (available_width / 4.0).max(80.0);
|
||||||
|
|
||||||
|
TableBuilder::new(ui)
|
||||||
|
.striped(true)
|
||||||
|
.column(Column::exact(col_width).clip(true)) // Slot
|
||||||
|
.column(Column::exact(col_width).clip(true)) // Sound / Action name
|
||||||
|
.column(Column::exact(col_width).clip(true)) // Key Chord
|
||||||
|
.column(Column::exact(col_width).clip(true)) // Actions
|
||||||
|
.header(30.0, |mut header| {
|
||||||
|
header.col(|ui| {
|
||||||
|
ui.label(
|
||||||
|
RichText::new("Slot")
|
||||||
|
.strong()
|
||||||
|
.monospace()
|
||||||
|
.color(Color32::LIGHT_GRAY),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
header.col(|ui| {
|
||||||
|
ui.label(
|
||||||
|
RichText::new("Sound")
|
||||||
|
.strong()
|
||||||
|
.monospace()
|
||||||
|
.color(Color32::LIGHT_GRAY),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
header.col(|ui| {
|
||||||
|
ui.label(
|
||||||
|
RichText::new("Key Chord")
|
||||||
|
.strong()
|
||||||
|
.monospace()
|
||||||
|
.color(Color32::LIGHT_GRAY),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
header.col(|ui| {
|
||||||
|
ui.label(
|
||||||
|
RichText::new("Actions")
|
||||||
|
.strong()
|
||||||
|
.monospace()
|
||||||
|
.color(Color32::LIGHT_GRAY),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.body(|mut body| {
|
||||||
|
if slots.is_empty() {
|
||||||
|
body.row(30.0, |mut row| {
|
||||||
|
row.col(|_| {});
|
||||||
|
row.col(|ui| {
|
||||||
|
ui.label(
|
||||||
|
RichText::new("No hotkey slots configured.")
|
||||||
|
.color(Color32::GRAY),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
row.col(|_| {});
|
||||||
|
row.col(|_| {});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for slot in &slots {
|
||||||
|
body.row(30.0, |mut row| {
|
||||||
|
// Column 1: Slot
|
||||||
|
row.col(|ui| {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
// Conflict badge
|
if conflict_slots.contains(slot.slot.as_str()) {
|
||||||
if conflict_slots.contains(&slot.slot) {
|
|
||||||
ui.label(
|
ui.label(
|
||||||
RichText::new(ICON_WARNING.codepoint)
|
RichText::new(ICON_WARNING.codepoint)
|
||||||
.color(Color32::from_rgb(255, 165, 0)),
|
.color(Color32::from_rgb(255, 165, 0)),
|
||||||
)
|
)
|
||||||
.on_hover_text("Key chord conflict");
|
.on_hover_text("Key chord conflict");
|
||||||
}
|
}
|
||||||
|
ui.add(
|
||||||
// Slot name
|
Label::new(RichText::new(&slot.slot).monospace())
|
||||||
let slot_text = RichText::new(&slot.slot).monospace();
|
.truncate(),
|
||||||
ui.label(slot_text);
|
);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Action description
|
// Column 2: Sound / Action name
|
||||||
|
row.col(|ui| {
|
||||||
let action_name = match slot.action.name.as_str() {
|
let action_name = match slot.action.name.as_str() {
|
||||||
"play" => {
|
"play" => {
|
||||||
if let Some(file_path_str) = slot.action.args.get("file_path") {
|
if let Some(file_path_str) =
|
||||||
|
slot.action.args.get("file_path")
|
||||||
|
{
|
||||||
Path::new(file_path_str)
|
Path::new(file_path_str)
|
||||||
.file_name()
|
.file_name()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
@@ -299,20 +335,29 @@ impl SoundpadGui {
|
|||||||
"toggle_loop" => "Toggle Loop".to_string(),
|
"toggle_loop" => "Toggle Loop".to_string(),
|
||||||
other => other.to_string(),
|
other => other.to_string(),
|
||||||
};
|
};
|
||||||
ui.add(Label::new(RichText::new(action_name).monospace()).truncate());
|
ui.add(
|
||||||
|
Label::new(RichText::new(action_name).monospace()).truncate(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// Key chord
|
// Column 3: Key Chord
|
||||||
|
row.col(|ui| {
|
||||||
let chord_text = slot.key_chord.as_deref().unwrap_or("(none)");
|
let chord_text = slot.key_chord.as_deref().unwrap_or("(none)");
|
||||||
ui.label(RichText::new(chord_text).monospace().color(
|
ui.add(
|
||||||
if slot.key_chord.is_some() {
|
Label::new(RichText::new(chord_text).monospace().color(
|
||||||
Color32::from_rgb(100, 200, 100)
|
if slot.key_chord.is_some() {
|
||||||
} else {
|
Color32::from_rgb(100, 200, 100)
|
||||||
Color32::GRAY
|
} else {
|
||||||
},
|
Color32::GRAY
|
||||||
));
|
},
|
||||||
|
))
|
||||||
|
.truncate(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Column 4: Actions
|
||||||
|
row.col(|ui| {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
// Delete button
|
|
||||||
if ui
|
if ui
|
||||||
.add(Button::new(ICON_DELETE).frame(false))
|
.add(Button::new(ICON_DELETE).frame(false))
|
||||||
.on_hover_text("Remove slot")
|
.on_hover_text("Remove slot")
|
||||||
@@ -320,8 +365,6 @@ impl SoundpadGui {
|
|||||||
{
|
{
|
||||||
action = Some(HotkeyAction::Remove(slot.slot.clone()));
|
action = Some(HotkeyAction::Remove(slot.slot.clone()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set key chord button
|
|
||||||
if ui
|
if ui
|
||||||
.add(Button::new(ICON_KEYBOARD).frame(false))
|
.add(Button::new(ICON_KEYBOARD).frame(false))
|
||||||
.on_hover_text("Set key chord")
|
.on_hover_text("Set key chord")
|
||||||
@@ -329,8 +372,6 @@ impl SoundpadGui {
|
|||||||
{
|
{
|
||||||
action = Some(HotkeyAction::Capture(slot.slot.clone()));
|
action = Some(HotkeyAction::Capture(slot.slot.clone()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear key chord
|
|
||||||
if slot.key_chord.is_some()
|
if slot.key_chord.is_some()
|
||||||
&& ui
|
&& ui
|
||||||
.add(Button::new(ICON_BACKSPACE).frame(false))
|
.add(Button::new(ICON_BACKSPACE).frame(false))
|
||||||
@@ -339,8 +380,6 @@ impl SoundpadGui {
|
|||||||
{
|
{
|
||||||
action = Some(HotkeyAction::ClearChord(slot.slot.clone()));
|
action = Some(HotkeyAction::ClearChord(slot.slot.clone()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Play button
|
|
||||||
if ui
|
if ui
|
||||||
.add(Button::new(ICON_PLAY_ARROW).frame(false))
|
.add(Button::new(ICON_PLAY_ARROW).frame(false))
|
||||||
.on_hover_text("Play")
|
.on_hover_text("Play")
|
||||||
@@ -349,39 +388,32 @@ impl SoundpadGui {
|
|||||||
action = Some(HotkeyAction::Play(slot.slot.clone()));
|
action = Some(HotkeyAction::Play(slot.slot.clone()));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
ui.end_row();
|
});
|
||||||
}
|
|
||||||
|
|
||||||
if slots.is_empty() {
|
|
||||||
ui.label("No hotkey slots configured.");
|
|
||||||
ui.label("");
|
|
||||||
ui.label("");
|
|
||||||
ui.label("");
|
|
||||||
ui.end_row();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Some(action) = action {
|
action
|
||||||
match action {
|
}
|
||||||
HotkeyAction::Remove(slot) => {
|
|
||||||
make_request_async(Request::clear_hotkey(&slot));
|
fn handle_hotkey_action(&mut self, action: HotkeyAction) {
|
||||||
self.app_state.hotkey_config.remove_slot(&slot);
|
match action {
|
||||||
}
|
HotkeyAction::Remove(slot) => {
|
||||||
HotkeyAction::Capture(slot) => {
|
make_request_async(Request::clear_hotkey(&slot));
|
||||||
self.app_state.assigning_hotkey_slot = Some(slot);
|
self.app_state.hotkey_config.remove_slot(&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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
HotkeyAction::Capture(slot) => {
|
||||||
|
self.app_state.assigning_hotkey_slot = Some(slot);
|
||||||
|
self.app_state.hotkey_capture_active = true;
|
||||||
|
}
|
||||||
|
HotkeyAction::ClearChord(slot) => {
|
||||||
|
make_request_async(Request::clear_hotkey_key(&slot));
|
||||||
|
self.app_state.hotkey_config.set_key_chord(&slot, None);
|
||||||
|
}
|
||||||
|
HotkeyAction::Play(slot) => {
|
||||||
|
self.play_hotkey_slot(&slot);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_header(&mut self, ui: &mut Ui) {
|
fn draw_header(&mut self, ui: &mut Ui) {
|
||||||
@@ -391,10 +423,9 @@ impl SoundpadGui {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let tracks = self.audio_player_state.tracks.clone();
|
|
||||||
let mut action = None;
|
let mut action = None;
|
||||||
|
|
||||||
for track in tracks {
|
for track in &self.audio_player_state.tracks {
|
||||||
CollapsingHeader::new(
|
CollapsingHeader::new(
|
||||||
RichText::new(
|
RichText::new(
|
||||||
track
|
track
|
||||||
@@ -409,7 +440,7 @@ impl SoundpadGui {
|
|||||||
)
|
)
|
||||||
.default_open(true)
|
.default_open(true)
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
if let Some(act) = Self::draw_track_control(ui, &mut self.app_state, &track) {
|
if let Some(act) = Self::draw_track_control(ui, &mut self.app_state, track) {
|
||||||
action = Some(act);
|
action = Some(act);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -427,6 +458,99 @@ impl SoundpadGui {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn draw_playback_controls(ui: &mut Ui, track: &TrackInfo) -> Option<TrackAction> {
|
||||||
|
let mut action = None;
|
||||||
|
|
||||||
|
let play_button = Button::new(if track.paused {
|
||||||
|
ICON_PLAY_ARROW
|
||||||
|
} else {
|
||||||
|
ICON_PAUSE
|
||||||
|
})
|
||||||
|
.corner_radius(15.0);
|
||||||
|
|
||||||
|
if ui.add_sized([30.0, 30.0], play_button).clicked() {
|
||||||
|
action = Some(if track.paused {
|
||||||
|
TrackAction::Resume(track.id)
|
||||||
|
} else {
|
||||||
|
TrackAction::Pause(track.id)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let loop_button = Button::new(
|
||||||
|
RichText::new(if track.looped {
|
||||||
|
ICON_REPEAT_ONE
|
||||||
|
} else {
|
||||||
|
ICON_REPEAT
|
||||||
|
})
|
||||||
|
.size(18.0),
|
||||||
|
)
|
||||||
|
.frame(false);
|
||||||
|
|
||||||
|
if ui.add_sized([15.0, 30.0], loop_button).clicked() {
|
||||||
|
action = Some(TrackAction::ToggleLoop(track.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
action
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_position_control(
|
||||||
|
ui: &mut Ui,
|
||||||
|
ui_state: &mut pwsp::types::gui::TrackUiState,
|
||||||
|
track: &TrackInfo,
|
||||||
|
default_slider_width: f32,
|
||||||
|
) {
|
||||||
|
let duration = track.duration.unwrap_or(1.0);
|
||||||
|
let position_slider = Slider::new(&mut ui_state.position_slider_value, 0.0..=duration)
|
||||||
|
.show_value(false)
|
||||||
|
.step_by(0.01);
|
||||||
|
|
||||||
|
let position_slider_width = ui.available_width()
|
||||||
|
- (30.0 * 3.0)
|
||||||
|
- default_slider_width
|
||||||
|
- (ui.spacing().item_spacing.x * 6.0);
|
||||||
|
|
||||||
|
ui.spacing_mut().slider_width = position_slider_width;
|
||||||
|
if ui.add_sized([30.0, 30.0], position_slider).drag_stopped() {
|
||||||
|
ui_state.position_dragged = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let time_label =
|
||||||
|
Label::new(RichText::new(format_time_pair(track.position, duration)).monospace());
|
||||||
|
ui.add_sized([30.0, 30.0], time_label);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_volume_control(
|
||||||
|
ui: &mut Ui,
|
||||||
|
ui_state: &mut pwsp::types::gui::TrackUiState,
|
||||||
|
track: &TrackInfo,
|
||||||
|
default_slider_width: f32,
|
||||||
|
) {
|
||||||
|
let volume_icon = Self::get_volume_icon(track.volume);
|
||||||
|
let volume_label = Label::new(RichText::new(volume_icon).size(18.0));
|
||||||
|
ui.add_sized([30.0, 30.0], volume_label)
|
||||||
|
.on_hover_text(format!("Volume: {:.0}%", track.volume * 100.0));
|
||||||
|
|
||||||
|
let volume_slider = Slider::new(&mut ui_state.volume_slider_value, 0.0..=1.0)
|
||||||
|
.show_value(false)
|
||||||
|
.step_by(0.01);
|
||||||
|
|
||||||
|
ui.spacing_mut().slider_width = default_slider_width - 30.0;
|
||||||
|
ui.spacing_mut().item_spacing.x = 0.0;
|
||||||
|
|
||||||
|
if ui.add_sized([30.0, 30.0], volume_slider).drag_stopped() {
|
||||||
|
ui_state.volume_dragged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_stop_control(ui: &mut Ui, track: &TrackInfo) -> Option<TrackAction> {
|
||||||
|
let stop_button = Button::new(ICON_CLOSE).frame(false);
|
||||||
|
if ui.add_sized([30.0, 30.0], stop_button).clicked() {
|
||||||
|
Some(TrackAction::Stop(track.id))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn draw_track_control(
|
fn draw_track_control(
|
||||||
ui: &mut Ui,
|
ui: &mut Ui,
|
||||||
app_state: &mut AppState,
|
app_state: &mut AppState,
|
||||||
@@ -457,93 +581,17 @@ impl SoundpadGui {
|
|||||||
let mut action = None;
|
let mut action = None;
|
||||||
|
|
||||||
ui.horizontal_top(|ui| {
|
ui.horizontal_top(|ui| {
|
||||||
// ---------- Play Button ----------
|
if let Some(act) = Self::draw_playback_controls(ui, track) {
|
||||||
let play_button = Button::new(if track.paused {
|
action = Some(act);
|
||||||
ICON_PLAY_ARROW
|
|
||||||
} else {
|
|
||||||
ICON_PAUSE
|
|
||||||
})
|
|
||||||
.corner_radius(15.0);
|
|
||||||
|
|
||||||
let play_button_response = ui.add_sized([30.0, 30.0], play_button);
|
|
||||||
if play_button_response.clicked() {
|
|
||||||
if track.paused {
|
|
||||||
action = Some(TrackAction::Resume(track.id));
|
|
||||||
} else {
|
|
||||||
action = Some(TrackAction::Pause(track.id));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// --------------------------------
|
|
||||||
|
|
||||||
// ---------- Loop Button ----------
|
|
||||||
let loop_button = Button::new(
|
|
||||||
RichText::new(if track.looped {
|
|
||||||
ICON_REPEAT_ONE
|
|
||||||
} else {
|
|
||||||
ICON_REPEAT
|
|
||||||
})
|
|
||||||
.size(18.0),
|
|
||||||
)
|
|
||||||
.frame(false);
|
|
||||||
|
|
||||||
let loop_button_response = ui.add_sized([15.0, 30.0], loop_button);
|
|
||||||
if loop_button_response.clicked() {
|
|
||||||
action = Some(TrackAction::ToggleLoop(track.id));
|
|
||||||
}
|
|
||||||
// --------------------------------
|
|
||||||
|
|
||||||
// ---------- Position Slider ----------
|
|
||||||
let duration = track.duration.unwrap_or(1.0);
|
|
||||||
let position_slider = Slider::new(&mut ui_state.position_slider_value, 0.0..=duration)
|
|
||||||
.show_value(false)
|
|
||||||
.step_by(0.01);
|
|
||||||
|
|
||||||
let default_slider_width = ui.spacing().slider_width;
|
let default_slider_width = ui.spacing().slider_width;
|
||||||
let position_slider_width = ui.available_width()
|
Self::draw_position_control(ui, ui_state, track, default_slider_width);
|
||||||
- (30.0 * 3.0)
|
Self::draw_volume_control(ui, ui_state, track, default_slider_width);
|
||||||
- default_slider_width
|
|
||||||
- (ui.spacing().item_spacing.x * 6.0);
|
if let Some(act) = Self::draw_stop_control(ui, track) {
|
||||||
ui.spacing_mut().slider_width = position_slider_width;
|
action = Some(act);
|
||||||
let position_slider_response = ui.add_sized([30.0, 30.0], position_slider);
|
|
||||||
if position_slider_response.drag_stopped() {
|
|
||||||
ui_state.position_dragged = true;
|
|
||||||
}
|
}
|
||||||
// --------------------------------
|
|
||||||
|
|
||||||
// ---------- Time Label ----------
|
|
||||||
let time_label =
|
|
||||||
Label::new(RichText::new(format_time_pair(track.position, duration)).monospace());
|
|
||||||
ui.add_sized([30.0, 30.0], time_label);
|
|
||||||
// --------------------------------
|
|
||||||
|
|
||||||
// ---------- Volume Icon ----------
|
|
||||||
let volume_icon = Self::get_volume_icon(track.volume);
|
|
||||||
let volume_label = Label::new(RichText::new(volume_icon).size(18.0));
|
|
||||||
ui.add_sized([30.0, 30.0], volume_label)
|
|
||||||
.on_hover_text(format!("Volume: {:.0}%", track.volume * 100.0));
|
|
||||||
// --------------------------------
|
|
||||||
|
|
||||||
// ---------- Volume Slider ----------
|
|
||||||
let volume_slider = Slider::new(&mut ui_state.volume_slider_value, 0.0..=1.0)
|
|
||||||
.show_value(false)
|
|
||||||
.step_by(0.01);
|
|
||||||
|
|
||||||
ui.spacing_mut().slider_width = default_slider_width - 30.0;
|
|
||||||
ui.spacing_mut().item_spacing.x = 0.0;
|
|
||||||
|
|
||||||
let volume_slider_response = ui.add_sized([30.0, 30.0], volume_slider);
|
|
||||||
if volume_slider_response.drag_stopped() {
|
|
||||||
ui_state.volume_dragged = true;
|
|
||||||
}
|
|
||||||
// --------------------------------
|
|
||||||
|
|
||||||
// ---------- Stop Button ---------
|
|
||||||
let stop_button = Button::new(ICON_CLOSE).frame(false);
|
|
||||||
let stop_button_response = ui.add_sized([30.0, 30.0], stop_button);
|
|
||||||
if stop_button_response.clicked() {
|
|
||||||
action = Some(TrackAction::Stop(track.id));
|
|
||||||
}
|
|
||||||
// --------------------------------
|
|
||||||
});
|
});
|
||||||
|
|
||||||
action
|
action
|
||||||
@@ -609,10 +657,10 @@ impl SoundpadGui {
|
|||||||
.unwrap_or_else(|| path.to_string_lossy().to_string());
|
.unwrap_or_else(|| path.to_string_lossy().to_string());
|
||||||
|
|
||||||
let mut dir_button_text = RichText::new(name.clone());
|
let mut dir_button_text = RichText::new(name.clone());
|
||||||
if let Some(current_dir) = &self.app_state.current_dir {
|
if let Some(current_dir) = &self.app_state.current_dir
|
||||||
if current_dir.eq(&path) {
|
&& current_dir.eq(&path)
|
||||||
dir_button_text = dir_button_text.color(Color32::WHITE);
|
{
|
||||||
}
|
dir_button_text = dir_button_text.color(Color32::WHITE);
|
||||||
}
|
}
|
||||||
|
|
||||||
let dir_button =
|
let dir_button =
|
||||||
@@ -645,10 +693,9 @@ impl SoundpadGui {
|
|||||||
ICON_OPEN_IN_BROWSER.codepoint, "Open in File Manager"
|
ICON_OPEN_IN_BROWSER.codepoint, "Open in File Manager"
|
||||||
))
|
))
|
||||||
.clicked()
|
.clicked()
|
||||||
|
&& let Err(e) = opener::open(&path)
|
||||||
{
|
{
|
||||||
if let Err(e) = opener::open(&path) {
|
eprintln!("Failed to open file manager: {}", e);
|
||||||
eprintln!("Failed to open file manager: {}", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.separator();
|
ui.separator();
|
||||||
@@ -728,13 +775,13 @@ impl SoundpadGui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut file_button_text = RichText::new(&file_name);
|
let mut file_button_text = RichText::new(&file_name);
|
||||||
if let Some(current_file) = &self.app_state.selected_file {
|
if let Some(current_file) = &self.app_state.selected_file
|
||||||
if current_file.eq(&entry_path) {
|
&& current_file.eq(&entry_path)
|
||||||
file_button_text = file_button_text.color(Color32::WHITE);
|
{
|
||||||
}
|
file_button_text = file_button_text.color(Color32::WHITE);
|
||||||
}
|
}
|
||||||
|
|
||||||
let file_button = Button::new(file_button_text).frame(false);
|
let file_button = Button::new(file_button_text).frame(false).truncate();
|
||||||
let file_button_response = ui.add(file_button);
|
let file_button_response = ui.add(file_button);
|
||||||
if file_button_response.clicked() {
|
if file_button_response.clicked() {
|
||||||
ui.input(|i| {
|
ui.input(|i| {
|
||||||
@@ -792,10 +839,9 @@ impl SoundpadGui {
|
|||||||
ICON_OPEN_IN_BROWSER.codepoint, "Show in File Manager"
|
ICON_OPEN_IN_BROWSER.codepoint, "Show in File Manager"
|
||||||
))
|
))
|
||||||
.clicked()
|
.clicked()
|
||||||
|
&& let Err(e) = opener::reveal(&entry_path)
|
||||||
{
|
{
|
||||||
if let Err(e) = opener::reveal(&entry_path) {
|
eprintln!("Failed to open file manager: {}", e);
|
||||||
eprintln!("Failed to open file manager: {}", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.separator();
|
ui.separator();
|
||||||
@@ -820,17 +866,16 @@ impl SoundpadGui {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_hotkey_badge(&self, path: &PathBuf) -> Option<String> {
|
fn get_hotkey_badge(&self, path: &Path) -> Option<String> {
|
||||||
for slot in &self.app_state.hotkey_config.slots {
|
for slot in &self.app_state.hotkey_config.slots {
|
||||||
if slot.action.name == "play" {
|
if slot.action.name == "play"
|
||||||
if let Some(file_path_str) = slot.action.args.get("file_path") {
|
&& let Some(file_path_str) = slot.action.args.get("file_path")
|
||||||
if Path::new(file_path_str) == path.as_path() {
|
&& Path::new(file_path_str) == path
|
||||||
if let Some(chord) = &slot.key_chord {
|
{
|
||||||
return Some(format!("[{}]", chord));
|
if let Some(chord) = &slot.key_chord {
|
||||||
} else {
|
return Some(format!("[{}]", chord));
|
||||||
return Some(format!("[{}]", slot.slot));
|
} else {
|
||||||
}
|
return Some(format!("[{}]", slot.slot));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-115
@@ -7,57 +7,18 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
/// Convert an egui Key + Modifiers to a normalized chord string like "Ctrl+Shift+A".
|
/// Convert an egui Key + Modifiers to a normalized chord string like "Ctrl+Shift+A".
|
||||||
fn chord_from_event(modifiers: &Modifiers, key: &Key) -> Option<String> {
|
fn chord_from_event(modifiers: &Modifiers, key: &Key) -> Option<String> {
|
||||||
let key_name = match key {
|
let key_name = key.name();
|
||||||
Key::A => "A",
|
let is_valid = (key_name.len() == 1
|
||||||
Key::B => "B",
|
&& key_name
|
||||||
Key::C => "C",
|
.chars()
|
||||||
Key::D => "D",
|
.next()
|
||||||
Key::E => "E",
|
.is_some_and(|c| c.is_ascii_alphanumeric()))
|
||||||
Key::F => "F",
|
|| (key_name.starts_with('F')
|
||||||
Key::G => "G",
|
&& key_name.len() > 1
|
||||||
Key::H => "H",
|
&& key_name[1..].chars().all(|c| c.is_ascii_digit()));
|
||||||
Key::I => "I",
|
if !is_valid {
|
||||||
Key::J => "J",
|
return None;
|
||||||
Key::K => "K",
|
}
|
||||||
Key::L => "L",
|
|
||||||
Key::M => "M",
|
|
||||||
Key::N => "N",
|
|
||||||
Key::O => "O",
|
|
||||||
Key::P => "P",
|
|
||||||
Key::Q => "Q",
|
|
||||||
Key::R => "R",
|
|
||||||
Key::S => "S",
|
|
||||||
Key::T => "T",
|
|
||||||
Key::U => "U",
|
|
||||||
Key::V => "V",
|
|
||||||
Key::W => "W",
|
|
||||||
Key::X => "X",
|
|
||||||
Key::Y => "Y",
|
|
||||||
Key::Z => "Z",
|
|
||||||
Key::Num0 => "0",
|
|
||||||
Key::Num1 => "1",
|
|
||||||
Key::Num2 => "2",
|
|
||||||
Key::Num3 => "3",
|
|
||||||
Key::Num4 => "4",
|
|
||||||
Key::Num5 => "5",
|
|
||||||
Key::Num6 => "6",
|
|
||||||
Key::Num7 => "7",
|
|
||||||
Key::Num8 => "8",
|
|
||||||
Key::Num9 => "9",
|
|
||||||
Key::F1 => "F1",
|
|
||||||
Key::F2 => "F2",
|
|
||||||
Key::F3 => "F3",
|
|
||||||
Key::F4 => "F4",
|
|
||||||
Key::F5 => "F5",
|
|
||||||
Key::F6 => "F6",
|
|
||||||
Key::F7 => "F7",
|
|
||||||
Key::F8 => "F8",
|
|
||||||
Key::F9 => "F9",
|
|
||||||
Key::F10 => "F10",
|
|
||||||
Key::F11 => "F11",
|
|
||||||
Key::F12 => "F12",
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Require at least one modifier for hotkey chords (ignoring command/Super due to Wayland/Niri bug)
|
// Require at least one modifier for hotkey chords (ignoring command/Super due to Wayland/Niri bug)
|
||||||
if !modifiers.ctrl && !modifiers.alt && !modifiers.shift {
|
if !modifiers.ctrl && !modifiers.alt && !modifiers.shift {
|
||||||
@@ -100,57 +61,21 @@ pub fn parse_chord(chord: &str) -> Option<(Modifiers, Key)> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let key = match parts[parts.len() - 1] {
|
let key_name = parts[parts.len() - 1];
|
||||||
"A" => Key::A,
|
let is_valid = (key_name.len() == 1
|
||||||
"B" => Key::B,
|
&& key_name
|
||||||
"C" => Key::C,
|
.chars()
|
||||||
"D" => Key::D,
|
.next()
|
||||||
"E" => Key::E,
|
.is_some_and(|c| c.is_ascii_alphanumeric()))
|
||||||
"F" => Key::F,
|
|| (key_name.starts_with('F')
|
||||||
"G" => Key::G,
|
&& key_name.len() > 1
|
||||||
"H" => Key::H,
|
&& key_name[1..].chars().all(|c| c.is_ascii_digit()));
|
||||||
"I" => Key::I,
|
|
||||||
"J" => Key::J,
|
if !is_valid {
|
||||||
"K" => Key::K,
|
return None;
|
||||||
"L" => Key::L,
|
}
|
||||||
"M" => Key::M,
|
|
||||||
"N" => Key::N,
|
let key = Key::from_name(key_name)?;
|
||||||
"O" => Key::O,
|
|
||||||
"P" => Key::P,
|
|
||||||
"Q" => Key::Q,
|
|
||||||
"R" => Key::R,
|
|
||||||
"S" => Key::S,
|
|
||||||
"T" => Key::T,
|
|
||||||
"U" => Key::U,
|
|
||||||
"V" => Key::V,
|
|
||||||
"W" => Key::W,
|
|
||||||
"X" => Key::X,
|
|
||||||
"Y" => Key::Y,
|
|
||||||
"Z" => Key::Z,
|
|
||||||
"0" => Key::Num0,
|
|
||||||
"1" => Key::Num1,
|
|
||||||
"2" => Key::Num2,
|
|
||||||
"3" => Key::Num3,
|
|
||||||
"4" => Key::Num4,
|
|
||||||
"5" => Key::Num5,
|
|
||||||
"6" => Key::Num6,
|
|
||||||
"7" => Key::Num7,
|
|
||||||
"8" => Key::Num8,
|
|
||||||
"9" => Key::Num9,
|
|
||||||
"F1" => Key::F1,
|
|
||||||
"F2" => Key::F2,
|
|
||||||
"F3" => Key::F3,
|
|
||||||
"F4" => Key::F4,
|
|
||||||
"F5" => Key::F5,
|
|
||||||
"F6" => Key::F6,
|
|
||||||
"F7" => Key::F7,
|
|
||||||
"F8" => Key::F8,
|
|
||||||
"F9" => Key::F9,
|
|
||||||
"F10" => Key::F10,
|
|
||||||
"F11" => Key::F11,
|
|
||||||
"F12" => Key::F12,
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
|
|
||||||
Some((modifiers, key))
|
Some((modifiers, key))
|
||||||
}
|
}
|
||||||
@@ -273,18 +198,18 @@ impl SoundpadGui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Play selected file on Enter
|
// Play selected file on Enter
|
||||||
if self.key_pressed(ctx, Key::Enter) {
|
if self.key_pressed(ctx, Key::Enter)
|
||||||
if let Some(path) = self.app_state.selected_file.clone() {
|
&& let Some(path) = self.app_state.selected_file.clone()
|
||||||
if modifiers.ctrl {
|
{
|
||||||
self.play_file(&path, true);
|
if modifiers.ctrl {
|
||||||
} else if modifiers.shift
|
self.play_file(&path, true);
|
||||||
&& let Some(last_track) = self.audio_player_state.tracks.last()
|
} else if modifiers.shift
|
||||||
{
|
&& let Some(last_track) = self.audio_player_state.tracks.last()
|
||||||
self.stop(Some(last_track.id));
|
{
|
||||||
self.play_file(&path, true);
|
self.stop(Some(last_track.id));
|
||||||
} else {
|
self.play_file(&path, true);
|
||||||
self.play_file(&path, false);
|
} else {
|
||||||
}
|
self.play_file(&path, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,7 +218,7 @@ impl SoundpadGui {
|
|||||||
let arrow_down_pressed = self.key_pressed(ctx, Key::ArrowDown);
|
let arrow_down_pressed = self.key_pressed(ctx, Key::ArrowDown);
|
||||||
if modifiers.ctrl && (arrow_up_pressed || arrow_down_pressed) {
|
if modifiers.ctrl && (arrow_up_pressed || arrow_down_pressed) {
|
||||||
if modifiers.shift && !self.app_state.dirs.is_empty() {
|
if modifiers.shift && !self.app_state.dirs.is_empty() {
|
||||||
let mut dirs: Vec<PathBuf> = self.app_state.dirs.iter().cloned().collect();
|
let mut dirs: Vec<PathBuf> = self.app_state.dirs.to_vec();
|
||||||
dirs.sort();
|
dirs.sort();
|
||||||
|
|
||||||
let current_dir_index = self
|
let current_dir_index = self
|
||||||
|
|||||||
+4
-4
@@ -21,12 +21,12 @@ use pwsp::{
|
|||||||
use rfd::FileDialog;
|
use rfd::FileDialog;
|
||||||
use std::{
|
use std::{
|
||||||
error::Error,
|
error::Error,
|
||||||
path::PathBuf,
|
path::{Path, PathBuf},
|
||||||
sync::{Arc, Mutex},
|
sync::{Arc, Mutex},
|
||||||
};
|
};
|
||||||
|
|
||||||
const SUPPORTED_EXTENSIONS: [&str; 12] = [
|
const SUPPORTED_EXTENSIONS: [&str; 13] = [
|
||||||
"mp3", "wav", "ogg", "flac", "mp4", "m4a", "aac", "mov", "mkv", "mka", "webm", "avi",
|
"mp3", "wav", "ogg", "flac", "mp4", "m4a", "aac", "mov", "mkv", "mka", "webm", "avi", "opus",
|
||||||
];
|
];
|
||||||
|
|
||||||
struct SoundpadGui {
|
struct SoundpadGui {
|
||||||
@@ -120,7 +120,7 @@ impl SoundpadGui {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn play_file(&mut self, path: &PathBuf, concurrent: bool) {
|
pub fn play_file(&mut self, path: &Path, concurrent: bool) {
|
||||||
make_request_async(Request::play(&path.to_string_lossy(), concurrent));
|
make_request_async(Request::play(&path.to_string_lossy(), concurrent));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+52
-19
@@ -2,7 +2,7 @@ use crate::{
|
|||||||
types::pipewire::{DeviceType, Terminate},
|
types::pipewire::{DeviceType, Terminate},
|
||||||
utils::{
|
utils::{
|
||||||
daemon::get_daemon_config,
|
daemon::get_daemon_config,
|
||||||
pipewire::{create_link, get_device},
|
pipewire::{create_link, get_device, link_player_to_virtual_mic},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use rodio::{Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source};
|
use rodio::{Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source};
|
||||||
@@ -58,6 +58,7 @@ pub struct AudioPlayer {
|
|||||||
pub next_id: u32,
|
pub next_id: u32,
|
||||||
|
|
||||||
input_link_sender: Option<pipewire::channel::Sender<Terminate>>,
|
input_link_sender: Option<pipewire::channel::Sender<Terminate>>,
|
||||||
|
player_link_sender: Option<pipewire::channel::Sender<Terminate>>,
|
||||||
pub input_device_name: Option<String>,
|
pub input_device_name: Option<String>,
|
||||||
|
|
||||||
pub volume: f32, // Master volume
|
pub volume: f32, // Master volume
|
||||||
@@ -74,6 +75,7 @@ impl AudioPlayer {
|
|||||||
next_id: 1,
|
next_id: 1,
|
||||||
|
|
||||||
input_link_sender: None,
|
input_link_sender: None,
|
||||||
|
player_link_sender: None,
|
||||||
input_device_name: daemon_config.default_input_name.clone(),
|
input_device_name: daemon_config.default_input_name.clone(),
|
||||||
|
|
||||||
volume: default_volume,
|
volume: default_volume,
|
||||||
@@ -98,21 +100,46 @@ impl AudioPlayer {
|
|||||||
fn drop_stream(&mut self) {
|
fn drop_stream(&mut self) {
|
||||||
if self.stream_handle.is_some() {
|
if self.stream_handle.is_some() {
|
||||||
self.stream_handle = None;
|
self.stream_handle = None;
|
||||||
|
self.abort_player_link_thread();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn abort_link_thread(&mut self) {
|
fn abort_link_thread(&mut self) {
|
||||||
if let Some(sender) = &self.input_link_sender {
|
if let Some(sender) = &self.input_link_sender {
|
||||||
match sender.send(Terminate {}) {
|
if sender.send(Terminate {}).is_ok() {
|
||||||
Ok(_) => {
|
println!("Sent terminate signal to input link thread");
|
||||||
println!("Sent terminate signal to link thread");
|
self.input_link_sender = None;
|
||||||
self.input_link_sender = None;
|
} else {
|
||||||
}
|
eprintln!("Failed to send terminate signal to input link thread");
|
||||||
Err(_) => eprintln!("Failed to send terminate signal to link thread"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn abort_player_link_thread(&mut self) {
|
||||||
|
if let Some(sender) = &self.player_link_sender {
|
||||||
|
if sender.send(Terminate {}).is_ok() {
|
||||||
|
println!("Sent terminate signal to player link thread");
|
||||||
|
self.player_link_sender = None;
|
||||||
|
} else {
|
||||||
|
eprintln!("Failed to send terminate signal to player link thread");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn link_player(&mut self) -> Result<(), Box<dyn Error>> {
|
||||||
|
if self.player_link_sender.is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
match link_player_to_virtual_mic().await {
|
||||||
|
Ok(sender) => {
|
||||||
|
self.player_link_sender = Some(sender);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(_) => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn link_devices(&mut self) -> Result<(), Box<dyn Error>> {
|
async fn link_devices(&mut self) -> Result<(), Box<dyn Error>> {
|
||||||
self.abort_link_thread();
|
self.abort_link_thread();
|
||||||
|
|
||||||
@@ -227,12 +254,12 @@ impl AudioPlayer {
|
|||||||
pub fn get_volume(&mut self, id: Option<u32>) -> Option<f32> {
|
pub fn get_volume(&mut self, id: Option<u32>) -> Option<f32> {
|
||||||
if let Some(id) = id {
|
if let Some(id) = id {
|
||||||
if let Some(sound) = self.tracks.get_mut(&id) {
|
if let Some(sound) = self.tracks.get_mut(&id) {
|
||||||
return Some(sound.sink.volume());
|
Some(sound.sink.volume())
|
||||||
} else {
|
} else {
|
||||||
return None;
|
None
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return Some(self.volume);
|
Some(self.volume)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,6 +343,7 @@ impl AudioPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.ensure_stream()?;
|
self.ensure_stream()?;
|
||||||
|
self.link_player().await.ok();
|
||||||
|
|
||||||
let id = self.next_id;
|
let id = self.next_id;
|
||||||
self.next_id += 1;
|
self.next_id += 1;
|
||||||
@@ -394,6 +422,10 @@ impl AudioPlayer {
|
|||||||
self.link_devices().await.ok();
|
self.link_devices().await.ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.stream_handle.is_some() && self.player_link_sender.is_none() {
|
||||||
|
self.link_player().await.ok();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle looped sounds
|
// Handle looped sounds
|
||||||
@@ -411,10 +443,10 @@ impl AudioPlayer {
|
|||||||
if let Some(sound) = self.tracks.get(&id) {
|
if let Some(sound) = self.tracks.get(&id) {
|
||||||
let path = sound.path.clone();
|
let path = sound.path.clone();
|
||||||
let handle = tokio::task::spawn_blocking(move || {
|
let handle = tokio::task::spawn_blocking(move || {
|
||||||
if let Ok(file) = fs::File::open(&path) {
|
if let Ok(file) = fs::File::open(&path)
|
||||||
if let Ok(source) = Decoder::try_from(file) {
|
&& let Ok(source) = Decoder::try_from(file)
|
||||||
return Some((id, source));
|
{
|
||||||
}
|
return Some((id, source));
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
});
|
});
|
||||||
@@ -423,11 +455,12 @@ impl AudioPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for handle in restart_futures {
|
for handle in restart_futures {
|
||||||
if let Ok(Some((id, source))) = handle.await {
|
if let Ok(res) = handle.await
|
||||||
if let Some(sound) = self.tracks.get_mut(&id) {
|
&& let Some((id, source)) = res
|
||||||
sound.sink.append(source);
|
&& let Some(sound) = self.tracks.get_mut(&id)
|
||||||
sound.sink.play();
|
{
|
||||||
}
|
sound.sink.append(source);
|
||||||
|
sound.sink.play();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -673,7 +673,7 @@ impl Executable for PlayHotkeyCommand {
|
|||||||
if let Some(cmd) = parse_command(&action) {
|
if let Some(cmd) = parse_command(&action) {
|
||||||
cmd.execute().await
|
cmd.execute().await
|
||||||
} else {
|
} else {
|
||||||
Response::new(false, "Unknown command in hotkey slot".to_string())
|
Response::new(false, "Unknown command in hotkey slot")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-10
@@ -13,10 +13,10 @@ impl DaemonConfig {
|
|||||||
pub fn save_to_file(&self) -> Result<(), Box<dyn Error>> {
|
pub fn save_to_file(&self) -> Result<(), Box<dyn Error>> {
|
||||||
let config_path = get_config_path()?.join("daemon.json");
|
let config_path = get_config_path()?.join("daemon.json");
|
||||||
|
|
||||||
if let Some(config_dir) = config_path.parent() {
|
if let Some(config_dir) = config_path.parent()
|
||||||
if !config_path.exists() {
|
&& !config_path.exists()
|
||||||
fs::create_dir_all(config_dir)?;
|
{
|
||||||
}
|
fs::create_dir_all(config_dir)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let config_json = serde_json::to_string_pretty(self)?;
|
let config_json = serde_json::to_string_pretty(self)?;
|
||||||
@@ -68,10 +68,10 @@ impl GuiConfig {
|
|||||||
pub fn save_to_file(&mut self) -> Result<(), Box<dyn Error>> {
|
pub fn save_to_file(&mut self) -> Result<(), Box<dyn Error>> {
|
||||||
let config_path = get_config_path()?.join("gui.json");
|
let config_path = get_config_path()?.join("gui.json");
|
||||||
|
|
||||||
if let Some(config_dir) = config_path.parent() {
|
if let Some(config_dir) = config_path.parent()
|
||||||
if !config_path.exists() {
|
&& !config_path.exists()
|
||||||
fs::create_dir_all(config_dir)?;
|
{
|
||||||
}
|
fs::create_dir_all(config_dir)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Do not save scale factor if user does not want to
|
// Do not save scale factor if user does not want to
|
||||||
@@ -172,7 +172,7 @@ impl HotkeyConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns pairs of slot names that share the same key chord.
|
/// Returns pairs of slot names that share the same key chord.
|
||||||
pub fn find_conflicts(&self) -> Vec<(String, String)> {
|
pub fn find_conflicts(&self) -> Vec<(&str, &str)> {
|
||||||
let mut conflicts = vec![];
|
let mut conflicts = vec![];
|
||||||
let mut chord_map: HashMap<&str, Vec<&str>> = HashMap::new();
|
let mut chord_map: HashMap<&str, Vec<&str>> = HashMap::new();
|
||||||
|
|
||||||
@@ -186,7 +186,7 @@ impl HotkeyConfig {
|
|||||||
if slots.len() > 1 {
|
if slots.len() > 1 {
|
||||||
for i in 0..slots.len() {
|
for i in 0..slots.len() {
|
||||||
for j in (i + 1)..slots.len() {
|
for j in (i + 1)..slots.len() {
|
||||||
conflicts.push((slots[i].to_string(), slots[j].to_string()));
|
conflicts.push((slots[i], slots[j]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
pub const MAX_MESSAGE_SIZE: usize = 128 * 1024;
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct Request {
|
pub struct Request {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
|||||||
@@ -122,3 +122,88 @@ pub fn parse_command(request: &Request) -> Option<Box<dyn Executable + Send>> {
|
|||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::types::socket::Request;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_set_volume_valid() {
|
||||||
|
let mut args = HashMap::new();
|
||||||
|
args.insert("volume".to_string(), "0.5".to_string());
|
||||||
|
args.insert("id".to_string(), "1".to_string());
|
||||||
|
let request = Request {
|
||||||
|
name: "set_volume".to_string(),
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cmd = parse_command(&request);
|
||||||
|
assert!(cmd.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_set_volume_missing_volume() {
|
||||||
|
let mut args = HashMap::new();
|
||||||
|
args.insert("id".to_string(), "1".to_string());
|
||||||
|
let request = Request {
|
||||||
|
name: "set_volume".to_string(),
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cmd = parse_command(&request);
|
||||||
|
assert!(cmd.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_set_volume_invalid_volume() {
|
||||||
|
let mut args = HashMap::new();
|
||||||
|
args.insert("volume".to_string(), "not-a-float".to_string());
|
||||||
|
let request = Request {
|
||||||
|
name: "set_volume".to_string(),
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cmd = parse_command(&request);
|
||||||
|
assert!(cmd.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_set_volume_missing_id() {
|
||||||
|
let mut args = HashMap::new();
|
||||||
|
args.insert("volume".to_string(), "0.5".to_string());
|
||||||
|
let request = Request {
|
||||||
|
name: "set_volume".to_string(),
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cmd = parse_command(&request);
|
||||||
|
assert!(cmd.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_set_volume_invalid_id() {
|
||||||
|
let mut args = HashMap::new();
|
||||||
|
args.insert("id".to_string(), "not-an-int".to_string());
|
||||||
|
args.insert("volume".to_string(), "0.5".to_string());
|
||||||
|
let request = Request {
|
||||||
|
name: "set_volume".to_string(),
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cmd = parse_command(&request);
|
||||||
|
assert!(cmd.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_set_volume_empty_args() {
|
||||||
|
let request = Request {
|
||||||
|
name: "set_volume".to_string(),
|
||||||
|
args: HashMap::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let cmd = parse_command(&request);
|
||||||
|
assert!(cmd.is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+13
-45
@@ -1,11 +1,9 @@
|
|||||||
use crate::{
|
use crate::types::{
|
||||||
types::{
|
audio_player::AudioPlayer,
|
||||||
audio_player::AudioPlayer,
|
config::DaemonConfig,
|
||||||
config::DaemonConfig,
|
socket::{MAX_MESSAGE_SIZE, Request, Response},
|
||||||
socket::{Request, Response},
|
|
||||||
},
|
|
||||||
utils::pipewire::{create_link, get_device},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::{error::Error, fs};
|
use std::{error::Error, fs};
|
||||||
@@ -38,44 +36,6 @@ pub fn get_daemon_config() -> DaemonConfig {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn link_player_to_virtual_mic() -> Result<(), Box<dyn Error>> {
|
|
||||||
let pwsp_daemon_output;
|
|
||||||
if let Ok(device) = get_device("pwsp-daemon").await {
|
|
||||||
pwsp_daemon_output = device;
|
|
||||||
} else {
|
|
||||||
return Err(
|
|
||||||
"Could not find alsa_playback.pwsp-daemon device, skipping device linking".into(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let pwsp_daemon_input;
|
|
||||||
if let Ok(device) = get_device("pwsp-virtual-mic").await {
|
|
||||||
pwsp_daemon_input = device;
|
|
||||||
} else {
|
|
||||||
return Err("Could not find pwsp-virtual-mic device, skipping device linking".into());
|
|
||||||
}
|
|
||||||
|
|
||||||
let output_fl = pwsp_daemon_output
|
|
||||||
.clone()
|
|
||||||
.output_fl
|
|
||||||
.expect("Failed to get pwsp-daemon output_fl");
|
|
||||||
let output_fr = pwsp_daemon_output
|
|
||||||
.clone()
|
|
||||||
.output_fr
|
|
||||||
.expect("Failed to get pwsp-daemon output_fl");
|
|
||||||
let input_fl = pwsp_daemon_input
|
|
||||||
.clone()
|
|
||||||
.input_fl
|
|
||||||
.expect("Failed to get pwsp-daemon input_fl");
|
|
||||||
let input_fr = pwsp_daemon_input
|
|
||||||
.clone()
|
|
||||||
.input_fr
|
|
||||||
.expect("Failed to get pwsp-daemon input_fr");
|
|
||||||
create_link(output_fl, output_fr, input_fl, input_fr)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_runtime_dir() -> PathBuf {
|
pub fn get_runtime_dir() -> PathBuf {
|
||||||
dirs::runtime_dir().unwrap_or(PathBuf::from("/run/pwsp"))
|
dirs::runtime_dir().unwrap_or(PathBuf::from("/run/pwsp"))
|
||||||
}
|
}
|
||||||
@@ -135,6 +95,14 @@ pub async fn make_request(request: Request) -> Result<Response, Box<dyn Error +
|
|||||||
}
|
}
|
||||||
let response_len = u32::from_le_bytes(len_bytes) as usize;
|
let response_len = u32::from_le_bytes(len_bytes) as usize;
|
||||||
|
|
||||||
|
if response_len > MAX_MESSAGE_SIZE {
|
||||||
|
eprintln!(
|
||||||
|
"Failed to read response from daemon: response too large ({} bytes)!",
|
||||||
|
response_len
|
||||||
|
);
|
||||||
|
return Err("Response too large".into());
|
||||||
|
}
|
||||||
|
|
||||||
let mut buffer = vec![0u8; response_len];
|
let mut buffer = vec![0u8; response_len];
|
||||||
if stream.read_exact(&mut buffer).await.is_err() {
|
if stream.read_exact(&mut buffer).await.is_err() {
|
||||||
return Err("Failed to read response".into());
|
return Err("Failed to read response".into());
|
||||||
|
|||||||
+7
-7
@@ -112,13 +112,13 @@ pub fn start_app_state_thread(audio_player_state_shared: Arc<Mutex<AudioPlayerSt
|
|||||||
let hotkey_res = make_request(Request::get_hotkeys())
|
let hotkey_res = make_request(Request::get_hotkeys())
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
if hotkey_res.status {
|
if hotkey_res.status
|
||||||
if let Ok(config) = serde_json::from_str::<HotkeyConfig>(&hotkey_res.message) {
|
&& let Ok(config) = serde_json::from_str::<HotkeyConfig>(&hotkey_res.message)
|
||||||
let mut guard = audio_player_state_shared
|
{
|
||||||
.lock()
|
let mut guard = audio_player_state_shared
|
||||||
.unwrap_or_else(|e| e.into_inner());
|
.lock()
|
||||||
guard.hotkey_config = Some(config);
|
.unwrap_or_else(|e| e.into_inner());
|
||||||
}
|
guard.hotkey_config = Some(config);
|
||||||
}
|
}
|
||||||
last_hotkey_poll = Instant::now();
|
last_hotkey_poll = Instant::now();
|
||||||
}
|
}
|
||||||
|
|||||||
+165
-50
@@ -9,6 +9,13 @@ use tokio::{
|
|||||||
time::{Duration, timeout},
|
time::{Duration, timeout},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub fn setup_pipewire_context() -> Result<(MainLoopRc, ContextRc), String> {
|
||||||
|
pipewire::init();
|
||||||
|
let main_loop = MainLoopRc::new(None).map_err(|e| e.to_string())?;
|
||||||
|
let context = ContextRc::new(&main_loop, None).map_err(|e| e.to_string())?;
|
||||||
|
Ok((main_loop, context))
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_global_object(
|
fn parse_global_object(
|
||||||
global_object: &GlobalObject<&DictRef>,
|
global_object: &GlobalObject<&DictRef>,
|
||||||
) -> (Option<AudioDevice>, Option<Port>) {
|
) -> (Option<AudioDevice>, Option<Port>) {
|
||||||
@@ -56,20 +63,20 @@ fn parse_global_object(
|
|||||||
(None, None)
|
(None, None)
|
||||||
};
|
};
|
||||||
// Check if the object is a port
|
// Check if the object is a port
|
||||||
} else if props.get("port.direction").is_some() {
|
} else if props.get("port.direction").is_some()
|
||||||
if let (Some(node_id), Some(port_id), Some(port_name)) = (
|
&& let (Some(node_id), Some(port_id), Some(port_name)) = (
|
||||||
props.get("node.id").and_then(|id| id.parse::<u32>().ok()),
|
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.id").and_then(|id| id.parse::<u32>().ok()),
|
||||||
props.get("port.name"),
|
props.get("port.name"),
|
||||||
) {
|
)
|
||||||
let port = Port {
|
{
|
||||||
node_id,
|
let port = Port {
|
||||||
port_id,
|
node_id,
|
||||||
name: port_name.to_string(),
|
port_id,
|
||||||
};
|
name: port_name.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
return (None, Some(port));
|
return (None, Some(port));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(None, None)
|
(None, None)
|
||||||
@@ -78,10 +85,15 @@ fn parse_global_object(
|
|||||||
async fn pw_get_global_objects_thread(
|
async fn pw_get_global_objects_thread(
|
||||||
main_sender: mpsc::Sender<(Option<AudioDevice>, Option<Port>)>,
|
main_sender: mpsc::Sender<(Option<AudioDevice>, Option<Port>)>,
|
||||||
pw_receiver: pipewire::channel::Receiver<Terminate>,
|
pw_receiver: pipewire::channel::Receiver<Terminate>,
|
||||||
|
init_sender: std::sync::mpsc::SyncSender<Result<(), String>>,
|
||||||
) {
|
) {
|
||||||
pipewire::init();
|
let (main_loop, context) = match setup_pipewire_context() {
|
||||||
|
Ok(res) => res,
|
||||||
let main_loop = MainLoopRc::new(None).expect("Failed to initialize pipewire main loop");
|
Err(e) => {
|
||||||
|
let _ = init_sender.send(Err(e));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Stop main loop on Terminate message
|
// Stop main loop on Terminate message
|
||||||
let _receiver = pw_receiver.attach(main_loop.loop_(), {
|
let _receiver = pw_receiver.attach(main_loop.loop_(), {
|
||||||
@@ -89,13 +101,24 @@ async fn pw_get_global_objects_thread(
|
|||||||
move |_| _main_loop.quit()
|
move |_| _main_loop.quit()
|
||||||
});
|
});
|
||||||
|
|
||||||
let context = ContextRc::new(&main_loop, None).expect("Failed to create pipewire context");
|
let core = match context.connect(None) {
|
||||||
let core = context
|
Ok(core) => core,
|
||||||
.connect(None)
|
Err(e) => {
|
||||||
.expect("Failed to connect to pipewire context");
|
let _ = init_sender.send(Err(format!("Failed to connect to pipewire context: {}", e)));
|
||||||
let registry = core
|
return;
|
||||||
.get_registry()
|
}
|
||||||
.expect("Failed to get registry from pipewire context");
|
};
|
||||||
|
|
||||||
|
let registry = match core.get_registry() {
|
||||||
|
Ok(registry) => registry,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = init_sender.send(Err(format!(
|
||||||
|
"Failed to get registry from pipewire context: {}",
|
||||||
|
e
|
||||||
|
)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let _listener = registry
|
let _listener = registry
|
||||||
.add_listener_local()
|
.add_listener_local()
|
||||||
@@ -111,6 +134,11 @@ async fn pw_get_global_objects_thread(
|
|||||||
})
|
})
|
||||||
.register();
|
.register();
|
||||||
|
|
||||||
|
// Signal successful initialization
|
||||||
|
if init_sender.send(Ok(())).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
main_loop.run();
|
main_loop.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,10 +146,17 @@ pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>), B
|
|||||||
// 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);
|
||||||
|
|
||||||
// Spawn pipewire thread in background
|
// Spawn pipewire thread in background
|
||||||
let _pw_thread =
|
let _pw_thread = tokio::spawn(async move {
|
||||||
tokio::spawn(async move { pw_get_global_objects_thread(main_sender, pw_receiver).await });
|
pw_get_global_objects_thread(main_sender, pw_receiver, init_sender).await
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for initialization to complete
|
||||||
|
if let Err(e) = init_receiver.recv()? {
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
|
||||||
let mut input_devices: HashMap<u32, AudioDevice> = HashMap::new();
|
let mut input_devices: HashMap<u32, AudioDevice> = HashMap::new();
|
||||||
let mut output_devices: HashMap<u32, AudioDevice> = HashMap::new();
|
let mut output_devices: HashMap<u32, AudioDevice> = HashMap::new();
|
||||||
@@ -146,9 +181,7 @@ pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>), B
|
|||||||
}
|
}
|
||||||
Ok(None) | Err(_) => {
|
Ok(None) | Err(_) => {
|
||||||
// Pipewire thread is finished and we can collect our devices
|
// Pipewire thread is finished and we can collect our devices
|
||||||
pw_sender
|
let _ = pw_sender.send(Terminate {});
|
||||||
.send(Terminate {})
|
|
||||||
.expect("Failed to terminate pipewire thread");
|
|
||||||
|
|
||||||
for port in ports {
|
for port in ports {
|
||||||
let node_id = port.node_id;
|
let node_id = port.node_id;
|
||||||
@@ -196,8 +229,8 @@ pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>), B
|
|||||||
let mut output_devices: Vec<AudioDevice> =
|
let mut output_devices: Vec<AudioDevice> =
|
||||||
output_devices.values().cloned().collect();
|
output_devices.values().cloned().collect();
|
||||||
|
|
||||||
input_devices.sort_by(|a, b| a.id.cmp(&b.id));
|
input_devices.sort_by_key(|a| a.id);
|
||||||
output_devices.sort_by(|a, b| a.id.cmp(&b.id));
|
output_devices.sort_by_key(|a| a.id);
|
||||||
|
|
||||||
return Ok((input_devices, output_devices));
|
return Ok((input_devices, output_devices));
|
||||||
}
|
}
|
||||||
@@ -222,15 +255,24 @@ pub async fn get_device(device_name: &str) -> Result<AudioDevice, Box<dyn Error>
|
|||||||
|
|
||||||
pub fn create_virtual_mic() -> Result<pipewire::channel::Sender<Terminate>, Box<dyn Error>> {
|
pub fn create_virtual_mic() -> Result<pipewire::channel::Sender<Terminate>, Box<dyn Error>> {
|
||||||
let (pw_sender, pw_receiver) = pipewire::channel::channel::<Terminate>();
|
let (pw_sender, pw_receiver) = pipewire::channel::channel::<Terminate>();
|
||||||
|
let (init_sender, init_receiver) = std::sync::mpsc::sync_channel(0);
|
||||||
|
|
||||||
let _pw_thread = thread::spawn(move || {
|
let _pw_thread = thread::spawn(move || {
|
||||||
pipewire::init();
|
let (main_loop, context) = match setup_pipewire_context() {
|
||||||
|
Ok(res) => res,
|
||||||
let main_loop = MainLoopRc::new(None).expect("Failed to initialize pipewire main loop");
|
Err(e) => {
|
||||||
let context = ContextRc::new(&main_loop, None).expect("Failed to create pipewire context");
|
let _ = init_sender.send(Err(e));
|
||||||
let core = context
|
return;
|
||||||
.connect(None)
|
}
|
||||||
.expect("Failed to connect to pipewire context");
|
};
|
||||||
|
let core = match context.connect(None) {
|
||||||
|
Ok(core) => core,
|
||||||
|
Err(e) => {
|
||||||
|
let _ =
|
||||||
|
init_sender.send(Err(format!("Failed to connect to pipewire context: {}", e)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let props = properties!(
|
let props = properties!(
|
||||||
"factory.name" => "support.null-audio-sink",
|
"factory.name" => "support.null-audio-sink",
|
||||||
@@ -242,9 +284,13 @@ pub fn create_virtual_mic() -> Result<pipewire::channel::Sender<Terminate>, Box<
|
|||||||
"object.linger" => "false", // Destroy the node on app exit
|
"object.linger" => "false", // Destroy the node on app exit
|
||||||
);
|
);
|
||||||
|
|
||||||
let _node = core
|
let _node = match core.create_object::<pipewire::node::Node>("adapter", &props) {
|
||||||
.create_object::<pipewire::node::Node>("adapter", &props)
|
Ok(node) => node,
|
||||||
.expect("Failed to create virtual mic");
|
Err(e) => {
|
||||||
|
let _ = init_sender.send(Err(format!("Failed to create virtual mic: {}", e)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let _receiver = pw_receiver.attach(main_loop.loop_(), {
|
let _receiver = pw_receiver.attach(main_loop.loop_(), {
|
||||||
let _main_loop = main_loop.clone();
|
let _main_loop = main_loop.clone();
|
||||||
@@ -252,12 +298,57 @@ pub fn create_virtual_mic() -> Result<pipewire::channel::Sender<Terminate>, Box<
|
|||||||
});
|
});
|
||||||
|
|
||||||
println!("Virtual mic created");
|
println!("Virtual mic created");
|
||||||
|
if init_sender.send(Ok(())).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
main_loop.run();
|
main_loop.run();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if let Err(e) = init_receiver.recv()? {
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
|
||||||
Ok(pw_sender)
|
Ok(pw_sender)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn link_player_to_virtual_mic()
|
||||||
|
-> Result<pipewire::channel::Sender<Terminate>, Box<dyn Error>> {
|
||||||
|
let pwsp_daemon_output = match get_device("pwsp-daemon").await {
|
||||||
|
Ok(device) => device,
|
||||||
|
Err(_) => {
|
||||||
|
return Err(
|
||||||
|
"Could not find alsa_playback.pwsp-daemon device, skipping device linking".into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let pwsp_daemon_input = match get_device("pwsp-virtual-mic").await {
|
||||||
|
Ok(device) => device,
|
||||||
|
Err(_) => {
|
||||||
|
return Err("Could not find pwsp-virtual-mic device, skipping device linking".into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let output_fl = match pwsp_daemon_output.output_fl {
|
||||||
|
Some(port) => port,
|
||||||
|
None => return Err("Failed to get pwsp-daemon output_fl".into()),
|
||||||
|
};
|
||||||
|
let output_fr = match pwsp_daemon_output.output_fr {
|
||||||
|
Some(port) => port,
|
||||||
|
None => return Err("Failed to get pwsp-daemon output_fr".into()),
|
||||||
|
};
|
||||||
|
let input_fl = match pwsp_daemon_input.input_fl {
|
||||||
|
Some(port) => port,
|
||||||
|
None => return Err("Failed to get pwsp-virtual-mic input_fl".into()),
|
||||||
|
};
|
||||||
|
let input_fr = match pwsp_daemon_input.input_fr {
|
||||||
|
Some(port) => port,
|
||||||
|
None => return Err("Failed to get pwsp-virtual-mic input_fr".into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
create_link(output_fl, output_fr, input_fl, input_fr)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn create_link(
|
pub fn create_link(
|
||||||
output_fl: Port,
|
output_fl: Port,
|
||||||
output_fr: Port,
|
output_fr: Port,
|
||||||
@@ -265,15 +356,24 @@ pub fn create_link(
|
|||||||
input_fr: Port,
|
input_fr: Port,
|
||||||
) -> Result<pipewire::channel::Sender<Terminate>, Box<dyn Error>> {
|
) -> Result<pipewire::channel::Sender<Terminate>, Box<dyn Error>> {
|
||||||
let (pw_sender, pw_receiver) = pipewire::channel::channel::<Terminate>();
|
let (pw_sender, pw_receiver) = pipewire::channel::channel::<Terminate>();
|
||||||
|
let (init_sender, init_receiver) = std::sync::mpsc::sync_channel(0);
|
||||||
|
|
||||||
let _pw_thread = thread::spawn(move || {
|
let _pw_thread = thread::spawn(move || {
|
||||||
pipewire::init();
|
let (main_loop, context) = match setup_pipewire_context() {
|
||||||
|
Ok(res) => res,
|
||||||
let main_loop = MainLoopRc::new(None).expect("Failed to initialize pipewire main loop");
|
Err(e) => {
|
||||||
let context = ContextRc::new(&main_loop, None).expect("Failed to create pipewire context");
|
let _ = init_sender.send(Err(e));
|
||||||
let core = context
|
return;
|
||||||
.connect(None)
|
}
|
||||||
.expect("Failed to connect to pipewire context");
|
};
|
||||||
|
let core = match context.connect(None) {
|
||||||
|
Ok(core) => core,
|
||||||
|
Err(e) => {
|
||||||
|
let _ =
|
||||||
|
init_sender.send(Err(format!("Failed to connect to pipewire context: {}", e)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let props_fl = properties! {
|
let props_fl = properties! {
|
||||||
"link.output.node" => format!("{}", output_fl.node_id).as_str(),
|
"link.output.node" => format!("{}", output_fl.node_id).as_str(),
|
||||||
@@ -288,12 +388,20 @@ pub fn create_link(
|
|||||||
"link.input.port" => format!("{}", input_fr.port_id).as_str(),
|
"link.input.port" => format!("{}", input_fr.port_id).as_str(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let _link_fl = core
|
let _link_fl = match core.create_object::<Link>("link-factory", &props_fl) {
|
||||||
.create_object::<Link>("link-factory", &props_fl)
|
Ok(link) => link,
|
||||||
.expect("Failed to create link FL");
|
Err(e) => {
|
||||||
let _link_fr = core
|
let _ = init_sender.send(Err(format!("Failed to create link FL: {}", e)));
|
||||||
.create_object::<Link>("link-factory", &props_fr)
|
return;
|
||||||
.expect("Failed to create link FR");
|
}
|
||||||
|
};
|
||||||
|
let _link_fr = match core.create_object::<Link>("link-factory", &props_fr) {
|
||||||
|
Ok(link) => link,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = init_sender.send(Err(format!("Failed to create link FR: {}", e)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let _receiver = pw_receiver.attach(main_loop.loop_(), {
|
let _receiver = pw_receiver.attach(main_loop.loop_(), {
|
||||||
let _main_loop = main_loop.clone();
|
let _main_loop = main_loop.clone();
|
||||||
@@ -304,8 +412,15 @@ pub fn create_link(
|
|||||||
"Link created: FL: {}-{} FR: {}-{}",
|
"Link created: FL: {}-{} FR: {}-{}",
|
||||||
output_fl.node_id, input_fl.node_id, output_fr.node_id, input_fr.node_id
|
output_fl.node_id, input_fl.node_id, output_fr.node_id, input_fr.node_id
|
||||||
);
|
);
|
||||||
|
if init_sender.send(Ok(())).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
main_loop.run();
|
main_loop.run();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if let Err(e) = init_receiver.recv()? {
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
|
||||||
Ok(pw_sender)
|
Ok(pw_sender)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user