mirror of
https://github.com/arabianq/pipewire-soundpad.git
synced 2026-06-19 12:13:32 +00:00
Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca9b5dd517 | |||
| 6863c9a6f8 | |||
| 958a3efde5 | |||
| 30e75e924c | |||
| 2b4b7ea730 | |||
| dafe67f35f | |||
| 8fa22ca5b0 | |||
| d72eaabf54 | |||
| 377b218592 | |||
| 911417af40 | |||
| 573958c05b | |||
| 0bb7ef3f33 | |||
| 10f07cd895 | |||
| b2f2894aa1 | |||
| e6c8d720d5 | |||
| a6d93ff528 | |||
| bcf791d84c | |||
| e4b0b10393 | |||
| 11de96db58 | |||
| 7396c0aef8 | |||
| fc2cd5e2da | |||
| 1a37729cf1 | |||
| 86b38a250e | |||
| 54fa278cea | |||
| db040aa820 | |||
| 04449e7525 | |||
| 9f50809a99 | |||
| 7dda4bc2b1 | |||
| 1569955e12 | |||
| 9adc6cfbda | |||
| 76b1d4f345 | |||
| 10f9937dc3 | |||
| 498c09eb50 | |||
| 78e0a133b6 | |||
| 7f8b7194b6 | |||
| 302f153b91 | |||
| f87dcb1564 | |||
| d4d16f6ce7 | |||
| 949307fcf8 | |||
| 2a8fcca06b | |||
| 5c4b8f4b45 | |||
| 70c7e3789b | |||
| 5367a3daae | |||
| 42c0170044 | |||
| cb56cb3a04 |
@@ -0,0 +1,6 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "cargo"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
@@ -23,7 +23,9 @@ jobs:
|
|||||||
zip jq \
|
zip jq \
|
||||||
libpipewire-0.3-dev \
|
libpipewire-0.3-dev \
|
||||||
libclang-dev \
|
libclang-dev \
|
||||||
libasound2-dev
|
libasound2-dev \
|
||||||
|
libdbus-1-dev \
|
||||||
|
pkg-config
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -74,7 +74,9 @@ jobs:
|
|||||||
zip jq \
|
zip jq \
|
||||||
libpipewire-0.3-dev \
|
libpipewire-0.3-dev \
|
||||||
libclang-dev \
|
libclang-dev \
|
||||||
libasound2-dev
|
libasound2-dev \
|
||||||
|
libdbus-1-dev \
|
||||||
|
pkg-config
|
||||||
|
|
||||||
- name: Checkout code at tag
|
- name: Checkout code at tag
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -174,10 +176,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
+599
-335
File diff suppressed because it is too large
Load Diff
+9
-6
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "pwsp"
|
name = "pwsp"
|
||||||
version = "1.7.0"
|
version = "1.8.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."
|
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.3", 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/arabianq/rodio.git", rev = "1a08f281c352622bd82b87b8731585245802d9cf", default-features = false, features = [
|
||||||
"symphonia-all",
|
"symphonia-all",
|
||||||
|
"symphonia-libopus",
|
||||||
"playback",
|
"playback",
|
||||||
] }
|
] }
|
||||||
pipewire = "0.9.2"
|
pipewire = "0.9.2"
|
||||||
@@ -39,17 +40,19 @@ 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"
|
||||||
|
|
||||||
egui = { version = "0.34.1", default-features = false, features = [
|
egui = { version = "0.34.2", default-features = false, features = [
|
||||||
"default_fonts",
|
"default_fonts",
|
||||||
"rayon",
|
"rayon",
|
||||||
] }
|
] }
|
||||||
eframe = { version = "0.34.1", default-features = false, features = [
|
eframe = { version = "0.34.2", default-features = false, features = [
|
||||||
"default_fonts",
|
"default_fonts",
|
||||||
"glow",
|
"glow",
|
||||||
"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"
|
||||||
|
|
||||||
|
|||||||
@@ -1,213 +1,120 @@
|
|||||||
# **🎵 Pipewire Soundpad (PWSP)**
|
<div align="center">
|
||||||
|
<h1>🎵 PipeWire Soundpad (PWSP)</h1>
|
||||||
|
<p><b>A simple, modern, and powerful soundboard for Linux, written in Rust.</b></p>
|
||||||
|
<img src="assets/screenshot.png" alt="PWSP Screenshot" width="700"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
**PipeWire Soundpad (PWSP)** is a simple yet powerful **soundboard application** written in **Rust**. It provides a
|
## 🌟 Overview
|
||||||
user-friendly graphical interface for **managing and playing audio files, directing their output directly to the virtual
|
**PipeWire Soundpad (PWSP)** is a graphical soundboard application that routes audio directly to your virtual microphone using **PipeWire**. It provides an intuitive interface for managing your audio collection, making it an ideal tool for gamers, streamers, and anyone looking to inject sound effects into voice chats on platforms like Discord, Zoom, or TeamSpeak.
|
||||||
microphone.** This makes it an ideal tool for gamers, streamers, and anyone looking to inject sound effects into voice
|
|
||||||
chats on platforms like **Discord, Zoom, or Teamspeak**.
|
|
||||||
|
|
||||||

|
## ✨ Key Features
|
||||||
|
* **🎙️ Virtual Microphone Output:** Seamlessly mixes your microphone input with sound effects by automatically managing PipeWire virtual devices.
|
||||||
|
* **🎵 Multi-Format Support:** Plays popular audio formats including `mp3`, `wav`, `ogg`, `flac`, `mp4`, and `aac`.
|
||||||
|
* **⚡ Global Hotkeys:** Trigger sounds instantly from anywhere, even when the app is running in the background.
|
||||||
|
* **📂 Smart Collection Management:** Drag-and-drop folders, quick search, and collapsible tracks to keep your library organized.
|
||||||
|
* **🎛️ Advanced Playback Controls:** Individual volume sliders, play/pause, position scrubbing, and concurrent multi-track playback.
|
||||||
|
* **🔌 Plug & Play:** Automatically detects when an input device is connected or disconnected and handles linking/unlinking on the fly.
|
||||||
|
* **🖥️ Modern GUI:** Clean, responsive, and lightweight interface powered by [egui](https://egui.rs/).
|
||||||
|
|
||||||
# **🌟 Key Features**
|
## ⚙️ Architecture
|
||||||
|
PWSP is built with a client-server model to ensure stability and separation of concerns:
|
||||||
|
* **`pwsp-daemon`**: The background engine. It runs silently, managing PipeWire virtual devices, audio routing, and playback.
|
||||||
|
* **`pwsp-gui`**: The graphical interface. Communicates with the daemon via a Unix socket to control playback and settings.
|
||||||
|
* **`pwsp-cli`**: The command-line tool. Perfect for scripting, hotkey binding, or quick terminal-based control.
|
||||||
|
|
||||||
* **Multi-Format Support**: Play audio files in popular formats, including _**mp3**_, _**wav**_, _**ogg**_, _**flac**_,
|
---
|
||||||
_**mp4**_, and _**aac**_.
|
|
||||||
* **Virtual Microphone Output**: The application routes audio through a virtual device created by PipeWire, allowing
|
|
||||||
other users to hear the sounds as if you were speaking into your microphone.
|
|
||||||
* **Modern and Clean GUI**: The interface is built with the [egui](https://egui.rs) library, ensuring an intuitive and
|
|
||||||
responsive user experience.
|
|
||||||
* **Sound Collection Management**: Easily add and remove directories containing your audio files. The application scans
|
|
||||||
these folders and displays all supported files for quick access.
|
|
||||||
* **Quick Search**: Use the built-in search bar to instantly find any sound file within your library.
|
|
||||||
* **Detailed Playback Controls**:
|
|
||||||
* **Play/Pause button**.
|
|
||||||
* **Volume slider** for individual sound adjustment.
|
|
||||||
* **Position slider** to fast-forward or rewind the audio.
|
|
||||||
* **Persistent Configuration**: The list of added directories and your selected audio output device are saved
|
|
||||||
automatically, so you won't need to reconfigure them every time you launch the application.
|
|
||||||
* **Collapsible Audio Tracks**: You can collapse every audio track to save space.
|
|
||||||
* **Drag and Drop Directories**: Reorder your sound directories easily using drag and drop.
|
|
||||||
* **Automatic Device Detection**: PWSP automatically detects when an input device is connected or disconnected and handles linking/unlinking.
|
|
||||||
|
|
||||||
# **⚙️ How It Works**
|
## 🚀 Installation
|
||||||
|
|
||||||
PWSP is designed with a clear separation of concerns, operating through a client-server architecture. It consists of
|
### 📦 Flatpak (Recommended)
|
||||||
three main components:
|
Install PWSP via Flatpak from our custom repository:
|
||||||
|
```bash
|
||||||
|
flatpak remote-add --user --if-not-exists pwsp-repo https://arabianq.github.io/pipewire-soundpad/index.flatpakrepo
|
||||||
|
|
||||||
* **pwsp-daemon**: This is the core of the application. It runs silently in the background, managing all the
|
# Install stable version
|
||||||
heavy-lifting tasks. The daemon is responsible for:
|
flatpak install --user arabianq-repo ru.arabianq.pwsp//stable
|
||||||
* Creating and managing virtual audio devices.
|
|
||||||
* Linking these devices within the PipeWire graph.
|
|
||||||
* Handling all audio playback.
|
|
||||||
* **pwsp-gui**: This is the graphical user interface. It acts as a client that communicates with pwsp-daemon via a
|
|
||||||
**UnixSocket**. This is how you interact with your sound collection, control playback, and configure settings.
|
|
||||||
* **pwsp-cli**: This is the command-line interface, also acting as a client. It provides a way to control the daemon
|
|
||||||
without a GUI, allowing for scripting or quick command-based actions.
|
|
||||||
|
|
||||||
# **🚀 Installation**
|
# Or install the nightly version (latest commit)
|
||||||
|
flatpak install --user arabianq-repo ru.arabianq.pwsp//nightly
|
||||||
## **Pre-built Packages**
|
```
|
||||||
|
|
||||||
You can download pre-built binaries and .deb packages from
|
|
||||||
the [releases page](https://github.com/arabianq/pipewire-soundpad/releases).
|
|
||||||
|
|
||||||
## **Fedora Linux (and derivatives)**
|
|
||||||
|
|
||||||
If you're using Fedora, you can install PWSP from a dedicated repository using DNF.
|
|
||||||
|
|
||||||
Add the repository:
|
|
||||||
|
|
||||||
|
### 🐧 Linux Packages
|
||||||
|
**Fedora (and derivatives):**
|
||||||
```bash
|
```bash
|
||||||
sudo dnf copr enable arabianq/pwsp
|
sudo dnf copr enable arabianq/pwsp
|
||||||
```
|
|
||||||
|
|
||||||
Update cache:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo dnf makecache
|
|
||||||
```
|
|
||||||
|
|
||||||
Install PWSP:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo dnf install pwsp
|
sudo dnf install pwsp
|
||||||
```
|
```
|
||||||
|
|
||||||
## **Arch Linux**
|
**Arch Linux (AUR):**
|
||||||
There is pwsp package in AUR.
|
|
||||||
You can install it using yay, paru or any other AUR helper.
|
|
||||||
```bash
|
```bash
|
||||||
paru pwsp-bin # or paru pwsp to build it locally
|
paru -S pwsp-bin # or 'pwsp' to build from source
|
||||||
```
|
```
|
||||||
|
|
||||||
## **Installing using cargo**
|
**Debian / Ubuntu:**
|
||||||
|
Download pre-built `.deb` packages or standalone binaries from the [Releases page](https://github.com/arabianq/pipewire-soundpad/releases).
|
||||||
|
|
||||||
|
### 🦀 Cargo / Source Build
|
||||||
```bash
|
```bash
|
||||||
cargo install pwsp
|
cargo install pwsp
|
||||||
```
|
|
||||||
|
|
||||||
## **Building from source**
|
# OR clone and build manually:
|
||||||
|
|
||||||
#### **Requirements**
|
|
||||||
|
|
||||||
* **Rust**: Install [Rust](https://www.rust-lang.org/tools/install) (using rustup is recommended).
|
|
||||||
* **PipeWire**: Ensure that [PipeWire](https://pipewire.org/) is installed and running on your system.
|
|
||||||
|
|
||||||
#### **Build Instructions**
|
|
||||||
|
|
||||||
Clone the repository:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/arabianq/pipewire-soundpad.git
|
git clone https://github.com/arabianq/pipewire-soundpad.git
|
||||||
cd pipewire-soundpad
|
cd pipewire-soundpad
|
||||||
```
|
|
||||||
|
|
||||||
Build the project:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo build --release
|
cargo build --release
|
||||||
```
|
```
|
||||||
|
*(Note: Requires Rust toolchain and PipeWire running on your system).*
|
||||||
|
|
||||||
Now you have three binary files inside ./target/release/:
|
---
|
||||||
|
|
||||||
- **pwsp-gui**
|
## 🎮 Usage
|
||||||
- **pwsp-cli**
|
|
||||||
- **pwsp-daemon**
|
|
||||||
|
|
||||||
# **🎮 Usage**
|
### 1. Start the Daemon
|
||||||
|
Before using the GUI or CLI, the daemon must be running in the background.
|
||||||
Before using pwsp-gui or pwsp-cli, you **must** first run the pwsp-daemon in the background.
|
|
||||||
|
|
||||||
### **Running the Daemon**
|
|
||||||
|
|
||||||
You can start the daemon from the terminal or enable the systemd service for automatic startup.
|
|
||||||
|
|
||||||
* **Manual Start:**
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/path/to/your/pwsp-daemon &
|
# Recommended: Start and enable via systemd (starts on login)
|
||||||
```
|
|
||||||
|
|
||||||
* **Using systemd (recommended):**
|
|
||||||
If you installed PWSP using prebuilt packages, the systemd service is added automatically.
|
|
||||||
1. **Start the service:**
|
|
||||||
```bash
|
|
||||||
systemctl --user start pwsp-daemon
|
|
||||||
```
|
|
||||||
2. **Enable autostart (starts on login):**
|
|
||||||
```bash
|
|
||||||
systemctl --user enable --now pwsp-daemon
|
systemctl --user enable --now pwsp-daemon
|
||||||
|
|
||||||
|
# Manual start (if not using systemd):
|
||||||
|
pwsp-daemon &
|
||||||
```
|
```
|
||||||
|
|
||||||
### **Using the GUI**
|
### 2. Using the GUI
|
||||||
|
1. **Add Sounds:** Click the **"+"** button to add a directory containing your audio files.
|
||||||
1. **Add Sounds**: Click the **"+"** button and select a folder containing your audio files. The application
|
2. **Select Mic:** Choose your physical microphone from the dropdown. PWSP will instantly create a virtual microphone combining your voice and the soundboard.
|
||||||
will automatically list all supported files.
|
3. **Play:** Click any sound to play it, adjust its volume, or assign a hotkey for quick access.
|
||||||
2. **Select Microphone**: In the main application window, select your microphone. PWSP will automatically
|
|
||||||
create a virtual microphone and feed it sound from two sources: **your microphone** and the **audio files**.
|
|
||||||
3. **Playback**: Click on a file in the list to load it, then use the **"Play"** and **"Pause"** buttons to control
|
|
||||||
playback. You can also play single file once using **"Play File"** button.
|
|
||||||
|
|
||||||
### **Using the CLI**
|
|
||||||
|
|
||||||
The pwsp-cli tool allows you to control the daemon from the command line.
|
|
||||||
|
|
||||||
* **General Help**: To see a list of all available commands, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pwsp-cli --help
|
|
||||||
```
|
|
||||||
|
|
||||||
* **Example Commands**:
|
|
||||||
* **Play a file**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pwsp-cli action play <file_path>
|
|
||||||
```
|
|
||||||
|
|
||||||
* **Get the current volume**:
|
|
||||||
|
|
||||||
|
### 3. Using the CLI
|
||||||
|
Control the daemon directly from your terminal:
|
||||||
```bash
|
```bash
|
||||||
|
pwsp-cli action play /path/to/sound.mp3
|
||||||
pwsp-cli get volume
|
pwsp-cli get volume
|
||||||
```
|
|
||||||
|
|
||||||
* **Set playback position to 20 seconds**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pwsp-cli set position 20
|
pwsp-cli set position 20
|
||||||
|
pwsp-cli --help # View all commands
|
||||||
```
|
```
|
||||||
|
|
||||||
### **Hotkeys & Controls**
|
---
|
||||||
|
|
||||||
#### **Keyboard Shortcuts**
|
## ⌨️ Shortcuts & Controls
|
||||||
|
|
||||||
| Key | Action |
|
| Action | Keyboard | Mouse |
|
||||||
| :----------------------- | :--------------------------------------------------- |
|
| :----------------------------------- | :--------------------- | :------------------- |
|
||||||
| **Space** | Pause / Resume audio |
|
| **Play Track** (Stops others) | `Enter` | `Left Click` |
|
||||||
| **Backspace** | Stop all audio tracks |
|
| **Add Track** (Plays simultaneously) | `Ctrl + Enter` | `Ctrl + Left Click` |
|
||||||
| **Enter** | Play selected file (stops all other tracks) |
|
| **Replace Last Track** | `Shift + Enter` | `Shift + Left Click` |
|
||||||
| **Ctrl + Enter** | Add selected file to playback (plays simultaneously) |
|
| **Pause / Resume** | `Space` | |
|
||||||
| **Shift + Enter** | Replace the last added track with the selected one |
|
| **Stop All Tracks** | `Backspace` | |
|
||||||
| **I** | Open / Close settings |
|
| **Open / Close Settings** | `I` | |
|
||||||
| **/** | Focus search field |
|
| **Search** | `/` | |
|
||||||
| **Ctrl + ↑ / ↓** | Navigate through files |
|
| **Navigate Files** | `Ctrl + ↑ / ↓` | |
|
||||||
| **Ctrl + Shift + ↑ / ↓** | Navigate through directories |
|
| **Navigate Directories** | `Ctrl + Shift + ↑ / ↓` | |
|
||||||
|
|
||||||
#### **Mouse Controls**
|
---
|
||||||
|
|
||||||
* **Left Click**: Play track (stops all other tracks).
|
## 🤝 Contributing
|
||||||
* **Ctrl + Left Click**: Add track (plays simultaneously with current tracks).
|
Contributions, issues, and feature requests are welcome! Feel free to check out the [issues page](https://github.com/arabianq/pipewire-soundpad/issues).
|
||||||
* **Shift + Left Click**: Replace the last added track with the selected one.
|
|
||||||
|
|
||||||
# **🤝 Contributing**
|
|
||||||
|
|
||||||
Contributions are welcome\! If you have ideas for improvements or find a bug, feel free to create
|
|
||||||
an [issue](https://github.com/arabianq/pipewire-soundpad/issues) or submit
|
|
||||||
a [pull request](https://github.com/arabianq/pipewire-soundpad/pulls).
|
|
||||||
|
|
||||||
# **📜 License**
|
|
||||||
|
|
||||||
This project is licensed under
|
|
||||||
the [MIT License](https://github.com/arabianq/pipewire-soundpad/blob/main/LICENSE).
|
|
||||||
|
|
||||||
# **🤖 AI Wiki**
|
|
||||||
[](https://deepwiki.com/arabianq/pipewire-soundpad)
|
[](https://deepwiki.com/arabianq/pipewire-soundpad)
|
||||||
|
|
||||||
|
## 📜 License
|
||||||
|
This project is licensed under the [MIT License](LICENSE).
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 94 KiB |
@@ -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.0
|
pkgver = 1.8.0
|
||||||
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.0.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.7.0/pwsp-v1.7.0-linux-x64.zip
|
source = pwsp-bin-1.8.0.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.8.0/pwsp-v1.8.0-linux-x64.zip
|
||||||
source = pipewire-soundpad-1.7.0.tar.gz :: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.7.0.tar.gz
|
source = pipewire-soundpad-1.8.0.tar.gz :: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.8.0.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.0
|
pkgver=1.8.0
|
||||||
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.0
|
pkgver = 1.8.0
|
||||||
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.0.tar.gz
|
source = https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.8.0.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.0
|
pkgver=1.8.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=('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.8.0" date="2026-05-13" />
|
||||||
|
</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.0
|
Version: 1.8.0
|
||||||
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
|
||||||
|
|||||||
+19
-1
@@ -70,8 +70,10 @@ enum Actions {
|
|||||||
},
|
},
|
||||||
/// Play a sound by hotkey slot name
|
/// Play a sound by hotkey slot name
|
||||||
PlayHotkey { slot: String },
|
PlayHotkey { slot: String },
|
||||||
/// Remove a hotkey slot
|
/// Remove the hotkey slot
|
||||||
ClearHotkey { slot: String },
|
ClearHotkey { slot: String },
|
||||||
|
/// Clear the key chord for a hotkey slot
|
||||||
|
ClearHotkeyKey { slot: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
@@ -135,6 +137,12 @@ enum SetCommands {
|
|||||||
Hotkey { slot: String, file_path: PathBuf },
|
Hotkey { slot: String, file_path: PathBuf },
|
||||||
/// Set the key chord for a hotkey slot (e.g. "Ctrl+Alt+1")
|
/// Set the key chord for a hotkey slot (e.g. "Ctrl+Alt+1")
|
||||||
HotkeyKey { slot: String, key_chord: String },
|
HotkeyKey { slot: String, key_chord: String },
|
||||||
|
/// Atomically set the action and key chord for a hotkey slot
|
||||||
|
HotkeyActionAndKey {
|
||||||
|
slot: String,
|
||||||
|
action: String,
|
||||||
|
key_chord: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -158,6 +166,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
Actions::ToggleLoop { id } => Request::toggle_loop(id),
|
Actions::ToggleLoop { id } => Request::toggle_loop(id),
|
||||||
Actions::PlayHotkey { slot } => Request::play_hotkey(&slot),
|
Actions::PlayHotkey { slot } => Request::play_hotkey(&slot),
|
||||||
Actions::ClearHotkey { slot } => Request::clear_hotkey(&slot),
|
Actions::ClearHotkey { slot } => Request::clear_hotkey(&slot),
|
||||||
|
Actions::ClearHotkeyKey { slot } => Request::clear_hotkey_key(&slot),
|
||||||
},
|
},
|
||||||
Commands::Get { parameter } => match parameter {
|
Commands::Get { parameter } => match parameter {
|
||||||
GetCommands::IsPaused => Request::get_is_paused(),
|
GetCommands::IsPaused => Request::get_is_paused(),
|
||||||
@@ -183,6 +192,15 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
SetCommands::HotkeyKey { slot, key_chord } => {
|
SetCommands::HotkeyKey { slot, key_chord } => {
|
||||||
Request::set_hotkey_key(&slot, &key_chord)
|
Request::set_hotkey_key(&slot, &key_chord)
|
||||||
}
|
}
|
||||||
|
SetCommands::HotkeyActionAndKey {
|
||||||
|
slot,
|
||||||
|
action,
|
||||||
|
key_chord,
|
||||||
|
} => Request::set_hotkey_action_and_key(
|
||||||
|
&slot,
|
||||||
|
&serde_json::from_str::<Request>(&action)?,
|
||||||
|
&key_chord,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+20
-29
@@ -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,21 +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) => println!("{e}\t{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;
|
||||||
});
|
});
|
||||||
@@ -57,11 +42,11 @@ 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)?;
|
||||||
fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600))?;
|
fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600))?;
|
||||||
@@ -105,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
|
||||||
@@ -174,19 +159,25 @@ async fn commands_loop(listener: UnixListener) -> Result<(), Box<dyn Error>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn player_loop() {
|
async fn player_loop() {
|
||||||
|
let mut device_check_counter: u32 = 0;
|
||||||
loop {
|
loop {
|
||||||
match get_audio_player().await {
|
let is_idle = match get_audio_player().await {
|
||||||
Ok(player_mutex) => {
|
Ok(player_mutex) => {
|
||||||
let mut audio_player = player_mutex.lock().await;
|
let mut audio_player = player_mutex.lock().await;
|
||||||
audio_player.update().await;
|
let check_devices = device_check_counter == 0;
|
||||||
}
|
audio_player.update(check_devices).await;
|
||||||
Err(_err) => {
|
audio_player.tracks.is_empty()
|
||||||
// To avoid spamming logs every 100ms when audio player fails to init
|
|
||||||
// we can just sleep, or you might prefer to print the error.
|
|
||||||
// Assuming it failed to initialize, no player update is possible.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
Err(_err) => true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if is_idle {
|
||||||
|
device_check_counter = 0;
|
||||||
|
sleep(Duration::from_secs(2)).await;
|
||||||
|
} else {
|
||||||
|
// Check devices every ~5 seconds (50 * 100ms) while playing
|
||||||
|
device_check_counter = (device_check_counter + 1) % 50;
|
||||||
sleep(Duration::from_millis(100)).await;
|
sleep(Duration::from_millis(100)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
+233
-198
@@ -1,17 +1,15 @@
|
|||||||
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, time::Instant};
|
||||||
path::{Path, PathBuf},
|
|
||||||
time::Instant,
|
|
||||||
};
|
|
||||||
|
|
||||||
enum TrackAction {
|
enum TrackAction {
|
||||||
Pause(u32),
|
Pause(u32),
|
||||||
@@ -133,26 +131,38 @@ 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| {
|
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);
|
let back_button = Button::new(ICON_ARROW_BACK).frame(false);
|
||||||
if ui.add(back_button).clicked() {
|
if ui.add(back_button).clicked() {
|
||||||
self.app_state.show_hotkeys = false;
|
self.app_state.show_hotkeys = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.add_space(ui.available_width() / 2.0 - 40.0);
|
ui.vertical_centered(|ui| {
|
||||||
ui.label(RichText::new("Hotkeys").color(Color32::WHITE).monospace());
|
ui.label(RichText::new("Hotkeys").color(Color32::WHITE).monospace());
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
ui.separator();
|
fn draw_hotkeys_search(&mut self, ui: &mut Ui) {
|
||||||
|
|
||||||
// Search and Add Command
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.menu_button(format!("{} Add Command", ICON_ADD.codepoint), |ui| {
|
ui.menu_button(format!("{} Add Command", ICON_ADD.codepoint), |ui| {
|
||||||
let mut selected_cmd = None;
|
let mut selected_cmd = None;
|
||||||
@@ -185,63 +195,21 @@ impl SoundpadGui {
|
|||||||
|
|
||||||
ui.add_space(10.0);
|
ui.add_space(10.0);
|
||||||
|
|
||||||
ui.add_sized(
|
ui.add(
|
||||||
[ui.available_width(), 22.0],
|
|
||||||
TextEdit::singleline(&mut self.app_state.hotkey_search_query)
|
TextEdit::singleline(&mut self.app_state.hotkey_search_query)
|
||||||
.hint_text("Search hotkeys..."),
|
.hint_text("Search hotkeys...")
|
||||||
|
.desired_width(f32::INFINITY),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
ui.separator();
|
fn draw_hotkeys_table(&mut self, ui: &mut Ui) -> Option<HotkeyAction> {
|
||||||
ui.add_space(5.0);
|
|
||||||
|
|
||||||
let conflicts = self.app_state.hotkey_config.find_conflicts();
|
let conflicts = self.app_state.hotkey_config.find_conflicts();
|
||||||
let conflict_slots: std::collections::HashSet<String> = conflicts
|
let conflict_slots: std::collections::HashSet<&str> =
|
||||||
.iter()
|
conflicts.into_iter().flat_map(|(a, b)| [a, b]).collect();
|
||||||
.flat_map(|(a, b)| vec![a.clone(), b.clone()])
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let search = self.app_state.hotkey_search_query.to_lowercase();
|
let search = self.app_state.hotkey_search_query.to_lowercase();
|
||||||
|
|
||||||
// Slots table
|
|
||||||
let mut action: Option<HotkeyAction> = None;
|
let mut action: Option<HotkeyAction> = None;
|
||||||
let area_size = ui.available_size();
|
|
||||||
|
|
||||||
ScrollArea::vertical().show(ui, |ui| {
|
|
||||||
ui.set_min_width(area_size.x);
|
|
||||||
Grid::new("hotkeys_grid")
|
|
||||||
.striped(true)
|
|
||||||
.num_columns(4)
|
|
||||||
.max_col_width(area_size.x)
|
|
||||||
.min_col_width(area_size.x / 4.0)
|
|
||||||
.spacing([40.0, 10.0])
|
|
||||||
.show(ui, |ui| {
|
|
||||||
// Table header
|
|
||||||
ui.label(
|
|
||||||
RichText::new("Slot")
|
|
||||||
.strong()
|
|
||||||
.monospace()
|
|
||||||
.color(Color32::LIGHT_GRAY),
|
|
||||||
);
|
|
||||||
ui.label(
|
|
||||||
RichText::new("Sound")
|
|
||||||
.strong()
|
|
||||||
.monospace()
|
|
||||||
.color(Color32::LIGHT_GRAY),
|
|
||||||
);
|
|
||||||
ui.label(
|
|
||||||
RichText::new("Key Chord")
|
|
||||||
.strong()
|
|
||||||
.monospace()
|
|
||||||
.color(Color32::LIGHT_GRAY),
|
|
||||||
);
|
|
||||||
ui.label(
|
|
||||||
RichText::new("Actions")
|
|
||||||
.strong()
|
|
||||||
.monospace()
|
|
||||||
.color(Color32::LIGHT_GRAY),
|
|
||||||
);
|
|
||||||
ui.end_row();
|
|
||||||
|
|
||||||
let slots: Vec<_> = self
|
let slots: Vec<_> = self
|
||||||
.app_state
|
.app_state
|
||||||
@@ -260,26 +228,86 @@ impl SoundpadGui {
|
|||||||
.to_lowercase()
|
.to_lowercase()
|
||||||
.contains(&search)
|
.contains(&search)
|
||||||
})
|
})
|
||||||
.cloned()
|
|
||||||
.collect();
|
.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 {
|
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()).truncate(),
|
||||||
let slot_text = RichText::new(&slot.slot).monospace();
|
);
|
||||||
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") {
|
||||||
@@ -300,19 +328,26 @@ impl SoundpadGui {
|
|||||||
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(
|
||||||
|
Label::new(RichText::new(chord_text).monospace().color(
|
||||||
if slot.key_chord.is_some() {
|
if slot.key_chord.is_some() {
|
||||||
Color32::from_rgb(100, 200, 100)
|
Color32::from_rgb(100, 200, 100)
|
||||||
} else {
|
} else {
|
||||||
Color32::GRAY
|
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 +355,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 +362,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 +370,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,20 +378,15 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_hotkey_action(&mut self, action: HotkeyAction) {
|
||||||
match action {
|
match action {
|
||||||
HotkeyAction::Remove(slot) => {
|
HotkeyAction::Remove(slot) => {
|
||||||
make_request_async(Request::clear_hotkey(&slot));
|
make_request_async(Request::clear_hotkey(&slot));
|
||||||
@@ -381,8 +405,6 @@ impl SoundpadGui {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_header(&mut self, ui: &mut Ui) {
|
fn draw_header(&mut self, ui: &mut Ui) {
|
||||||
ui.vertical_centered_justified(|ui| {
|
ui.vertical_centered_justified(|ui| {
|
||||||
@@ -391,10 +413,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 +430,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 +448,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 +571,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,11 +647,11 @@ 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 =
|
||||||
Button::new(dir_button_text.atom_max_width(area_size.x)).frame(false);
|
Button::new(dir_button_text.atom_max_width(area_size.x)).frame(false);
|
||||||
@@ -645,11 +683,10 @@ 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 +765,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,11 +829,10 @@ 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,11 +856,12 @@ 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 {
|
if let Some(chord) = &slot.key_chord {
|
||||||
return Some(format!("[{}]", chord));
|
return Some(format!("[{}]", chord));
|
||||||
} else {
|
} else {
|
||||||
@@ -832,8 +869,6 @@ impl SoundpadGui {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+39
-109
@@ -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))
|
||||||
}
|
}
|
||||||
@@ -221,16 +146,21 @@ impl SoundpadGui {
|
|||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.to_string();
|
.to_string();
|
||||||
let action = Request::play(&file_path.to_string_lossy(), false);
|
let action = Request::play(&file_path.to_string_lossy(), false);
|
||||||
make_request_async(Request::set_hotkey_action(&slot_name, &action));
|
|
||||||
make_request_async(Request::set_hotkey_key(&slot_name, &chord));
|
make_request_async(Request::set_hotkey_action_and_key(
|
||||||
|
&slot_name, &action, &chord,
|
||||||
|
));
|
||||||
|
|
||||||
self.app_state
|
self.app_state
|
||||||
.hotkey_config
|
.hotkey_config
|
||||||
.set_slot(slot_name.clone(), action);
|
.set_slot(slot_name.clone(), action);
|
||||||
self.app_state
|
self.app_state
|
||||||
.hotkey_config
|
.hotkey_config
|
||||||
.set_key_chord(&slot_name, Some(chord));
|
.set_key_chord(&slot_name, Some(chord.clone()));
|
||||||
}
|
}
|
||||||
self.app_state.hotkey_capture_active = false;
|
self.app_state.hotkey_capture_active = false;
|
||||||
|
self.app_state.assigning_hotkey_slot = None;
|
||||||
|
self.app_state.assigning_hotkey_for_file = None;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -268,8 +198,9 @@ 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 {
|
if modifiers.ctrl {
|
||||||
self.play_file(&path, true);
|
self.play_file(&path, true);
|
||||||
} else if modifiers.shift
|
} else if modifiers.shift
|
||||||
@@ -281,14 +212,13 @@ impl SoundpadGui {
|
|||||||
self.play_file(&path, false);
|
self.play_file(&path, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Iterate through dirs and files with Ctrl + Up/Down
|
// Iterate through dirs and files with Ctrl + Up/Down
|
||||||
let arrow_up_pressed = self.key_pressed(ctx, Key::ArrowUp);
|
let arrow_up_pressed = self.key_pressed(ctx, Key::ArrowUp);
|
||||||
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
|
||||||
|
|||||||
+60
-5
@@ -3,7 +3,7 @@ mod input;
|
|||||||
mod update;
|
mod update;
|
||||||
|
|
||||||
use eframe::{HardwareAcceleration, NativeOptions, icon_data::from_png_bytes, run_native};
|
use eframe::{HardwareAcceleration, NativeOptions, icon_data::from_png_bytes, run_native};
|
||||||
use egui::{Context, Vec2, ViewportBuilder};
|
use egui::{Context, FontData, FontDefinitions, FontFamily, FontTweak, Vec2, ViewportBuilder};
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use pwsp::{
|
use pwsp::{
|
||||||
types::{
|
types::{
|
||||||
@@ -21,12 +21,14 @@ use pwsp::{
|
|||||||
use rfd::FileDialog;
|
use rfd::FileDialog;
|
||||||
use std::{
|
use std::{
|
||||||
error::Error,
|
error::Error,
|
||||||
path::PathBuf,
|
fs,
|
||||||
|
path::{Path, PathBuf},
|
||||||
sync::{Arc, Mutex},
|
sync::{Arc, Mutex},
|
||||||
};
|
};
|
||||||
|
use system_fonts::{FontStyle, FoundFontSource, find_for_locale};
|
||||||
|
|
||||||
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 +122,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));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +198,54 @@ impl SoundpadGui {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn add_font(
|
||||||
|
font_name: &str,
|
||||||
|
font_bytes: &[u8],
|
||||||
|
fonts: &mut FontDefinitions,
|
||||||
|
) -> Result<(), Box<dyn Error>> {
|
||||||
|
let font_data = FontData::from_owned(font_bytes.to_vec()).tweak(FontTweak {
|
||||||
|
scale: 1.0,
|
||||||
|
hinting_override: Some(true),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
fonts
|
||||||
|
.font_data
|
||||||
|
.insert(font_name.to_owned(), font_data.into());
|
||||||
|
|
||||||
|
fonts
|
||||||
|
.families
|
||||||
|
.entry(FontFamily::Proportional)
|
||||||
|
.or_default()
|
||||||
|
.insert(0, font_name.to_owned());
|
||||||
|
fonts
|
||||||
|
.families
|
||||||
|
.entry(FontFamily::Monospace)
|
||||||
|
.or_default()
|
||||||
|
.insert(0, font_name.to_owned());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_system_fonts(fonts: &mut FontDefinitions) -> Result<(), Box<dyn Error>> {
|
||||||
|
let (_, en_sans) = find_for_locale("en", FontStyle::Sans);
|
||||||
|
let (_, en_serif) = find_for_locale("en", FontStyle::Serif);
|
||||||
|
let (_, ja_sans) = find_for_locale("ja", FontStyle::Sans);
|
||||||
|
|
||||||
|
let system_fonts = [en_sans, en_serif, ja_sans].concat();
|
||||||
|
|
||||||
|
for font in system_fonts.iter().rev() {
|
||||||
|
let font_bytes = match &font.source {
|
||||||
|
FoundFontSource::Path(path) => fs::read(path)?,
|
||||||
|
FoundFontSource::Bytes(bytes) => bytes.to_vec(),
|
||||||
|
};
|
||||||
|
|
||||||
|
add_font(&font.key, &font_bytes, fonts)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn run() -> Result<(), Box<dyn Error>> {
|
pub async fn run() -> Result<(), Box<dyn Error>> {
|
||||||
const ICON: &[u8] = include_bytes!("../../assets/icon.png");
|
const ICON: &[u8] = include_bytes!("../../assets/icon.png");
|
||||||
|
|
||||||
@@ -218,6 +268,11 @@ pub async fn run() -> Result<(), Box<dyn Error>> {
|
|||||||
options,
|
options,
|
||||||
Box::new(|cc| {
|
Box::new(|cc| {
|
||||||
egui_material_icons::initialize(&cc.egui_ctx);
|
egui_material_icons::initialize(&cc.egui_ctx);
|
||||||
|
|
||||||
|
let mut fonts = FontDefinitions::default();
|
||||||
|
load_system_fonts(&mut fonts).ok();
|
||||||
|
cc.egui_ctx.set_fonts(fonts);
|
||||||
|
|
||||||
Ok(Box::new(SoundpadGui::new(&cc.egui_ctx)))
|
Ok(Box::new(SoundpadGui::new(&cc.egui_ctx)))
|
||||||
}),
|
}),
|
||||||
) {
|
) {
|
||||||
|
|||||||
+80
-22
@@ -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};
|
||||||
@@ -53,11 +53,12 @@ pub struct PlayingSound {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct AudioPlayer {
|
pub struct AudioPlayer {
|
||||||
pub stream_handle: MixerDeviceSink,
|
stream_handle: Option<MixerDeviceSink>,
|
||||||
pub tracks: HashMap<u32, PlayingSound>,
|
pub tracks: HashMap<u32, PlayingSound>,
|
||||||
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
|
||||||
@@ -68,14 +69,13 @@ impl AudioPlayer {
|
|||||||
let daemon_config = get_daemon_config();
|
let daemon_config = get_daemon_config();
|
||||||
let default_volume = daemon_config.default_volume.unwrap_or(1.0);
|
let default_volume = daemon_config.default_volume.unwrap_or(1.0);
|
||||||
|
|
||||||
let stream_handle = DeviceSinkBuilder::open_default_sink()?;
|
|
||||||
|
|
||||||
let mut audio_player = AudioPlayer {
|
let mut audio_player = AudioPlayer {
|
||||||
stream_handle,
|
stream_handle: None,
|
||||||
tracks: HashMap::new(),
|
tracks: HashMap::new(),
|
||||||
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,
|
||||||
@@ -88,16 +88,56 @@ impl AudioPlayer {
|
|||||||
Ok(audio_player)
|
Ok(audio_player)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ensure_stream(&mut self) -> Result<&MixerDeviceSink, Box<dyn Error>> {
|
||||||
|
if self.stream_handle.is_none() {
|
||||||
|
let mut sink = DeviceSinkBuilder::open_default_sink()?;
|
||||||
|
sink.log_on_drop(false);
|
||||||
|
self.stream_handle = Some(sink);
|
||||||
|
}
|
||||||
|
Ok(self.stream_handle.as_ref().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drop_stream(&mut self) {
|
||||||
|
if self.stream_handle.is_some() {
|
||||||
|
self.stream_handle = None;
|
||||||
|
self.abort_player_link_thread();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn abort_link_thread(&mut self) {
|
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>> {
|
||||||
@@ -179,6 +219,9 @@ impl AudioPlayer {
|
|||||||
} else {
|
} else {
|
||||||
self.tracks.clear();
|
self.tracks.clear();
|
||||||
}
|
}
|
||||||
|
if self.tracks.is_empty() {
|
||||||
|
self.drop_stream();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_paused(&self) -> bool {
|
pub fn is_paused(&self) -> bool {
|
||||||
@@ -211,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,12 +342,16 @@ impl AudioPlayer {
|
|||||||
self.tracks.clear();
|
self.tracks.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
let duration = source.total_duration().map(|d| d.as_secs_f32());
|
let duration = source.total_duration().map(|d| d.as_secs_f32());
|
||||||
|
|
||||||
let sink = Player::connect_new(self.stream_handle.mixer());
|
let mixer = self.stream_handle.as_ref().unwrap().mixer();
|
||||||
|
let sink = Player::connect_new(mixer);
|
||||||
sink.set_volume(self.volume); // Default volume is 1.0 * master
|
sink.set_volume(self.volume); // Default volume is 1.0 * master
|
||||||
sink.append(source);
|
sink.append(source);
|
||||||
sink.play();
|
sink.play();
|
||||||
@@ -358,11 +405,12 @@ impl AudioPlayer {
|
|||||||
tracks
|
tracks
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update(&mut self) {
|
pub async fn update(&mut self, check_devices: bool) {
|
||||||
|
if check_devices {
|
||||||
if let Some(input_device_name) = &self.input_device_name {
|
if let Some(input_device_name) = &self.input_device_name {
|
||||||
// Unlink devices if selected input device was removed
|
// Unlink devices if selected input device was removed
|
||||||
if self.input_link_sender.is_some() && get_device(input_device_name).await.is_err() {
|
if self.input_link_sender.is_some() && get_device(input_device_name).await.is_err()
|
||||||
// Selected input device was removed
|
{
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"Selected input device {} was removed, unlinking devices",
|
"Selected input device {} was removed, unlinking devices",
|
||||||
input_device_name
|
input_device_name
|
||||||
@@ -375,6 +423,11 @@ impl AudioPlayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.stream_handle.is_some() && self.player_link_sender.is_none() {
|
||||||
|
self.link_player().await.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle looped sounds
|
// Handle looped sounds
|
||||||
let mut restarts = vec![];
|
let mut restarts = vec![];
|
||||||
|
|
||||||
@@ -390,11 +443,11 @@ 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
|
||||||
});
|
});
|
||||||
restart_futures.push(handle);
|
restart_futures.push(handle);
|
||||||
@@ -402,16 +455,21 @@ 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
|
||||||
|
&& let Some(sound) = self.tracks.get_mut(&id)
|
||||||
|
{
|
||||||
sound.sink.append(source);
|
sound.sink.append(source);
|
||||||
sound.sink.play();
|
sound.sink.play();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
self.tracks
|
self.tracks
|
||||||
.retain(|_, sound| !sound.sink.empty() || sound.looped);
|
.retain(|_, sound| !sound.sink.empty() || sound.looped);
|
||||||
|
|
||||||
|
if self.tracks.is_empty() {
|
||||||
|
self.drop_stream();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_current_input_device(&mut self, name: &str) -> Result<(), Box<dyn Error>> {
|
pub async fn set_current_input_device(&mut self, name: &str) -> Result<(), Box<dyn Error>> {
|
||||||
|
|||||||
+64
-18
@@ -99,22 +99,28 @@ pub struct SetHotkeyCommand {
|
|||||||
pub file_path: Option<PathBuf>,
|
pub file_path: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct SetHotkeyActionCommand {
|
||||||
|
pub slot: Option<String>,
|
||||||
|
pub action: Option<Request>,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct SetHotkeyKeyCommand {
|
pub struct SetHotkeyKeyCommand {
|
||||||
pub slot: Option<String>,
|
pub slot: Option<String>,
|
||||||
pub key_chord: Option<String>,
|
pub key_chord: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct ClearHotkeyCommand {
|
pub struct SetHotkeyActionAndKeyCommand {
|
||||||
pub slot: Option<String>,
|
pub slot: Option<String>,
|
||||||
|
pub action: Option<Request>,
|
||||||
|
pub key_chord: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PlayHotkeyCommand {
|
pub struct PlayHotkeyCommand {
|
||||||
pub slot: Option<String>,
|
pub slot: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct SetHotkeyActionCommand {
|
pub struct ClearHotkeyCommand {
|
||||||
pub slot: Option<String>,
|
pub slot: Option<String>,
|
||||||
pub action: Option<Request>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct ClearHotkeyKeyCommand {
|
pub struct ClearHotkeyKeyCommand {
|
||||||
@@ -553,6 +559,30 @@ impl Executable for SetHotkeyCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Executable for SetHotkeyActionCommand {
|
||||||
|
async fn execute(&self) -> Response {
|
||||||
|
let Some(slot) = &self.slot else {
|
||||||
|
return Response::new(false, "Missing slot name");
|
||||||
|
};
|
||||||
|
let Some(action) = &self.action else {
|
||||||
|
return Response::new(false, "Missing or invalid action");
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut config = match HotkeyConfig::load() {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(err) => return Response::new(false, format!("Failed to load hotkeys: {}", err)),
|
||||||
|
};
|
||||||
|
|
||||||
|
config.set_slot(slot.clone(), action.clone());
|
||||||
|
|
||||||
|
match config.save() {
|
||||||
|
Ok(_) => Response::new(true, format!("Hotkey slot '{}' set", slot)),
|
||||||
|
Err(err) => Response::new(false, format!("Failed to save hotkeys: {}", err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Executable for SetHotkeyKeyCommand {
|
impl Executable for SetHotkeyKeyCommand {
|
||||||
async fn execute(&self) -> Response {
|
async fn execute(&self) -> Response {
|
||||||
@@ -583,24 +613,41 @@ impl Executable for SetHotkeyKeyCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Executable for ClearHotkeyCommand {
|
impl Executable for SetHotkeyActionAndKeyCommand {
|
||||||
async fn execute(&self) -> Response {
|
async fn execute(&self) -> Response {
|
||||||
let Some(slot) = &self.slot else {
|
let Some(slot) = &self.slot else {
|
||||||
return Response::new(false, "Missing slot name");
|
return Response::new(false, "Missing slot name");
|
||||||
};
|
};
|
||||||
|
let Some(action) = &self.action else {
|
||||||
|
return Response::new(false, "Missing or invalid action");
|
||||||
|
};
|
||||||
|
let Some(key_chord) = &self.key_chord else {
|
||||||
|
return Response::new(false, "Missing key chord");
|
||||||
|
};
|
||||||
|
|
||||||
let mut config = match HotkeyConfig::load() {
|
let mut config = match HotkeyConfig::load() {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(err) => return Response::new(false, format!("Failed to load hotkeys: {}", err)),
|
Err(err) => return Response::new(false, format!("Failed to load hotkeys: {}", err)),
|
||||||
};
|
};
|
||||||
|
|
||||||
if config.remove_slot(slot) {
|
// Set the action and then the key chord
|
||||||
match config.save() {
|
config.set_slot(slot.clone(), action.clone());
|
||||||
Ok(_) => Response::new(true, format!("Hotkey slot '{}' cleared", slot)),
|
if !config.set_key_chord(slot, Some(key_chord.clone())) {
|
||||||
Err(err) => Response::new(false, format!("Failed to save hotkeys: {}", err)),
|
return Response::new(
|
||||||
|
false,
|
||||||
|
format!("Slot '{}' not found after setting action", slot),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
Response::new(false, format!("Slot '{}' not found", slot))
|
match config.save() {
|
||||||
|
Ok(_) => Response::new(
|
||||||
|
true,
|
||||||
|
format!(
|
||||||
|
"Hotkey slot '{}' set with action and key chord '{}'",
|
||||||
|
slot, key_chord
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Err(err) => Response::new(false, format!("Failed to save hotkeys: {}", err)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -626,32 +673,31 @@ 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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Executable for SetHotkeyActionCommand {
|
impl Executable for ClearHotkeyCommand {
|
||||||
async fn execute(&self) -> Response {
|
async fn execute(&self) -> Response {
|
||||||
let Some(slot) = &self.slot else {
|
let Some(slot) = &self.slot else {
|
||||||
return Response::new(false, "Missing slot name");
|
return Response::new(false, "Missing slot name");
|
||||||
};
|
};
|
||||||
let Some(action) = &self.action else {
|
|
||||||
return Response::new(false, "Missing or invalid action");
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut config = match HotkeyConfig::load() {
|
let mut config = match HotkeyConfig::load() {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(err) => return Response::new(false, format!("Failed to load hotkeys: {}", err)),
|
Err(err) => return Response::new(false, format!("Failed to load hotkeys: {}", err)),
|
||||||
};
|
};
|
||||||
|
|
||||||
config.set_slot(slot.clone(), action.clone());
|
if config.remove_slot(slot) {
|
||||||
|
|
||||||
match config.save() {
|
match config.save() {
|
||||||
Ok(_) => Response::new(true, format!("Hotkey slot '{}' set", slot)),
|
Ok(_) => Response::new(true, format!("Hotkey slot '{}' cleared", slot)),
|
||||||
Err(err) => Response::new(false, format!("Failed to save hotkeys: {}", err)),
|
Err(err) => Response::new(false, format!("Failed to save hotkeys: {}", err)),
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
Response::new(false, format!("Slot '{}' not found", slot))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-10
@@ -13,11 +13,11 @@ 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)?;
|
||||||
fs::write(config_path, config_json.as_bytes())?;
|
fs::write(config_path, config_json.as_bytes())?;
|
||||||
@@ -68,11 +68,11 @@ 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
|
||||||
if !self.save_scale_factor {
|
if !self.save_scale_factor {
|
||||||
@@ -98,7 +98,6 @@ impl GuiConfig {
|
|||||||
pub struct HotkeySlot {
|
pub struct HotkeySlot {
|
||||||
pub slot: String,
|
pub slot: String,
|
||||||
pub action: Request,
|
pub action: Request,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub key_chord: Option<String>,
|
pub key_chord: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +120,7 @@ impl HotkeyConfig {
|
|||||||
let bytes = fs::read(&path)?;
|
let bytes = fs::read(&path)?;
|
||||||
match serde_json::from_slice::<HotkeyConfig>(&bytes) {
|
match serde_json::from_slice::<HotkeyConfig>(&bytes) {
|
||||||
Ok(config) => Ok(config),
|
Ok(config) => Ok(config),
|
||||||
Err(_) => Ok(HotkeyConfig::default()),
|
Err(e) => Err(e.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,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();
|
||||||
|
|
||||||
@@ -187,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,
|
||||||
@@ -208,6 +210,18 @@ impl Request {
|
|||||||
pub fn clear_hotkey_key(slot: &str) -> Self {
|
pub fn clear_hotkey_key(slot: &str) -> Self {
|
||||||
Request::new("clear_hotkey_key", vec![("slot", slot)])
|
Request::new("clear_hotkey_key", vec![("slot", slot)])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_hotkey_action_and_key(slot: &str, action: &Request, key_chord: &str) -> Self {
|
||||||
|
let action_json = serde_json::to_string(action).unwrap_or_default();
|
||||||
|
Request::new(
|
||||||
|
"set_hotkey_action_and_key",
|
||||||
|
vec![
|
||||||
|
("slot", slot),
|
||||||
|
("action", &action_json),
|
||||||
|
("key_chord", key_chord),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
|
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -106,6 +106,104 @@ pub fn parse_command(request: &Request) -> Option<Box<dyn Executable + Send>> {
|
|||||||
let slot = request.args.get("slot").cloned();
|
let slot = request.args.get("slot").cloned();
|
||||||
Some(Box::new(ClearHotkeyKeyCommand { slot }))
|
Some(Box::new(ClearHotkeyKeyCommand { slot }))
|
||||||
}
|
}
|
||||||
|
"set_hotkey_action_and_key" => {
|
||||||
|
let slot = request.args.get("slot").cloned();
|
||||||
|
let action = request
|
||||||
|
.args
|
||||||
|
.get("action")
|
||||||
|
.and_then(|s| serde_json::from_str::<Request>(s).ok());
|
||||||
|
let key_chord = request.args.get("key_chord").cloned();
|
||||||
|
Some(Box::new(SetHotkeyActionAndKeyCommand {
|
||||||
|
slot,
|
||||||
|
action,
|
||||||
|
key_chord,
|
||||||
|
}))
|
||||||
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::types::socket::Request;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_set_volume_valid() {
|
||||||
|
let mut args = HashMap::new();
|
||||||
|
args.insert("volume".to_string(), "0.5".to_string());
|
||||||
|
args.insert("id".to_string(), "1".to_string());
|
||||||
|
let request = Request {
|
||||||
|
name: "set_volume".to_string(),
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cmd = parse_command(&request);
|
||||||
|
assert!(cmd.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_set_volume_missing_volume() {
|
||||||
|
let mut args = HashMap::new();
|
||||||
|
args.insert("id".to_string(), "1".to_string());
|
||||||
|
let request = Request {
|
||||||
|
name: "set_volume".to_string(),
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cmd = parse_command(&request);
|
||||||
|
assert!(cmd.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_set_volume_invalid_volume() {
|
||||||
|
let mut args = HashMap::new();
|
||||||
|
args.insert("volume".to_string(), "not-a-float".to_string());
|
||||||
|
let request = Request {
|
||||||
|
name: "set_volume".to_string(),
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cmd = parse_command(&request);
|
||||||
|
assert!(cmd.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_set_volume_missing_id() {
|
||||||
|
let mut args = HashMap::new();
|
||||||
|
args.insert("volume".to_string(), "0.5".to_string());
|
||||||
|
let request = Request {
|
||||||
|
name: "set_volume".to_string(),
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cmd = parse_command(&request);
|
||||||
|
assert!(cmd.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_set_volume_invalid_id() {
|
||||||
|
let mut args = HashMap::new();
|
||||||
|
args.insert("id".to_string(), "not-an-int".to_string());
|
||||||
|
args.insert("volume".to_string(), "0.5".to_string());
|
||||||
|
let request = Request {
|
||||||
|
name: "set_volume".to_string(),
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cmd = parse_command(&request);
|
||||||
|
assert!(cmd.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_set_volume_empty_args() {
|
||||||
|
let request = Request {
|
||||||
|
name: "set_volume".to_string(),
|
||||||
|
args: HashMap::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let cmd = parse_command(&request);
|
||||||
|
assert!(cmd.is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+11
-43
@@ -1,11 +1,9 @@
|
|||||||
use crate::{
|
use crate::types::{
|
||||||
types::{
|
|
||||||
audio_player::AudioPlayer,
|
audio_player::AudioPlayer,
|
||||||
config::DaemonConfig,
|
config::DaemonConfig,
|
||||||
socket::{Request, Response},
|
socket::{MAX_MESSAGE_SIZE, 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());
|
||||||
|
|||||||
+3
-3
@@ -112,14 +112,14 @@ 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
|
let mut guard = audio_player_state_shared
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(|e| e.into_inner());
|
.unwrap_or_else(|e| e.into_inner());
|
||||||
guard.hotkey_config = Some(config);
|
guard.hotkey_config = Some(config);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
last_hotkey_poll = Instant::now();
|
last_hotkey_poll = Instant::now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+159
-44
@@ -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,12 +63,13 @@ fn parse_global_object(
|
|||||||
(None, None)
|
(None, None)
|
||||||
};
|
};
|
||||||
// Check if the object is a port
|
// Check if the object is a port
|
||||||
} else if props.get("port.direction").is_some() {
|
} else if props.get("port.direction").is_some()
|
||||||
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 {
|
let port = Port {
|
||||||
node_id,
|
node_id,
|
||||||
port_id,
|
port_id,
|
||||||
@@ -71,17 +79,21 @@ fn parse_global_object(
|
|||||||
return (None, Some(port));
|
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>>,
|
||||||
) {
|
) {
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
use rodio::{DeviceSinkBuilder, MixerDeviceSink};
|
|
||||||
use std::fs;
|
|
||||||
use std::path::Path;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Instant;
|
|
||||||
use tokio::sync::Mutex;
|
|
||||||
|
|
||||||
// A mock of AudioPlayer to isolate the play method's blocking behavior.
|
|
||||||
// We only implement the relevant part of the logic that needs optimizing.
|
|
||||||
pub struct AudioPlayerMock {
|
|
||||||
pub tracks: std::collections::HashMap<u32, ()>,
|
|
||||||
pub next_id: u32,
|
|
||||||
pub volume: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AudioPlayerMock {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
AudioPlayerMock {
|
|
||||||
tracks: std::collections::HashMap::new(),
|
|
||||||
next_id: 1,
|
|
||||||
volume: 1.0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn play(
|
|
||||||
&mut self,
|
|
||||||
file_path: &Path,
|
|
||||||
concurrent: bool,
|
|
||||||
) -> Result<u32, Box<dyn std::error::Error + Send + Sync>> {
|
|
||||||
if !file_path.exists() {
|
|
||||||
return Err(format!("File does not exist: {}", file_path.display()).into());
|
|
||||||
}
|
|
||||||
|
|
||||||
let path_buf = file_path.to_path_buf();
|
|
||||||
let _file = tokio::task::spawn_blocking(move || {
|
|
||||||
// Simulate some blocking work like Decoder::try_from which reads file headers
|
|
||||||
let _f = fs::File::open(&path_buf).unwrap();
|
|
||||||
|
|
||||||
// Emulate the actual time spent reading file and decoding header (which is what Decoder::try_from does)
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(100)); // Simulate slow disk/decode
|
|
||||||
_f
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !concurrent {
|
|
||||||
self.tracks.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
let id = self.next_id;
|
|
||||||
self.next_id += 1;
|
|
||||||
self.tracks.insert(id, ());
|
|
||||||
|
|
||||||
Ok(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
||||||
async fn test_performance_blocking() {
|
|
||||||
println!("Setting up mock environment...");
|
|
||||||
|
|
||||||
// Create a dummy file to read
|
|
||||||
let test_file = Path::new("test_dummy.wav");
|
|
||||||
fs::write(test_file, "dummy content").unwrap();
|
|
||||||
|
|
||||||
let player = Arc::new(Mutex::new(AudioPlayerMock::new()));
|
|
||||||
|
|
||||||
println!("Starting benchmark for synchronous behavior in async fn...");
|
|
||||||
|
|
||||||
// We launch a background task that measures event loop latency.
|
|
||||||
// If the main tasks block the executor, this task will suffer high latency.
|
|
||||||
let latency_task = tokio::spawn(async {
|
|
||||||
let mut max_latency = std::time::Duration::from_secs(0);
|
|
||||||
for _ in 0..50 {
|
|
||||||
let start = Instant::now();
|
|
||||||
tokio::task::yield_now().await;
|
|
||||||
let elapsed = start.elapsed();
|
|
||||||
if elapsed > max_latency {
|
|
||||||
max_latency = elapsed;
|
|
||||||
}
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
|
||||||
}
|
|
||||||
max_latency
|
|
||||||
});
|
|
||||||
|
|
||||||
// Launch multiple play operations
|
|
||||||
let mut tasks = vec![];
|
|
||||||
let start_time = Instant::now();
|
|
||||||
for _ in 0..10 {
|
|
||||||
let player_clone = Arc::clone(&player);
|
|
||||||
let file_path = test_file.to_path_buf();
|
|
||||||
tasks.push(tokio::spawn(async move {
|
|
||||||
let mut p = player_clone.lock().await;
|
|
||||||
let _ = p.play(&file_path, true).await;
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for all tasks to finish
|
|
||||||
for t in tasks {
|
|
||||||
let _ = t.await;
|
|
||||||
}
|
|
||||||
let total_time = start_time.elapsed();
|
|
||||||
|
|
||||||
let max_latency = latency_task.await.unwrap();
|
|
||||||
|
|
||||||
println!("Total execution time: {:?}", total_time);
|
|
||||||
println!(
|
|
||||||
"Max event loop latency (blocking indicator): {:?}",
|
|
||||||
max_latency
|
|
||||||
);
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
fs::remove_file(test_file).unwrap();
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user