Compare commits

..

12 Commits

Author SHA1 Message Date
Tarasov Aleksandr 78e0a133b6 Bump version to 1.7.3 and update dependencies (#70)
* change version to 1.7.3

* deps: cargo update

orbclient -> v0.3.53
wasip2 -> v1.0.3+wasi-0.2.9
wit-bindgen + v0.57.1

* deps: update cargo-sources.json for flatpak
2026-04-21 19:41:19 +03:00
Tarasov Aleksandr 7f8b7194b6 refactor: move PipeWire initialization into a reusable helper function (#69)
Extract duplicated `pipewire::init()`, `MainLoopRc::new()`, and `ContextRc::new()` setup code from `pw_get_global_objects_thread`, `create_virtual_mic`, and `create_link` into a shared `setup_pipewire_context` helper in `src/utils/pipewire.rs`. Also ran codebase-wide linters to improve code quality.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-04-20 19:22:50 +03:00
Tarasov Aleksandr 302f153b91 refactor: break down handle_input into smaller methods in src/gui/input.rs (#67)
Extract sections from the long `handle_input` function into smaller,
context-specific helper methods such as `handle_hotkey_assignment`,
`handle_toggles`, `handle_playback_and_focus`, `handle_file_playback`,
`handle_navigation`, and `handle_hotkey_triggers`. This significantly
improves the maintainability and readability of `src/gui/input.rs`
while preserving original functionality.

In addition, ran `cargo clippy --fix` on the project to resolve a few
other minor health issues, like collapsing nested `if` statements and
reducing unnecessary allocations.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-04-20 19:21:59 +03:00
Tarasov Aleksandr f87dcb1564 refactor: remove unnecessary string cloning when finding hotkey conflicts (#68)
Changed `HotkeyConfig::find_conflicts` to return a `Vec<(&str, &str)>` rather than allocating owned `Strings`. In `src/gui/draw.rs`, the code now builds a `HashSet<&str>` directly from the borrowed strings using array-based flat-mapping, avoiding intermediate `Vec` allocations and redundant clones.

Benchmarked to be approximately 3.5x faster in scenarios involving many configured slots.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-04-20 19:20:54 +03:00
Tarasov Aleksandr d4d16f6ce7 Prepare Application for Flathub Submission (#64)
* chore(flatpak): prepare application for Flathub submission

- Change Flatpak filesystem permissions from `host` to `home` to comply with Flathub sandbox rules
- Remove `--share=network` build argument and implement offline Rust building using `flatpak-cargo-generator.py`
- Add generated `cargo-sources.json` to the Flatpak manifest to download dependencies
- Update `CARGO_HOME` environment variable to ensure vendored dependencies are found by `cargo build --offline`
- Update `.desktop` categories to meet Flathub specs (`AudioVideo` requirement)
- Add required `<releases>`, `bugtracker`, `vcs-browser` URLs, and valid `<developer>` tags to `metainfo.xml`

Co-authored-by: arabianq <55220741+arabianq@users.noreply.github.com>

* chore(flatpak): prepare application for Flathub submission

- Change Flatpak filesystem permissions from `host` to `home` to comply with Flathub sandbox rules
- Remove `--share=network` build argument and implement offline Rust building using `flatpak-cargo-generator.py`
- Add generated `cargo-sources.json` to the Flatpak manifest to download dependencies
- Explicitly set `CARGO_HOME=$PWD/cargo` in build-commands to ensure vendored dependencies are found by `cargo build --offline`
- Update `.desktop` categories to meet Flathub specs (`AudioVideo` requirement)
- Add required `<releases>`, `bugtracker`, `vcs-browser` URLs, and valid `<developer>` tags to `metainfo.xml`

Co-authored-by: arabianq <55220741+arabianq@users.noreply.github.com>

* chore(flatpak): add script to generate cargo sources

- Added `packages/flatpak/generate-sources.sh` to automate the generation of `cargo-sources.json`
- Script downloads the `flatpak-cargo-generator.py` tool from upstream, generates the offline sources map based on `Cargo.lock`, and cleans up after itself

Co-authored-by: arabianq <55220741+arabianq@users.noreply.github.com>

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-04-17 19:35:36 +03:00
Tarasov Aleksandr 949307fcf8 Update dependencies and change version to 1.7.2 (#63)
* deps: bump tokio to 1.52.1

* deps: bump clap to 4.6.1

* deps: cargo update

bitflags -> v2.11.1
core2 -
dary_heap -> v0.3.9
font-types -> v0.11.3
include-flate -> v0.3.3
include-flate-codegen -> v0.3.3
include-flate-compress -> v0.3.3
libc -> v0.2.185
libflate -> v2.3.0
libflate_lz77 -> v2.3.0
no_std_io2 + v0.9.3
portable-atomic-util -> v0.2.7
pxfm -> v0.1.29
rayon -> v1.12.0
uuid -> v1.23.1
webbrowser -> v1.2.1

* change version to 1.7.2
2026-04-17 14:42:59 +03:00
Tarasov Aleksandr 2a8fcca06b Fix virtual mic audio linking (#62)
* Fix virtual mic audio linking by managing it in AudioPlayer lifecycle

- Moved `link_player_to_virtual_mic` to `src/utils/pipewire.rs` and updated it to return a termination sender.
- Added `player_link_sender` to `AudioPlayer` to manage the PipeWire link between the daemon and the virtual mic.
- Integrated linking logic into `AudioPlayer::play` and `AudioPlayer::update` to ensure the link is established when audio starts playing.
- Ensured the link is terminated in `AudioPlayer::drop_stream` when the audio sink is closed.
- Removed redundant and potentially failing startup linking loop from the daemon.
- Fixed log spam by ensuring `link_player` is only attempted when necessary and errors are handled gracefully.
- Maintained compatibility with stable Rust by avoiding unstable features.

Co-authored-by: arabianq <55220741+arabianq@users.noreply.github.com>

* small refactor

* refactor

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-04-17 14:24:58 +03:00
Tarasov Aleksandr 5c4b8f4b45 refactor(gui): replace verbose key matching with egui native methods (#60)
Replaced the large match blocks in `chord_from_event` and `parse_chord`
with `egui::Key::name()` and `egui::Key::from_name()`. This drastically
reduces boilerplate code while maintaining the existing behavior that
strictly allows only single-character alphanumeric keys and 'F' keys for
chords.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-04-17 13:58:08 +03:00
Tarasov Aleksandr 70c7e3789b 🔒 Fix potential memory exhaustion in socket reads (#59)
Addresses a security vulnerability where the daemon or client could be
forced to allocate up to 10MB of memory per malformed socket message,
potentially leading to Out-Of-Memory (OOM) crashes.

Changes:
- Introduced a central `MAX_MESSAGE_SIZE` constant of 128KB in `src/types/socket.rs`.
- Enforced the 128KB limit on incoming requests in `src/bin/daemon.rs`.
- Enforced the 128KB limit on incoming responses in `src/utils/daemon.rs`.
- Preserved detailed `eprintln!` logging when messages are rejected.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-04-17 13:56:29 +03:00
Tarasov Aleksandr 5367a3daae version 1.7.1, update deps, update docs (#57)
* refactor: removed garbage

* change version to 1.7.1

* cargo fmt

* cargo update

* docs: add information about hotkeys to README

* docs: small refactor
2026-04-12 17:23:04 +03:00
Tarasov Aleksandr 42c0170044 fix: hotkeys setting from pwsp-gui (#56)
* refactor: do not overwrite incorrect hotkeys config

* fix: hotkeys not saved via pwsp-gui
2026-04-12 17:05:10 +03:00
RiDDiX cb56cb3a04 fix: drop audio stream when idle to allow system suspend (#54)
* fix: drop audio stream when idle to allow system suspend

The daemon kept its ALSA playback stream open permanently, which
PipeWire reported as a running Stream/Output/Audio node even with
no tracks playing. This prevented desktop environments from detecting
idle state and entering suspend.

- Make the audio sink on-demand: created when playback starts,
  dropped when all tracks finish
- Reduce player loop polling from 100ms to 2s when idle
- Throttle PipeWire device enumeration to every ~5s while playing
- Log only first and last link retry attempt instead of all 60
2026-04-12 00:42:10 +03:00
26 changed files with 7306 additions and 693 deletions
Generated
+238 -236
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "pwsp" name = "pwsp"
version = "1.7.0" version = "1.7.3"
edition = "2024" edition = "2024"
authors = ["arabian"] authors = ["arabian"]
description = "PWSP lets you play audio files through your microphone. Has both CLI and GUI clients." description = "PWSP lets you play audio files through your microphone. Has both CLI and GUI clients."
@@ -12,13 +12,13 @@ keywords = ["soundpad", "pipewire", "linux", "cli", "gui"]
[dependencies] [dependencies]
tokio = { version = "1.51.1", features = ["full"] } tokio = { version = "1.52.1", features = ["full"] }
async-trait = "0.1.89" async-trait = "0.1.89"
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149" serde_json = "1.0.149"
clap = { version = "4.6.0", default-features = false, features = [ clap = { version = "4.6.1", default-features = false, features = [
"std", "std",
"suggestions", "suggestions",
"help", "help",
+3 -1
View File
@@ -27,6 +27,8 @@ chats on platforms like **Discord, Zoom, or Teamspeak**.
* **Collapsible Audio Tracks**: You can collapse every audio track to save space. * **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. * **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. * **Automatic Device Detection**: PWSP automatically detects when an input device is connected or disconnected and handles linking/unlinking.
* **Global Hotkeys**: Assign custom keyboard shortcuts to any sound file (or action) to trigger playback instantly, even when the application is not in focus.
# **⚙️ How It Works** # **⚙️ How It Works**
@@ -38,8 +40,8 @@ three main components:
* Creating and managing virtual audio devices. * Creating and managing virtual audio devices.
* Linking these devices within the PipeWire graph. * Linking these devices within the PipeWire graph.
* Handling all audio playback. * Handling all audio playback.
* **UnixSocket**. This is how you interact with your sound collection, control playback, and configure settings.
* **pwsp-gui**: This is the graphical user interface. It acts as a client that communicates with pwsp-daemon via a * **pwsp-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 * **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. without a GUI, allowing for scripting or quick command-based actions.
+3 -3
View File
@@ -1,6 +1,6 @@
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.7.3
pkgrel = 2 pkgrel = 2
url = https://github.com/arabianq/pipewire-soundpad url = https://github.com/arabianq/pipewire-soundpad
arch = x86_64 arch = x86_64
@@ -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.7.3.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.7.3/pwsp-v1.7.3-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.7.3.tar.gz :: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.7.3.tar.gz
sha256sums = SKIP sha256sums = SKIP
sha256sums = SKIP sha256sums = SKIP
+1 -1
View File
@@ -1,7 +1,7 @@
# 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.7.3
pkgrel=2 pkgrel=2
pkgdesc="Lets you play audio files through your microphone (Pre-built binaries)" pkgdesc="Lets you play audio files through your microphone (Pre-built binaries)"
arch=('x86_64') arch=('x86_64')
+2 -2
View File
@@ -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.7.3
pkgrel = 1 pkgrel = 1
url = https://github.com/arabianq/pipewire-soundpad url = https://github.com/arabianq/pipewire-soundpad
arch = any arch = any
@@ -10,7 +10,7 @@ pkgbase = pwsp
makedepends = cargo makedepends = cargo
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.7.3.tar.gz
sha256sums = SKIP sha256sums = SKIP
pkgname = pwsp pkgname = pwsp
+1 -1
View File
@@ -1,7 +1,7 @@
# 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.7.3
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')
File diff suppressed because it is too large Load Diff
+19
View File
@@ -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"
+1 -1
View File
@@ -5,5 +5,5 @@ Exec=pwsp-wrapper.py %u
Icon=ru.arabianq.pwsp Icon=ru.arabianq.pwsp
Terminal=false Terminal=false
Type=Application Type=Application
Categories=Audio;Utility; Categories=AudioVideo;Audio;
Keywords=soundpad;pipewire;audio; Keywords=soundpad;pipewire;audio;
@@ -15,11 +15,17 @@
<launchable type="desktop-id">ru.arabianq.pwsp.desktop</launchable> <launchable type="desktop-id">ru.arabianq.pwsp.desktop</launchable>
<screenshots> <screenshots>
<screenshot type="default"> <screenshot type="default">
<image> <image>https://raw.githubusercontent.com/arabianq/pipewire-soundpad/master/assets/screenshot.png</image>
https://raw.githubusercontent.com/arabianq/pipewire-soundpad/master/assets/screenshot.png</image>
</screenshot> </screenshot>
</screenshots> </screenshots>
<url type="homepage">https://pwsp.arabianq.ru</url> <url type="homepage">https://pwsp.arabianq.ru</url>
<developer_name>arabian</developer_name> <url type="bugtracker">https://github.com/arabianq/pipewire-soundpad/issues</url>
<url type="vcs-browser">https://github.com/arabianq/pipewire-soundpad</url>
<developer id="ru.arabianq">
<name>arabian</name>
</developer>
<releases>
<release version="1.7.3" date="2026-04-20" />
</releases>
<content_rating type="oars-1.1" /> <content_rating type="oars-1.1" />
</component> </component>
+3 -6
View File
@@ -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
+1 -1
View File
@@ -4,7 +4,7 @@
%global cargo_install_lib 0 %global cargo_install_lib 0
Name: pwsp Name: pwsp
Version: 1.7.0 Version: 1.7.3
Release: %autorelease Release: %autorelease
Summary: Lets you play audio files through your microphone Summary: Lets you play audio files through your microphone
+19 -1
View File
@@ -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
View File
@@ -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;
} }
} }
}
+15 -20
View File
@@ -196,10 +196,8 @@ impl SoundpadGui {
ui.add_space(5.0); 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();
@@ -266,7 +264,7 @@ impl SoundpadGui {
for slot in &slots { for slot in &slots {
ui.horizontal(|ui| { ui.horizontal(|ui| {
// Conflict badge // Conflict badge
if conflict_slots.contains(&slot.slot) { if conflict_slots.contains(slot.slot.as_str()) {
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)),
@@ -609,11 +607,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 +643,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,11 +725,11 @@ 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);
let file_button_response = ui.add(file_button); let file_button_response = ui.add(file_button);
@@ -792,11 +789,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();
@@ -822,9 +818,10 @@ impl SoundpadGui {
fn get_hotkey_badge(&self, path: &PathBuf) -> Option<String> { fn get_hotkey_badge(&self, path: &PathBuf) -> 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.as_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 +829,6 @@ impl SoundpadGui {
} }
} }
} }
}
}
None None
} }
+33 -109
View File
@@ -7,57 +7,15 @@ 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.chars().next().unwrap().is_ascii_alphanumeric())
Key::C => "C", || (key_name.starts_with('F')
Key::D => "D", && key_name.len() > 1
Key::E => "E", && key_name[1..].chars().all(|c| c.is_ascii_digit()));
Key::F => "F", if !is_valid {
Key::G => "G", return None;
Key::H => "H", }
Key::I => "I",
Key::J => "J",
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 +58,18 @@ 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.chars().next().unwrap().is_ascii_alphanumeric())
"C" => Key::C, || (key_name.starts_with('F')
"D" => Key::D, && key_name.len() > 1
"E" => Key::E, && key_name[1..].chars().all(|c| c.is_ascii_digit()));
"F" => Key::F,
"G" => Key::G, if !is_valid {
"H" => Key::H, return None;
"I" => Key::I, }
"J" => Key::J,
"K" => Key::K, let key = Key::from_name(key_name)?;
"L" => Key::L,
"M" => Key::M,
"N" => Key::N,
"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 +140,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 +192,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 +206,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
+80 -22
View File
@@ -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
View File
@@ -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
View File
@@ -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]));
} }
} }
} }
+14
View File
@@ -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)]
+13
View File
@@ -106,6 +106,19 @@ 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,
} }
} }
+11 -43
View File
@@ -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
View File
@@ -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();
} }
+52 -16
View File
@@ -9,6 +9,13 @@ use tokio::{
time::{Duration, timeout}, time::{Duration, timeout},
}; };
pub fn setup_pipewire_context() -> (MainLoopRc, ContextRc) {
pipewire::init();
let main_loop = MainLoopRc::new(None).expect("Failed to initialize pipewire main loop");
let context = ContextRc::new(&main_loop, None).expect("Failed to create pipewire context");
(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,7 +79,6 @@ fn parse_global_object(
return (None, Some(port)); return (None, Some(port));
} }
} }
}
(None, None) (None, None)
} }
@@ -79,9 +86,7 @@ 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>,
) { ) {
pipewire::init(); let (main_loop, context) = setup_pipewire_context();
let main_loop = MainLoopRc::new(None).expect("Failed to initialize pipewire main loop");
// 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,7 +94,6 @@ 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 = context let core = context
.connect(None) .connect(None)
.expect("Failed to connect to pipewire context"); .expect("Failed to connect to pipewire context");
@@ -224,10 +228,7 @@ pub fn create_virtual_mic() -> Result<pipewire::channel::Sender<Terminate>, Box<
let (pw_sender, pw_receiver) = pipewire::channel::channel::<Terminate>(); let (pw_sender, pw_receiver) = pipewire::channel::channel::<Terminate>();
let _pw_thread = thread::spawn(move || { let _pw_thread = thread::spawn(move || {
pipewire::init(); let (main_loop, context) = setup_pipewire_context();
let main_loop = MainLoopRc::new(None).expect("Failed to initialize pipewire main loop");
let context = ContextRc::new(&main_loop, None).expect("Failed to create pipewire context");
let core = context let core = context
.connect(None) .connect(None)
.expect("Failed to connect to pipewire context"); .expect("Failed to connect to pipewire context");
@@ -258,6 +259,44 @@ pub fn create_virtual_mic() -> Result<pipewire::channel::Sender<Terminate>, Box<
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,
@@ -267,10 +306,7 @@ pub fn create_link(
let (pw_sender, pw_receiver) = pipewire::channel::channel::<Terminate>(); let (pw_sender, pw_receiver) = pipewire::channel::channel::<Terminate>();
let _pw_thread = thread::spawn(move || { let _pw_thread = thread::spawn(move || {
pipewire::init(); let (main_loop, context) = setup_pipewire_context();
let main_loop = MainLoopRc::new(None).expect("Failed to initialize pipewire main loop");
let context = ContextRc::new(&main_loop, None).expect("Failed to create pipewire context");
let core = context let core = context
.connect(None) .connect(None)
.expect("Failed to connect to pipewire context"); .expect("Failed to connect to pipewire context");
-113
View File
@@ -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();
}