Compare commits

...

19 Commits

Author SHA1 Message Date
Tarasov Aleksandr b2b83f5c32 merge dev
* cargo fmt

* deps: bump clap to 4.6.0

* deps: cargo update

* Fix daemon autostart issue caused by sync pipewire retry loop (#43) (#44)

At boot time, PipeWire takes some time to register the `pwsp-daemon` and `pwsp-virtual-mic` devices. Previously, the daemon's retry loop for `link_player_to_virtual_mic()` was synchronous and limited to 5 attempts (1.5 seconds total). This caused systemd autostarts to fail with a code 1 if the devices were not yet available.

This change replaces the synchronous wait with an asynchronous `tokio::spawn` task. It will retry the link attempt up to 60 times with a 1-second delay without blocking the startup of the rest of the daemon. This prevents it from exiting abruptly during autostart.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>

* deps: bump egui & eframe version to 0.34.1

* feat: replaced App::update with new App::logic and App::ui

* deps: bump egui_material_icons to 0.6.0

* deps: bump egui_dnd to 0.15.0

* fix: use .codepoint for icons

* refactor

* refactor: replaced deprecated CentralPanel::show with CentralPanel::show_inside

* refactor

* change version to 1.6.3

* update rust toolchain in github actions

* update freedesktop platform version to 25.08 for flaptak

* update github actions for flatpak builds

* add flatpak-builder installation inside actions

* add flathub configuration to actions

* add --user flag to flathub configuration

* remove sudo from flatpak actions

* Fix/dev flatpak actions 6082245116761610541 (#47)

* remove sudo

* remove steps

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-27 15:46:06 +03:00
Tarasov Aleksandr f01a0e656c Fix daemon autostart issue caused by sync pipewire retry loop (#43)
At boot time, PipeWire takes some time to register the `pwsp-daemon` and `pwsp-virtual-mic` devices. Previously, the daemon's retry loop for `link_player_to_virtual_mic()` was synchronous and limited to 5 attempts (1.5 seconds total). This caused systemd autostarts to fail with a code 1 if the devices were not yet available.

This change replaces the synchronous wait with an asynchronous `tokio::spawn` task. It will retry the link attempt up to 60 times with a 1-second delay without blocking the startup of the rest of the daemon. This prevents it from exiting abruptly during autostart.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-22 17:05:26 +03:00
Tarasov Aleksandr 6114b9a7f8 🔒 [security] Set restricted permissions on socket and runtime directory (#40)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-21 21:03:48 +03:00
Tarasov Aleksandr b8baeb6226 refactor: replace contains_key/unwrap with if let Some in pipewire.rs (#39)
This commit replaces two instances of `contains_key` followed by
`get_mut().unwrap()` with the more idiomatic `if let Some(...)` pattern
in `src/utils/pipewire.rs`. This reduces redundant hash map lookups and
eliminates potential panics from `unwrap()`.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-21 21:03:30 +03:00
Tarasov Aleksandr 02306b5893 refactor: simplify file and directory navigation logic (#37)
Refactored the navigation logic in `src/gui/input.rs` to use idiomatic Rust patterns.
Replaced clunky manual index calculations and type casting with `match` expressions and modular arithmetic on `usize`.
This improvement enhances readability and maintainability by eliminating nested `if/else` blocks and potential overflow issues from integer casts.

🎯 **What:** The code health issue addressed
- Refactored directory and file navigation logic to use `usize` and modular arithmetic.
- Replaced manual wrap-around logic with idiomatic `match` expressions.

💡 **Why:** How this improves maintainability
- Eliminates unnecessary and potentially risky type casting (e.g., `i8`, `i64`).
- Reduces code nesting and complexity, making it easier to read and extend.
- Standardizes the circular navigation pattern across the GUI.

 **Verification:** How you confirmed the change is safe
- Manually reviewed and verified the logic for all key combinations (ArrowUp, ArrowDown, both, or none).
- Confirmed correct behavior for both initial selection (None) and existing selection (Some) states.

 **Result:** The improvement achieved
- Cleaner, more idiomatic Rust code for list navigation.
- Reduced potential for index-related bugs.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-08 02:40:39 +03:00
Tarasov Aleksandr 3add499bd7 Optimize get_device with iterator chaining (#38)
Replaces manual vector extension and linear search with an iterator
chain. This avoids an unnecessary allocation and potential reallocation
of the `input_devices` vector and allows for short-circuiting if the
device is found early.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-08 02:40:25 +03:00
Tarasov Aleksandr 3c2e943e18 fix(security): eliminate TOCTOU vulnerability during socket removal (#36)
Directly attempt to remove the daemon socket file and handle NotFound errors
instead of checking for its existence first. This prevents a potential
race condition where the file could be replaced between the check and
the removal.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-08 02:13:19 +03:00
Tarasov Aleksandr 261f83efd4 🧹 Code Health: Handle AudioPlayer initialization errors safely (#35)
* 🧹 Refactor: Replace unsafe unwrap in get_audio_player

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

* 🧹 Refactor: Replace unsafe unwrap in get_audio_player

Resolved GitHub CI failure where a syntax error was introduced due to a bad automated merge with main. Rebased cleanly to ensure only the get_audio_player code health changes are included.

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

* Delete tests/perf_play.rs

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-08 01:36:25 +03:00
Tarasov Aleksandr c6577cd5e0 🧹 Replace unsafe unwrap on Mutex lock with unwrap_or_else (#33)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-08 00:43:36 +03:00
Tarasov Aleksandr 95761f6a5a Optimize AudioPlayer::play by offloading sync file I/O to spawn_blocking (#29)
* perf(audio_player): offload synchronous I/O and decoder init to spawn_blocking

Moved synchronous file system operations (`fs::File::open` and `file_path.exists()`) and CPU-bound decoder initialization (`Decoder::try_from`) inside the async `AudioPlayer::play` method to `tokio::task::spawn_blocking`.
This prevents starving the Tokio worker threads during disk operations and significantly reduces event loop latency.

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

* perf(audio_player): offload synchronous I/O and decoder init to spawn_blocking

Moved synchronous file system operations (`fs::File::open` and `file_path.exists()`) and CPU-bound decoder initialization (`Decoder::try_from`) inside the async `AudioPlayer::play` method to `tokio::task::spawn_blocking`.
This prevents starving the Tokio worker threads during disk operations and significantly reduces event loop latency.

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-03-08 00:41:34 +03:00
Tarasov Aleksandr d6effc972e 🧹 refactor: replace unsafe unwraps in pipewire port parsing with safe destructuring (#34)
Replaced sequential unwraps on pipewire properties ("node.id", "port.id", "port.name")
with an `if let` and `and_then()` pattern in `src/utils/pipewire.rs`. This provides
safety against daemon crashes when properties are missing or malformed by silently
returning instead of panicking.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-08 00:41:06 +03:00
Tarasov Aleksandr 498d0d25af perf: optimize AudioPlayer::update to avoid blocking the Tokio executor thread (#32)
Offload synchronous `fs::File::open` and `Decoder::try_from` operations to `tokio::task::spawn_blocking` in `AudioPlayer::update`. This allows the Tokio runtime to process other asynchronous tasks concurrently without being blocked by file I/O operations and audio header decoding during looped playback.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-08 00:36:08 +03:00
Tarasov Aleksandr c99d0749e3 🔒 Add request size limit to daemon socket IPC to prevent OOM panic (#31)
The daemon was allocating memory based on an unverified length prefix
sent over the unauthenticated Unix socket, potentially allowing a malicious
client to cause an Out-Of-Memory panic (DoS). A 10 MB size limit has been
introduced.

Note: The previously reported `unwrap()` panic on invalid JSON payloads
was already fixed and replaced with a safe `match` block in a prior commit.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-08 00:34:50 +03:00
Tarasov Aleksandr 151f43f1ab Optimize value lookup in GetFullStateCommand loop (#30)
Moves the check `if let Some(current_input_name) = &audio_player.input_device_name`
outside the loop over `input_devices` in `GetFullStateCommand::execute`.

By creating two separate loops (one for when an input device name is selected,
and one for when it is not), we eliminate the overhead of evaluating the `Option`
on every single iteration of the `input_devices` array. This effectively unswitches
the loop and avoids repeatedly accessing the `audio_player` struct field.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-08 00:32:42 +03:00
Tarasov Aleksandr 077518019f perf: cache and sort microphone inputs once instead of every frame (#27)
In `draw_footer`, the `all_inputs` HashMap was being collected into a Vec
and sorted on every single UI frame (~60 FPS). This caused unnecessary
overhead and allocations.

This commit introduces a cached `all_inputs_sorted` vector in the
`AudioPlayerState` which is populated only when the inputs map changes
during the state sync. `draw_footer` now loops over this pre-sorted
vector directly.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-08 00:28:45 +03:00
Tarasov Aleksandr 968eba80e6 Fix: Prevent panic on invalid configuration files (#26)
When the user's daemon.json or gui.json configuration files become corrupted or invalid (e.g. invalid JSON), the application panics as the load_from_file function previously bubbled up the error causing a panic. This fix modifies load_from_file for both DaemonConfig and GuiConfig to catch JSON parsing errors using a match statement and return a Default configuration instead.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-08 00:28:09 +03:00
Tarasov Aleksandr aa77a8d212 refactor: replace unsafe unwrap with if let in config saving (#24)
Removed the unsafe `.unwrap()` call when attempting to get the parent directory of `config_path` in `DaemonConfig::save_to_file` and `GuiConfig::save_to_file`. Replaced it with an idiomatic `if let Some(config_dir)` check to improve code safety and maintainability.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-08 00:26:45 +03:00
Tarasov Aleksandr 4b50645c93 refactor(gui): replace unsafe unwrap on gui thread lock (#23)
Replaces `audio_player_state_shared.lock().unwrap()` with `.unwrap_or_else(|e| e.into_inner())` in `src/utils/gui.rs` to allow safe recovery from poisoned locks and avoid application panics.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-08 00:25:42 +03:00
Tarasov Aleksandr 39648f7781 refactor(gui): replace unsafe unwrap with idiomatic if let Some in input handling (#22)
This commit addresses a code health issue in `src/gui/input.rs` where an `.is_some()` check was followed by an unsafe `.unwrap()` on `self.app_state.selected_file`.

The logic has been updated to use the idiomatic `if let Some(path) = self.app_state.selected_file.clone()` pattern. The `.clone()` is necessary because the subsequent methods (`self.play_file` and `self.stop`) require a mutable borrow (`&mut self`), which would conflict with an immutable borrow of `self.app_state.selected_file`. This change ensures the code is safe and panic-free while satisfying Rust's borrow checker rules. Behavior remains unchanged.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-03-08 00:24:25 +03:00
23 changed files with 970 additions and 352 deletions
+2 -2
View File
@@ -33,7 +33,7 @@ jobs:
- name: Setup Rust toolchain - name: Setup Rust toolchain
uses: actions-rs/toolchain@v1 uses: actions-rs/toolchain@v1
with: with:
toolchain: stable toolchain: 1.94.1
- name: Extract all binary names - name: Extract all binary names
id: cargo-meta id: cargo-meta
@@ -103,7 +103,7 @@ jobs:
flatpak-build: flatpak-build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: bilelmoussaoui/flatpak-github-actions:freedesktop-24.08 image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-25.08
options: --privileged options: --privileged
steps: steps:
+2 -2
View File
@@ -85,7 +85,7 @@ jobs:
- name: Setup Rust toolchain - name: Setup Rust toolchain
uses: actions-rs/toolchain@v1 uses: actions-rs/toolchain@v1
with: with:
toolchain: stable toolchain: 1.94.1
- name: Extract all binary names - name: Extract all binary names
id: cargo-meta id: cargo-meta
@@ -158,7 +158,7 @@ jobs:
needs: prepare needs: prepare
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: bilelmoussaoui/flatpak-github-actions:freedesktop-24.08 image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-25.08
options: --privileged options: --privileged
steps: steps:
Generated
+526 -169
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "pwsp" name = "pwsp"
version = "1.6.2" version = "1.6.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."
@@ -18,7 +18,7 @@ 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.5.60", default-features = false, features = [ clap = { version = "4.6.0", default-features = false, features = [
"std", "std",
"suggestions", "suggestions",
"help", "help",
@@ -39,18 +39,18 @@ rfd = { version = "0.17.2", default-features = false, features = [
] } ] }
opener = { version = "0.8.4", features = ["reveal"] } opener = { version = "0.8.4", features = ["reveal"] }
egui = { version = "0.33.3", default-features = false, features = [ egui = { version = "0.34.1", default-features = false, features = [
"default_fonts", "default_fonts",
"rayon", "rayon",
] } ] }
eframe = { version = "0.33.3", default-features = false, features = [ eframe = { version = "0.34.1", default-features = false, features = [
"default_fonts", "default_fonts",
"glow", "glow",
"x11", "x11",
"wayland", "wayland",
] } ] }
egui_material_icons = "0.5.0" egui_material_icons = "0.6.0"
egui_dnd = "0.14.0" egui_dnd = "0.15.0"
[[bin]] [[bin]]
name = "pwsp-daemon" name = "pwsp-daemon"
+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.6.2 pkgver = 1.6.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.6.2.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.6.2/pwsp-v1.6.2-linux-x64.zip source = pwsp-bin-1.6.3.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.6.3/pwsp-v1.6.3-linux-x64.zip
source = pipewire-soundpad-1.6.2.tar.gz :: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.6.2.tar.gz source = pipewire-soundpad-1.6.3.tar.gz :: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.6.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.6.2 pkgver=1.6.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.6.2 pkgver = 1.6.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.6.2.tar.gz source = https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.6.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.6.2 pkgver=1.6.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')
+1 -1
View File
@@ -1,6 +1,6 @@
app-id: ru.arabianq.pwsp app-id: ru.arabianq.pwsp
runtime: org.freedesktop.Platform runtime: org.freedesktop.Platform
runtime-version: "24.08" runtime-version: "25.08"
sdk: org.freedesktop.Sdk sdk: org.freedesktop.Sdk
sdk-extensions: sdk-extensions:
- org.freedesktop.Sdk.Extension.rust-stable - org.freedesktop.Sdk.Extension.rust-stable
+1 -1
View File
@@ -4,7 +4,7 @@
%global cargo_install_lib 0 %global cargo_install_lib 0
Name: pwsp Name: pwsp
Version: 1.6.2 Version: 1.6.3
Release: %autorelease Release: %autorelease
Summary: Lets you play audio files through your microphone Summary: Lets you play audio files through your microphone
+36 -9
View File
@@ -9,6 +9,7 @@ use pwsp::{
pipewire::create_virtual_mic, pipewire::create_virtual_mic,
}, },
}; };
use std::os::unix::fs::PermissionsExt;
use std::{error::Error, fs, time::Duration}; use std::{error::Error, fs, time::Duration};
use tokio::{ use tokio::{
io::{AsyncReadExt, AsyncWriteExt}, io::{AsyncReadExt, AsyncWriteExt},
@@ -26,18 +27,24 @@ async fn main() -> Result<(), Box<dyn Error>> {
get_daemon_config(); // Initialize daemon config get_daemon_config(); // Initialize daemon config
create_virtual_mic()?; create_virtual_mic()?;
get_audio_player().await; // Initialize audio player if let Err(err) = get_audio_player().await {
eprintln!("Failed to initialize audio player: {}", err);
} // Initialize audio player
let max_retries = 5; tokio::spawn(async {
let max_retries = 60;
for i in 0..=max_retries { for i in 0..=max_retries {
match link_player_to_virtual_mic().await { match link_player_to_virtual_mic().await {
Ok(_) => break, Ok(_) => {
println!("Successfully linked player to virtual mic.");
break;
}
Err(e) => println!("{e}\t{i}/{max_retries}"), Err(e) => println!("{e}\t{i}/{max_retries}"),
} }
sleep(Duration::from_millis(300 * i)).await; sleep(Duration::from_millis(1000)).await;
} }
link_player_to_virtual_mic().await?; });
let runtime_dir = get_runtime_dir(); let runtime_dir = get_runtime_dir();
@@ -45,11 +52,15 @@ 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 fs::metadata(&socket_path).is_ok() { if let Err(e) = fs::remove_file(&socket_path) {
fs::remove_file(&socket_path)?; if e.kind() != std::io::ErrorKind::NotFound {
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))?;
println!( println!(
"Daemon started. Listening on {}", "Daemon started. Listening on {}",
socket_path.to_str().unwrap_or_default() socket_path.to_str().unwrap_or_default()
@@ -89,6 +100,14 @@ 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 {
eprintln!(
"Failed to read message from client: request too large ({} bytes)!",
request_len
);
return;
}
let mut buffer = vec![0u8; request_len]; let mut buffer = vec![0u8; request_len];
if stream.read_exact(&mut buffer).await.is_err() { if stream.read_exact(&mut buffer).await.is_err() {
eprintln!("Failed to read message from client!"); eprintln!("Failed to read message from client!");
@@ -151,9 +170,17 @@ async fn commands_loop(listener: UnixListener) -> Result<(), Box<dyn Error>> {
async fn player_loop() { async fn player_loop() {
loop { loop {
let mut audio_player = get_audio_player().await.lock().await; match get_audio_player().await {
Ok(player_mutex) => {
let mut audio_player = player_mutex.lock().await;
audio_player.update().await; audio_player.update().await;
}
Err(_err) => {
// 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.
}
}
sleep(Duration::from_millis(100)).await; sleep(Duration::from_millis(100)).await;
} }
+20 -16
View File
@@ -19,13 +19,13 @@ enum TrackAction {
impl SoundpadGui { impl SoundpadGui {
fn get_volume_icon(volume: f32) -> &'static str { fn get_volume_icon(volume: f32) -> &'static str {
if volume > 0.7 { if volume > 0.7 {
ICON_VOLUME_UP ICON_VOLUME_UP.codepoint
} else if volume <= 0.0 { } else if volume <= 0.0 {
ICON_VOLUME_OFF ICON_VOLUME_OFF.codepoint
} else if volume < 0.3 { } else if volume < 0.3 {
ICON_VOLUME_MUTE ICON_VOLUME_MUTE.codepoint
} else { } else {
ICON_VOLUME_DOWN ICON_VOLUME_DOWN.codepoint
} }
} }
@@ -313,7 +313,7 @@ impl SoundpadGui {
let path = item.clone(); let path = item.clone();
ui.horizontal(|ui| { ui.horizontal(|ui| {
handle.ui(ui, |ui| { handle.ui(ui, |ui| {
ui.label(ICON_DRAG_INDICATOR); ui.label(ICON_DRAG_INDICATOR.codepoint);
}); });
let name = path let name = path
.file_name() .file_name()
@@ -345,7 +345,7 @@ impl SoundpadGui {
// Context menu // Context menu
dir_button_response.context_menu(|ui| { dir_button_response.context_menu(|ui| {
if ui if ui
.button(format!("{} {}", ICON_OPEN_IN_NEW, "Show")) .button(format!("{} {}", ICON_OPEN_IN_NEW.codepoint, "Show"))
.clicked() .clicked()
{ {
self.open_dir(&path); self.open_dir(&path);
@@ -354,7 +354,7 @@ impl SoundpadGui {
if ui if ui
.button(format!( .button(format!(
"{} {}", "{} {}",
ICON_OPEN_IN_BROWSER, "Open in File Manager" ICON_OPEN_IN_BROWSER.codepoint, "Open in File Manager"
)) ))
.clicked() .clicked()
{ {
@@ -365,7 +365,10 @@ impl SoundpadGui {
ui.separator(); ui.separator();
if ui.button(format!("{} {}", ICON_DELETE, "Remove")).clicked() { if ui
.button(format!("{} {}", ICON_DELETE.codepoint, "Remove"))
.clicked()
{
self.app_state.dirs_to_remove.insert(path.clone()); self.app_state.dirs_to_remove.insert(path.clone());
} }
}); });
@@ -452,20 +455,23 @@ impl SoundpadGui {
// Context menu // Context menu
file_button_response.context_menu(|ui| { file_button_response.context_menu(|ui| {
if ui if ui
.button(format!("{} {}", ICON_BOLT, "Play Solo")) .button(format!("{} {}", ICON_BOLT.codepoint, "Play Solo"))
.clicked() .clicked()
{ {
self.play_file(&entry_path, false); self.play_file(&entry_path, false);
self.app_state.selected_file = Some(entry_path.clone()); self.app_state.selected_file = Some(entry_path.clone());
} }
if ui.button(format!("{} {}", ICON_ADD, "Add New")).clicked() { if ui
.button(format!("{} {}", ICON_ADD.codepoint, "Add New"))
.clicked()
{
self.play_file(&entry_path, true); self.play_file(&entry_path, true);
self.app_state.selected_file = Some(entry_path.clone()); self.app_state.selected_file = Some(entry_path.clone());
} }
if ui if ui
.button(format!("{} {}", ICON_SWAP_HORIZ, "Replace Last")) .button(format!("{} {}", ICON_SWAP_HORIZ.codepoint, "Replace Last"))
.clicked() .clicked()
&& let Some(last_track) = self.audio_player_state.tracks.last() && let Some(last_track) = self.audio_player_state.tracks.last()
{ {
@@ -479,7 +485,7 @@ impl SoundpadGui {
if ui if ui
.button(format!( .button(format!(
"{} {}", "{} {}",
ICON_OPEN_IN_BROWSER, "Show in File Manager" ICON_OPEN_IN_BROWSER.codepoint, "Show in File Manager"
)) ))
.clicked() .clicked()
{ {
@@ -498,9 +504,7 @@ impl SoundpadGui {
ui.add_space(5.0); ui.add_space(5.0);
ui.horizontal(|ui| { ui.horizontal(|ui| {
// ---------- Microphone selection ---------- // ---------- Microphone selection ----------
let mut mics: Vec<(&String, &String)> = let mics = &self.audio_player_state.all_inputs_sorted;
self.audio_player_state.all_inputs.iter().collect();
mics.sort_by_key(|(k, _)| *k);
let mut selected_input = self.audio_player_state.current_input.to_owned(); let mut selected_input = self.audio_player_state.current_input.to_owned();
let prev_input = selected_input.to_owned(); let prev_input = selected_input.to_owned();
@@ -514,7 +518,7 @@ impl SoundpadGui {
) )
.show_ui(ui, |ui| { .show_ui(ui, |ui| {
for (name, nick) in mics { for (name, nick) in mics {
ui.selectable_value(&mut selected_input, name.to_owned(), nick); ui.selectable_value(&mut selected_input, name.clone(), nick);
} }
}); });
+32 -38
View File
@@ -57,17 +57,18 @@ impl SoundpadGui {
} }
// Play selected file on Enter // Play selected file on Enter
if self.key_pressed(ctx, Key::Enter) && self.app_state.selected_file.is_some() { if self.key_pressed(ctx, Key::Enter) {
let path = &self.app_state.selected_file.clone().unwrap(); if 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
&& let Some(last_track) = self.audio_player_state.tracks.last() && let Some(last_track) = self.audio_player_state.tracks.last()
{ {
self.stop(Some(last_track.id)); self.stop(Some(last_track.id));
self.play_file(path, true); self.play_file(&path, true);
} else { } else {
self.play_file(path, false); self.play_file(&path, false);
}
} }
} }
@@ -79,29 +80,23 @@ impl SoundpadGui {
let mut dirs: Vec<PathBuf> = self.app_state.dirs.iter().cloned().collect(); let mut dirs: Vec<PathBuf> = self.app_state.dirs.iter().cloned().collect();
dirs.sort(); dirs.sort();
let current_dir_index: i8; let current_dir_index = self
if let Some(current_dir) = &self.app_state.current_dir { .app_state
if let Some(index) = dirs.iter().position(|x| x == current_dir) { .current_dir
current_dir_index = index as i8; .as_ref()
} else { .and_then(|cd| dirs.iter().position(|x| x == cd));
current_dir_index = -1;
}
} else {
current_dir_index = -1;
}
let mut new_dir_index: i8; let new_dir_index =
match (current_dir_index, arrow_up_pressed, arrow_down_pressed) {
(Some(i), true, false) => (i + dirs.len() - 1) % dirs.len(),
(Some(i), false, true) => (i + 1) % dirs.len(),
(Some(i), true, true) => i,
(None, true, _) => dirs.len() - 1,
(None, false, true) => 0,
_ => return,
};
new_dir_index = self.open_dir(&dirs[new_dir_index]);
current_dir_index - arrow_up_pressed as i8 + arrow_down_pressed as i8;
if new_dir_index < 0 {
new_dir_index = (dirs.len() - 1) as i8;
} else if new_dir_index >= dirs.len() as i8 {
new_dir_index = 0;
}
self.open_dir(&dirs[new_dir_index as usize]);
} else if self.app_state.current_dir.is_some() { } else if self.app_state.current_dir.is_some() {
let files = self.get_filtered_files(); let files = self.get_filtered_files();
@@ -113,20 +108,19 @@ impl SoundpadGui {
.app_state .app_state
.selected_file .selected_file
.as_ref() .as_ref()
.and_then(|f| files.iter().position(|x| x == f)) .and_then(|f| files.iter().position(|x| x == f));
.map(|i| i as i64)
.unwrap_or(-1);
let mut new_files_index = let new_files_index =
current_files_index - arrow_up_pressed as i64 + arrow_down_pressed as i64; match (current_files_index, arrow_up_pressed, arrow_down_pressed) {
(Some(i), true, false) => (i + files.len() - 1) % files.len(),
(Some(i), false, true) => (i + 1) % files.len(),
(Some(i), true, true) => i,
(None, true, _) => files.len() - 1,
(None, false, true) => 0,
_ => return,
};
if new_files_index < 0 { self.app_state.selected_file = Some(files[new_files_index].clone());
new_files_index = (files.len() - 1) as i64;
} else if new_files_index >= files.len() as i64 {
new_files_index = 0;
}
self.app_state.selected_file = Some(files[new_files_index as usize].clone());
} }
} }
} }
+8 -2
View File
@@ -58,7 +58,10 @@ impl SoundpadGui {
pub fn play_toggle(&mut self) { pub fn play_toggle(&mut self) {
let (new_state, request) = { let (new_state, request) = {
let guard = self.audio_player_state_shared.lock().unwrap(); let guard = self
.audio_player_state_shared
.lock()
.unwrap_or_else(|e| e.into_inner());
match guard.state { match guard.state {
PlayerState::Playing => (Some(PlayerState::Paused), Some(Request::pause(None))), PlayerState::Playing => (Some(PlayerState::Paused), Some(Request::pause(None))),
PlayerState::Paused => (Some(PlayerState::Playing), Some(Request::resume(None))), PlayerState::Paused => (Some(PlayerState::Playing), Some(Request::resume(None))),
@@ -71,7 +74,10 @@ impl SoundpadGui {
} }
if let Some(state) = new_state { if let Some(state) = new_state {
let mut guard = self.audio_player_state_shared.lock().unwrap(); let mut guard = self
.audio_player_state_shared
.lock()
.unwrap_or_else(|e| e.into_inner());
guard.new_state = Some(state.clone()); guard.new_state = Some(state.clone());
guard.state = state; guard.state = state;
} }
+9 -4
View File
@@ -8,7 +8,7 @@ use pwsp::{
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
impl App for SoundpadGui { impl App for SoundpadGui {
fn update(&mut self, ctx: &Context, _frame: &mut EFrame) { fn logic(&mut self, ctx: &Context, _frame: &mut EFrame) {
// Remove directories // Remove directories
for path in self.app_state.dirs_to_remove.drain() { for path in self.app_state.dirs_to_remove.drain() {
self.app_state.dirs.retain(|x| x != &path); self.app_state.dirs.retain(|x| x != &path);
@@ -77,7 +77,10 @@ impl App for SoundpadGui {
// Sync audio player state // Sync audio player state
{ {
let guard = self.audio_player_state_shared.lock().unwrap(); let guard = self
.audio_player_state_shared
.lock()
.unwrap_or_else(|e| e.into_inner());
self.audio_player_state = guard.clone(); self.audio_player_state = guard.clone();
} }
@@ -94,9 +97,11 @@ impl App for SoundpadGui {
// Handle input // Handle input
self.handle_input(ctx); self.handle_input(ctx);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut EFrame) {
// Draw UI // Draw UI
CentralPanel::default().show(ctx, |ui| { CentralPanel::default().show_inside(ui, |ui| {
if !self.audio_player_state.is_daemon_running { if !self.audio_player_state.is_daemon_running {
self.draw_waiting_for_daemon(ui); self.draw_waiting_for_daemon(ui);
return; return;
@@ -111,6 +116,6 @@ impl App for SoundpadGui {
}); });
// Request repaint // Request repaint
ctx.request_repaint_after_secs(1.0 / 60.0); ui.request_repaint_after_secs(1.0 / 60.0);
} }
} }
+33 -8
View File
@@ -278,12 +278,22 @@ impl AudioPlayer {
file_path: &Path, file_path: &Path,
concurrent: bool, concurrent: bool,
) -> Result<u32, Box<dyn Error>> { ) -> Result<u32, Box<dyn Error>> {
if !file_path.exists() { let path_buf = file_path.to_path_buf();
return Err(format!("File does not exist: {}", file_path.display()).into());
let decoder_result =
tokio::task::spawn_blocking(move || -> Result<_, Box<dyn Error + Send + Sync>> {
if !path_buf.exists() {
return Err(format!("File does not exist: {}", path_buf.display()).into());
} }
let file = fs::File::open(file_path)?; let file = fs::File::open(&path_buf)?;
match Decoder::try_from(file) { let decoder = Decoder::try_from(file)
.map_err(|e| Box::new(e) as Box<dyn Error + Send + Sync>)?;
Ok(decoder)
})
.await?;
match decoder_result {
Ok(source) => { Ok(source) => {
if !concurrent { if !concurrent {
self.tracks.clear(); self.tracks.clear();
@@ -312,7 +322,7 @@ impl AudioPlayer {
Ok(id) Ok(id)
} }
Err(err) => Err(err.into()), Err(err) => Err(err as Box<dyn Error>),
} }
} }
@@ -374,16 +384,31 @@ impl AudioPlayer {
} }
} }
let mut restart_futures = vec![];
for id in restarts { for id in restarts {
if let Some(sound) = self.tracks.get_mut(&id) { if let Some(sound) = self.tracks.get(&id) {
if let Ok(file) = fs::File::open(&sound.path) { let path = sound.path.clone();
let handle = tokio::task::spawn_blocking(move || {
if let Ok(file) = fs::File::open(&path) {
if let Ok(source) = Decoder::try_from(file) { if let Ok(source) = Decoder::try_from(file) {
return Some((id, source));
}
}
None
});
restart_futures.push(handle);
}
}
for handle in restart_futures {
if let Ok(Some((id, source))) = handle.await {
if 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);
+85 -25
View File
@@ -107,7 +107,10 @@ impl Executable for KillCommand {
#[async_trait] #[async_trait]
impl Executable for PauseCommand { impl Executable for PauseCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let mut audio_player = get_audio_player().await.lock().await; let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
audio_player.pause(self.id); audio_player.pause(self.id);
Response::new(true, "Audio was paused") Response::new(true, "Audio was paused")
} }
@@ -116,7 +119,10 @@ impl Executable for PauseCommand {
#[async_trait] #[async_trait]
impl Executable for ResumeCommand { impl Executable for ResumeCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let mut audio_player = get_audio_player().await.lock().await; let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
audio_player.resume(self.id); audio_player.resume(self.id);
Response::new(true, "Audio was resumed") Response::new(true, "Audio was resumed")
} }
@@ -125,7 +131,10 @@ impl Executable for ResumeCommand {
#[async_trait] #[async_trait]
impl Executable for TogglePauseCommand { impl Executable for TogglePauseCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let mut audio_player = get_audio_player().await.lock().await; let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
if audio_player.get_state() == PlayerState::Stopped { if audio_player.get_state() == PlayerState::Stopped {
return Response::new(false, "Audio is not playing"); return Response::new(false, "Audio is not playing");
@@ -163,7 +172,10 @@ impl Executable for TogglePauseCommand {
#[async_trait] #[async_trait]
impl Executable for StopCommand { impl Executable for StopCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let mut audio_player = get_audio_player().await.lock().await; let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
audio_player.stop(self.id); audio_player.stop(self.id);
Response::new(true, "Audio was stopped") Response::new(true, "Audio was stopped")
} }
@@ -172,7 +184,10 @@ impl Executable for StopCommand {
#[async_trait] #[async_trait]
impl Executable for IsPausedCommand { impl Executable for IsPausedCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let audio_player = get_audio_player().await.lock().await; let audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
let is_paused = audio_player.is_paused().to_string(); let is_paused = audio_player.is_paused().to_string();
Response::new(true, is_paused) Response::new(true, is_paused)
} }
@@ -181,7 +196,10 @@ impl Executable for IsPausedCommand {
#[async_trait] #[async_trait]
impl Executable for GetStateCommand { impl Executable for GetStateCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let audio_player = get_audio_player().await.lock().await; let audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
let state = audio_player.get_state(); let state = audio_player.get_state();
match serde_json::to_string(&state) { match serde_json::to_string(&state) {
Ok(json) => Response::new(true, json), Ok(json) => Response::new(true, json),
@@ -193,7 +211,10 @@ impl Executable for GetStateCommand {
#[async_trait] #[async_trait]
impl Executable for GetVolumeCommand { impl Executable for GetVolumeCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let mut audio_player = get_audio_player().await.lock().await; let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
let volume = audio_player.get_volume(self.id); let volume = audio_player.get_volume(self.id);
if let Some(volume) = volume { if let Some(volume) = volume {
@@ -208,7 +229,10 @@ impl Executable for GetVolumeCommand {
impl Executable for SetVolumeCommand { impl Executable for SetVolumeCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
if let Some(volume) = self.volume { if let Some(volume) = self.volume {
let mut audio_player = get_audio_player().await.lock().await; let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
audio_player.set_volume(volume, self.id); audio_player.set_volume(volume, self.id);
Response::new(true, format!("Audio volume was set to {}", volume)) Response::new(true, format!("Audio volume was set to {}", volume))
} else { } else {
@@ -220,7 +244,10 @@ impl Executable for SetVolumeCommand {
#[async_trait] #[async_trait]
impl Executable for GetPositionCommand { impl Executable for GetPositionCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let audio_player = get_audio_player().await.lock().await; let audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
let position = audio_player.get_position(self.id); let position = audio_player.get_position(self.id);
Response::new(true, position.to_string()) Response::new(true, position.to_string())
} }
@@ -230,7 +257,10 @@ impl Executable for GetPositionCommand {
impl Executable for SeekCommand { impl Executable for SeekCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
if let Some(position) = self.position { if let Some(position) = self.position {
let mut audio_player = get_audio_player().await.lock().await; let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
match audio_player.seek(position, self.id) { match audio_player.seek(position, self.id) {
Ok(_) => Response::new(true, format!("Audio position was set to {}", position)), Ok(_) => Response::new(true, format!("Audio position was set to {}", position)),
Err(err) => Response::new(false, err.to_string()), Err(err) => Response::new(false, err.to_string()),
@@ -244,7 +274,10 @@ impl Executable for SeekCommand {
#[async_trait] #[async_trait]
impl Executable for GetDurationCommand { impl Executable for GetDurationCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let mut audio_player = get_audio_player().await.lock().await; let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
match audio_player.get_duration(self.id) { match audio_player.get_duration(self.id) {
Ok(duration) => Response::new(true, duration.to_string()), Ok(duration) => Response::new(true, duration.to_string()),
Err(err) => Response::new(false, err.to_string()), Err(err) => Response::new(false, err.to_string()),
@@ -256,7 +289,10 @@ impl Executable for GetDurationCommand {
impl Executable for PlayCommand { impl Executable for PlayCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
if let Some(file_path) = &self.file_path { if let Some(file_path) = &self.file_path {
let mut audio_player = get_audio_player().await.lock().await; let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
match audio_player match audio_player
.play(file_path, self.concurrent.unwrap_or(false)) .play(file_path, self.concurrent.unwrap_or(false))
.await .await
@@ -273,7 +309,10 @@ impl Executable for PlayCommand {
#[async_trait] #[async_trait]
impl Executable for GetTracksCommand { impl Executable for GetTracksCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let audio_player = get_audio_player().await.lock().await; let audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
let tracks = audio_player.get_tracks(); let tracks = audio_player.get_tracks();
match serde_json::to_string(&tracks) { match serde_json::to_string(&tracks) {
Ok(json) => Response::new(true, json), Ok(json) => Response::new(true, json),
@@ -285,7 +324,10 @@ impl Executable for GetTracksCommand {
#[async_trait] #[async_trait]
impl Executable for GetCurrentInputCommand { impl Executable for GetCurrentInputCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let audio_player = get_audio_player().await.lock().await; let audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
if let Some(input_device_name) = &audio_player.input_device_name { if let Some(input_device_name) = &audio_player.input_device_name {
if let Ok(input_device) = get_device(input_device_name).await { if let Ok(input_device) = get_device(input_device_name).await {
Response::new( Response::new(
@@ -327,7 +369,10 @@ impl Executable for GetAllInputsCommand {
impl Executable for SetCurrentInputCommand { impl Executable for SetCurrentInputCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
if let Some(name) = &self.name { if let Some(name) = &self.name {
let mut audio_player = get_audio_player().await.lock().await; let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
match audio_player.set_current_input_device(name).await { match audio_player.set_current_input_device(name).await {
Ok(_) => Response::new(true, "Input device was set"), Ok(_) => Response::new(true, "Input device was set"),
Err(err) => Response::new(false, err.to_string()), Err(err) => Response::new(false, err.to_string()),
@@ -341,7 +386,10 @@ impl Executable for SetCurrentInputCommand {
#[async_trait] #[async_trait]
impl Executable for SetLoopCommand { impl Executable for SetLoopCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let mut audio_player = get_audio_player().await.lock().await; let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
match self.enabled { match self.enabled {
Some(enabled) => { Some(enabled) => {
@@ -356,7 +404,10 @@ impl Executable for SetLoopCommand {
#[async_trait] #[async_trait]
impl Executable for ToggleLoopCommand { impl Executable for ToggleLoopCommand {
async fn execute(&self) -> Response { async fn execute(&self) -> Response {
let mut audio_player = get_audio_player().await.lock().await; let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
if let Some(id) = self.id { if let Some(id) = self.id {
if let Some(track) = audio_player.tracks.get_mut(&id) { if let Some(track) = audio_player.tracks.get_mut(&id) {
track.looped = !track.looped; track.looped = !track.looped;
@@ -391,21 +442,30 @@ impl Executable for GetFullStateCommand {
let mut all_inputs = HashMap::new(); let mut all_inputs = HashMap::new();
let mut current_input_nick = String::new(); let mut current_input_nick = String::new();
let audio_player = get_audio_player().await.lock().await; let audio_player = match get_audio_player().await {
let current_input_name = audio_player.input_device_name.as_deref(); Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
if let Some(current_input_name) = &audio_player.input_device_name {
for device in input_devices {
if device.name == "pwsp-virtual-mic" {
continue;
}
if device.name == *current_input_name {
current_input_nick = format!("{} - {}", device.name, device.nick);
}
all_inputs.insert(device.name, device.nick);
}
} else {
for device in input_devices { for device in input_devices {
if device.name == "pwsp-virtual-mic" { if device.name == "pwsp-virtual-mic" {
continue; continue;
} }
if let Some(name) = current_input_name {
if device.name == name {
current_input_nick = format!("{} - {}", device.name, device.nick);
}
}
all_inputs.insert(device.name, device.nick); all_inputs.insert(device.name, device.nick);
} }
}
let full_state = FullState { let full_state = FullState {
state: audio_player.get_state(), state: audio_player.get_state(),
+12 -4
View File
@@ -12,11 +12,12 @@ pub struct DaemonConfig {
impl DaemonConfig { 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");
let config_dir = config_path.parent().unwrap();
if let Some(config_dir) = config_path.parent() {
if !config_path.exists() { if !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())?;
@@ -26,7 +27,10 @@ impl DaemonConfig {
pub fn load_from_file() -> Result<DaemonConfig, Box<dyn Error>> { pub fn load_from_file() -> Result<DaemonConfig, Box<dyn Error>> {
let config_path = get_config_path()?.join("daemon.json"); let config_path = get_config_path()?.join("daemon.json");
let bytes = fs::read(config_path)?; let bytes = fs::read(config_path)?;
Ok(serde_json::from_slice::<DaemonConfig>(&bytes)?) match serde_json::from_slice::<DaemonConfig>(&bytes) {
Ok(config) => Ok(config),
Err(_) => Ok(DaemonConfig::default()),
}
} }
} }
@@ -63,11 +67,12 @@ impl Default for GuiConfig {
impl GuiConfig { 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");
let config_dir = config_path.parent().unwrap();
if let Some(config_dir) = config_path.parent() {
if !config_path.exists() { if !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 {
@@ -82,6 +87,9 @@ impl GuiConfig {
pub fn load_from_file() -> Result<GuiConfig, Box<dyn Error>> { pub fn load_from_file() -> Result<GuiConfig, Box<dyn Error>> {
let config_path = get_config_path()?.join("gui.json"); let config_path = get_config_path()?.join("gui.json");
let bytes = fs::read(config_path)?; let bytes = fs::read(config_path)?;
Ok(serde_json::from_slice::<GuiConfig>(&bytes)?) match serde_json::from_slice::<GuiConfig>(&bytes) {
Ok(config) => Ok(config),
Err(_) => Ok(GuiConfig::default()),
}
} }
} }
+1
View File
@@ -55,6 +55,7 @@ pub struct AudioPlayerState {
pub current_input: String, pub current_input: String,
pub all_inputs: HashMap<String, String>, pub all_inputs: HashMap<String, String>,
pub all_inputs_sorted: Vec<(String, String)>,
pub is_daemon_running: bool, pub is_daemon_running: bool,
} }
+8 -3
View File
@@ -6,6 +6,7 @@ use crate::{
}, },
utils::pipewire::{create_link, get_device}, utils::pipewire::{create_link, get_device},
}; };
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf; use std::path::PathBuf;
use std::{error::Error, fs}; use std::{error::Error, fs};
use tokio::{ use tokio::{
@@ -17,11 +18,14 @@ use tokio::{
static AUDIO_PLAYER: OnceCell<Mutex<AudioPlayer>> = OnceCell::const_new(); static AUDIO_PLAYER: OnceCell<Mutex<AudioPlayer>> = OnceCell::const_new();
pub async fn get_audio_player() -> &'static Mutex<AudioPlayer> { pub async fn get_audio_player() -> Result<&'static Mutex<AudioPlayer>, String> {
AUDIO_PLAYER AUDIO_PLAYER
.get_or_init(|| async { .get_or_try_init(|| async {
println!("Initializing audio player"); println!("Initializing audio player");
Mutex::new(AudioPlayer::new().await.unwrap()) match AudioPlayer::new().await {
Ok(player) => Ok(Mutex::new(player)),
Err(err) => Err(err.to_string()),
}
}) })
.await .await
} }
@@ -81,6 +85,7 @@ pub fn create_runtime_dir() -> Result<(), Box<dyn Error>> {
if !runtime_dir.exists() { if !runtime_dir.exists() {
fs::create_dir_all(&runtime_dir)?; fs::create_dir_all(&runtime_dir)?;
} }
fs::set_permissions(&runtime_dir, fs::Permissions::from_mode(0o700))?;
Ok(()) Ok(())
} }
+17 -2
View File
@@ -55,7 +55,9 @@ pub fn start_app_state_thread(audio_player_state_shared: Arc<Mutex<AudioPlayerSt
if !is_running { if !is_running {
{ {
let mut guard = audio_player_state_shared.lock().unwrap(); let mut guard = audio_player_state_shared
.lock()
.unwrap_or_else(|e| e.into_inner());
guard.is_daemon_running = false; guard.is_daemon_running = false;
} }
sleep(Duration::from_millis(500)).await; sleep(Duration::from_millis(500)).await;
@@ -69,7 +71,9 @@ pub fn start_app_state_thread(audio_player_state_shared: Arc<Mutex<AudioPlayerSt
let full_state: FullState = let full_state: FullState =
serde_json::from_str(&full_state_res.message).unwrap_or_default(); serde_json::from_str(&full_state_res.message).unwrap_or_default();
let mut guard = audio_player_state_shared.lock().unwrap(); let mut guard = audio_player_state_shared
.lock()
.unwrap_or_else(|e| e.into_inner());
guard.state = match guard.new_state.clone() { guard.state = match guard.new_state.clone() {
Some(new_state) => { Some(new_state) => {
@@ -86,7 +90,18 @@ pub fn start_app_state_thread(audio_player_state_shared: Arc<Mutex<AudioPlayerSt
.next() .next()
.unwrap_or_default() .unwrap_or_default()
.to_string(); .to_string();
if guard.all_inputs != full_state.all_inputs {
guard.all_inputs = full_state.all_inputs; guard.all_inputs = full_state.all_inputs;
let mut sorted: Vec<(String, String)> = guard
.all_inputs
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
sorted.sort_by(|a, b| a.0.cmp(&b.0));
guard.all_inputs_sorted = sorted;
}
guard.is_daemon_running = true; guard.is_daemon_running = true;
} }
+16 -18
View File
@@ -57,10 +57,11 @@ fn parse_global_object(
}; };
// 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() {
let node_id = props.get("node.id").unwrap().parse::<u32>().unwrap(); if let (Some(node_id), Some(port_id), Some(port_name)) = (
let port_id = props.get("port.id").unwrap().parse::<u32>().unwrap(); props.get("node.id").and_then(|id| id.parse::<u32>().ok()),
let port_name = props.get("port.name").unwrap(); props.get("port.id").and_then(|id| id.parse::<u32>().ok()),
props.get("port.name"),
) {
let port = Port { let port = Port {
node_id, node_id,
port_id, port_id,
@@ -70,6 +71,7 @@ fn parse_global_object(
return (None, Some(port)); return (None, Some(port));
} }
} }
}
(None, None) (None, None)
} }
@@ -151,8 +153,7 @@ pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>), B
for port in ports { for port in ports {
let node_id = port.node_id; let node_id = port.node_id;
if input_devices.contains_key(&node_id) { if let Some(input_device) = input_devices.get_mut(&node_id) {
let input_device = input_devices.get_mut(&node_id).unwrap();
match port.name.as_str() { match port.name.as_str() {
"input_FL" => input_device.input_fl = Some(port), "input_FL" => input_device.input_fl = Some(port),
"input_FR" => input_device.input_fr = Some(port), "input_FR" => input_device.input_fr = Some(port),
@@ -170,8 +171,7 @@ pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>), B
} }
_ => {} _ => {}
} }
} else if output_devices.contains_key(&node_id) { } else if let Some(output_device) = output_devices.get_mut(&node_id) {
let output_device = output_devices.get_mut(&node_id).unwrap();
match port.name.as_str() { match port.name.as_str() {
"input_FL" => output_device.input_fl = Some(port), "input_FL" => output_device.input_fl = Some(port),
"input_FR" => output_device.input_fr = Some(port), "input_FR" => output_device.input_fr = Some(port),
@@ -206,20 +206,18 @@ pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>), B
} }
pub async fn get_device(device_name: &str) -> Result<AudioDevice, Box<dyn Error>> { pub async fn get_device(device_name: &str) -> Result<AudioDevice, Box<dyn Error>> {
let (mut input_devices, output_devices) = get_all_devices().await?; let (input_devices, output_devices) = get_all_devices().await?;
input_devices.extend(output_devices);
for device in input_devices { input_devices
if device.name == device_name .into_iter()
.chain(output_devices)
.find(|device| {
device.name == device_name
|| device.nick == device_name || device.nick == device_name
|| device.name.contains(device_name) || device.name.contains(device_name)
|| device.nick.contains(device_name) || device.nick.contains(device_name)
{ })
return Ok(device); .ok_or_else(|| "Device not found".into())
}
}
Err("Device not found".into())
} }
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>> {
+113
View File
@@ -0,0 +1,113 @@
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();
}