Compare commits

...

22 Commits

Author SHA1 Message Date
arabianq 6c59137639 fix icon 2026-06-01 23:37:46 +03:00
arabianq 3693a678ea new icon 2026-06-01 23:20:11 +03:00
arabianq 5511d23c3e deps: update cargo-sources.json 2026-06-01 23:05:02 +03:00
arabianq 818cd8b50d deps: cargo update 2026-06-01 23:04:42 +03:00
arabianq 6f7d631e28 deps: update rodio 2026-06-01 23:03:21 +03:00
arabianq 18904052c7 change version to 1.10.0 2026-06-01 23:01:32 +03:00
Tarasov Aleksandr 6841d8d1c3 refactor(gui): break down monolithic draw_footer into helper methods (#127)
Split the long continuous block in `draw_footer` into smaller,
modular methods (`draw_mic_selection`, `draw_master_volume`,
`draw_hotkeys_button`, `draw_settings_button`) for better
readability and maintainability.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-06-01 22:56:37 +03:00
Tarasov Aleksandr 105be87222 refactor(daemon): Refactor commands_loop to use handle_connection (#126)
- Extracted the main token processing loop body in `commands_loop` into `handle_connection` to resolve deep nesting and improve code readability.
- Improved request reading logic by using `(&mut stream).take(request_len as u64).read_to_end(&mut buffer)` to strictly bound allocation to `request_len` and prevent initialization overhead.
- Passed `cargo fmt` and `cargo clippy`.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-06-01 22:54:27 +03:00
Tarasov Aleksandr 0f8abbc443 refactor(daemon): Refactor src/utils/pipewire.rs to flatten deep conditionals and consolidate logic. (#124)
Moved redundant struct initialization into `AudioDevice::new` and unified port mapping assignments in an `add_port` method. This removes nesting using early returns and eliminates an unnecessary clone on the hashmap conversion step.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-06-01 22:51:58 +03:00
Tarasov Aleksandr 54011e7ff1 fix(daemon): Replace unwrap with safe Option handling in audio_player (#125)
The `play` method in `src/types/audio_player.rs` previously used `.unwrap()`
directly on `self.stream_handle.as_ref()`. This posed a security/stability risk
where if `stream_handle` was uninitialized or became `None` unexpectedly
despite the prior `ensure_stream()` call, it would cause the thread to panic
and potentially crash the application.

This commit replaces the `.unwrap()` call with `.ok_or_else` to safely handle
the `None` case, returning an `anyhow` error instead of panicking, adhering to
the project's no-panic policy.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-06-01 22:48:08 +03:00
arabianq dac9d53cef deps: update cargo-sources.json 2026-05-28 01:09:33 +03:00
arabianq 9da3799cd3 cargo update 2026-05-28 01:08:52 +03:00
arabianq d66369884c deps: update rodio 2026-05-28 01:08:13 +03:00
Tarasov Aleksandr 5e47e7d6fb feat(gui): support for soundpad:// uri (#123)
* feat(gui): support for soundpad:// uri

* fix: flatpak

* do not open gui when downloading file
2026-05-28 00:58:03 +03:00
Tarasov Aleksandr 695c83c9e6 feat(gui): theme selection (#122)
* fix: increment pkgrel to 2 for pwsp aur package

* feat(gui): implemented theme switching

* fix(gui): fixed incorrect colors in light theme

* fix(gui): fixed incorrect colors in light theme
2026-05-27 18:24:28 +03:00
dependabot[bot] 798a6d1887 chore(deps): bump system-fonts from 0.1.0 to 0.1.1 (#118)
* chore(deps): bump system-fonts from 0.1.0 to 0.1.1

Bumps [system-fonts](https://github.com/yijehyung/system-fonts) from 0.1.0 to 0.1.1.
- [Commits](https://github.com/yijehyung/system-fonts/commits)

---
updated-dependencies:
- dependency-name: system-fonts
  dependency-version: 0.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* deps: update cargo-sources.json

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: arabian <a.tevg@ya.ru>
2026-05-27 18:03:02 +03:00
Ryan Lucas bb18175a30 fix: incorrect install icon name on AUR PKGBUILDs (#117)
Co-authored-by: Ryan Lucas <36653660+maxteer@users.noreply.github.com>
2026-05-25 15:04:01 +03:00
arabianq 6ef3f8d76e deps: update cargo-sources.json 2026-05-22 00:33:13 +03:00
arabianq bec77f59bd change version to 1.9.1 2026-05-22 00:32:45 +03:00
arabianq dad1a62798 deps: cargo update
Removing audio_thread_priority v0.35.1
    Updating cpal v0.18.0 (https://github.com/RustAudio/cpal#2c7acf8e) -> #81b4d659
    Removing dbus v0.6.5
    Updating either v1.15.0 -> v1.16.0
    Removing libdbus-sys v0.2.7
    Removing mach2 v0.4.3
    Updating peniko v0.6.0 -> v0.6.1
    Updating serde_json v1.0.149 -> v1.0.150
    Updating winnow v1.0.2 -> v1.0.3
2026-05-22 00:30:53 +03:00
dependabot[bot] 84a4a01282 chore(deps): bump tokio from 1.52.1 to 1.52.3 (#115)
* chore(deps): bump tokio from 1.52.1 to 1.52.3

Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.52.1 to 1.52.3.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.52.1...tokio-1.52.3)

---
updated-dependencies:
- dependency-name: tokio
  dependency-version: 1.52.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix: replace std::sync::mpsc::sync_channel with tokio::sync::oneshot::channel to avoid deadlocks

* deps: update cargo-sources.json

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: arabian <a.tevg@ya.ru>
2026-05-22 00:28:46 +03:00
dependabot[bot] 88995f6fd1 chore(deps): bump pipewire from 0.9.2 to 0.10.0 (#116)
* chore(deps): bump pipewire from 0.9.2 to 0.10.0

Bumps pipewire from 0.9.2 to 0.10.0.

---
updated-dependencies:
- dependency-name: pipewire
  dependency-version: 0.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* deps: update cargo-sources.json

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: arabian <a.tevg@ya.ru>
2026-05-21 22:36:23 +03:00
27 changed files with 2166 additions and 1042 deletions
Generated
+738 -282
View File
File diff suppressed because it is too large Load Diff
+8 -5
View File
@@ -1,6 +1,6 @@
[package]
name = "pwsp"
version = "1.9.0"
version = "1.10.0"
edition = "2024"
authors = ["arabian"]
description = "PWSP lets you play audio files through your microphone. Has both CLI and GUI clients."
@@ -12,7 +12,7 @@ keywords = ["soundpad", "pipewire", "linux", "cli", "gui"]
[dependencies]
tokio = { version = "1.52.1", features = ["full"] }
tokio = { version = "1.52.3", features = ["full"] }
async-trait = "0.1.89"
serde = { version = "1.0.228", features = ["derive"] }
@@ -36,19 +36,19 @@ rfd = { version = "0.17.2", default-features = false, features = [
] }
opener = { version = "0.8.4", features = ["reveal"] }
system-fonts = "0.1.0"
system-fonts = "0.1.1"
anyhow = "1.0.102"
rustix = { version = "1.1.4", features = ["process"] }
rust-i18n = "4.0.0"
sys-locale = "0.3.2"
rodio = { git = "https://github.com/arabianq/rodio.git", rev = "1a08f281c352622bd82b87b8731585245802d9cf", default-features = false, features = [
rodio = { git = "https://github.com/arabianq/rodio.git", rev = "a634dd471e9d59196e19bf01323fb45f2f899821", default-features = false, features = [
"symphonia-all",
"symphonia-libopus",
"playback",
] }
pipewire = "0.9.2"
pipewire = "0.10.0"
egui = { version = "0.34.2", default-features = false, features = [
"default_fonts",
@@ -64,6 +64,9 @@ egui_extras = "0.34.1"
egui_material_icons = "0.6.0"
egui_dnd = "0.15.0"
reqwest = "0.13.4"
percent-encoding = "2.3.2"
[[bin]]
name = "pwsp-daemon"
path = "src/bin/daemon.rs"
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 79 KiB

+3 -2
View File
@@ -1,8 +1,9 @@
[Desktop Entry]
Name=PWSP (Soundpad)
Comment=Let's you play audio files through you microphone
Exec=pwsp-gui %u
Exec=/usr/bin/pwsp-gui %u
Icon=pwsp
Terminal=false
Type=Application
Categories=Audio
Categories=Audio
MimeType=x-scheme-handler/soundpad;
+44
View File
@@ -195,6 +195,50 @@ kz = "GUI нұсқасы: %{version}"
he = "גרסת ממשק משתמש: %{version}"
pt-BR = "Versão da GUI: %{version}"
[gui.settings.theme.label]
en = "Color Scheme"
ru = "Цветовая схема"
es = "Esquema de color"
fr = "Schéma de couleurs"
zh = "配色方案"
ar = "نظام الألوان"
kz = "Түс схемасы"
he = "ערכת צבעים"
pt-BR = "Esquema de cores"
[gui.settings.theme.system]
en = "System"
ru = "Системная"
es = "Sistema"
fr = "Système"
zh = "系统"
ar = "النظام"
kz = "Жүйе"
he = "מערכת"
pt-BR = "Sistema"
[gui.settings.theme.light]
en = "Light"
ru = "Светлая"
es = "Claro"
fr = "Clair"
zh = "浅色"
ar = "فاتح"
kz = "Жарық"
he = "בהיר"
pt-BR = "Claro"
[gui.settings.theme.dark]
en = "Dark"
ru = "Тёмная"
es = "Oscuro"
fr = "Sombre"
zh = "暗色"
ar = "داكن"
kz = "Қараңғы"
he = "כהה"
pt-BR = "Escuro"
# ----------------
# Hotkeys
# ----------------
+3 -3
View File
@@ -1,6 +1,6 @@
pkgbase = pwsp-bin
pkgdesc = Lets you play audio files through your microphone (Pre-built binaries)
pkgver = 1.9.0
pkgver = 1.10.0
pkgrel = 1
url = https://github.com/arabianq/pipewire-soundpad
arch = x86_64
@@ -9,8 +9,8 @@ depends = pipewire
depends = alsa-lib
provides = pwsp
conflicts = pwsp
source = pwsp-bin-1.9.0.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.9.0/pwsp-v1.9.0-linux-x64.zip
source = pipewire-soundpad-1.9.0.tar.gz :: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.9.0.tar.gz
source = pwsp-bin-1.10.0.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.10.0/pwsp-v1.10.0-linux-x64.zip
source = pipewire-soundpad-1.10.0.tar.gz :: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.10.0.tar.gz
sha256sums = SKIP
sha256sums = SKIP
+2 -2
View File
@@ -1,7 +1,7 @@
# Maintainer: Alexander Tarasov <a.tevg@ya.ru>
pkgname=pwsp-bin
_pkgname=pipewire-soundpad
pkgver=1.9.0
pkgver=1.10.0
pkgrel=1
pkgdesc="Lets you play audio files through your microphone (Pre-built binaries)"
arch=('x86_64')
@@ -25,7 +25,7 @@ package() {
install -Dm755 "${srcdir}/pwsp-gui" "${pkgdir}/usr/bin/pwsp-gui"
install -Dm644 "$_srcsrc/assets/pwsp-gui.desktop" "${pkgdir}/usr/share/applications/pwsp-gui.desktop"
install -Dm644 "$_srcsrc/assets/icon.png" "${pkgdir}/usr/share/icons/hicolor/256x256/apps/icon.png"
install -Dm644 "$_srcsrc/assets/icon.png" "${pkgdir}/usr/share/icons/hicolor/256x256/apps/pwsp.png"
install -Dm644 "$_srcsrc/assets/pwsp-daemon.service" "${pkgdir}/usr/lib/systemd/user/pwsp-daemon.service"
install -Dm644 "$_srcsrc/LICENSE" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
+2 -2
View File
@@ -1,6 +1,6 @@
pkgbase = pwsp
pkgdesc = Lets you play audio files through your microphone
pkgver = 1.9.0
pkgver = 1.10.0
pkgrel = 1
url = https://github.com/arabianq/pipewire-soundpad
arch = any
@@ -11,7 +11,7 @@ pkgbase = pwsp
makedepends = cmake
makedepends = pipewire
makedepends = alsa-lib
source = https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.9.0.tar.gz
source = https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.10.0.tar.gz
sha256sums = SKIP
pkgname = pwsp
+2 -2
View File
@@ -1,7 +1,7 @@
# Maintainer: Alexander Tarasov <a.tevg@ya.ru>
pkgsubn=pwsp
pkgname=pwsp
pkgver=1.9.0
pkgver=1.10.0
pkgrel=1
pkgdesc="Lets you play audio files through your microphone"
arch=('any')
@@ -41,7 +41,7 @@ package() {
install -Dm755 "target/release/pwsp-gui" "${pkgdir}/usr/bin/pwsp-gui"
install -Dm644 "assets/pwsp-gui.desktop" "${pkgdir}/usr/share/applications/pwsp-gui.desktop"
install -Dm644 "assets/icon.png" "${pkgdir}/usr/share/icons/hicolor/256x256/apps/icon.png"
install -Dm644 "assets/icon.png" "${pkgdir}/usr/share/icons/hicolor/256x256/apps/pwsp.png"
install -Dm644 "assets/pwsp-daemon.service" "${pkgdir}/usr/lib/systemd/user/pwsp-daemon.service"
}
File diff suppressed because one or more lines are too long
+11 -6
View File
@@ -2,21 +2,26 @@
import argparse
import subprocess
import sys
if __name__ == "__main__":
if len(sys.argv) == 2 and sys.argv[1].startswith("soundpad://"):
subprocess.Popen(["pwsp-gui", sys.argv[1]])
sys.exit(0)
parser = argparse.ArgumentParser(
prog="PWSP Flatpak",
add_help=True,
exit_on_error=True
prog="PWSP Flatpak", add_help=True, exit_on_error=True
)
subparsers = parser.add_subparsers(dest="command")
cli_parser = subparsers.add_parser("cli", add_help=False, prefix_chars=" ")
cli_parser.add_argument("args", nargs=argparse.REMAINDER, help="Arguments for pwsp-cli")
cli_parser.add_argument(
"args", nargs=argparse.REMAINDER, help="Arguments for pwsp-cli"
)
daemon_parser = subparsers.add_parser("daemon", add_help=True)
daemon_group = daemon_parser.add_mutually_exclusive_group(required=True)
daemon_group.add_argument("--start", action="store_true", help="Start pwps-daemon")
daemon_group.add_argument("--start", action="store_true", help="Start pwsp-daemon")
daemon_group.add_argument("--kill", action="store_true", help="Kill pwsp-daemon")
args = parser.parse_args()
@@ -32,4 +37,4 @@ if __name__ == "__main__":
if args.start:
subprocess.Popen("pwsp-daemon")
elif args.kill:
subprocess.Popen(["pwsp-cli", "action", "kill"])
subprocess.Popen(["pwsp-cli", "action", "kill"])
@@ -7,3 +7,4 @@ Terminal=false
Type=Application
Categories=AudioVideo;Audio;
Keywords=soundpad;pipewire;audio;
MimeType=x-scheme-handler/soundpad;
@@ -25,7 +25,7 @@
<name>arabian</name>
</developer>
<releases>
<release version="1.9.0" date="2026-05-15" />
<release version="1.10.0" date="2026-06-01" />
</releases>
<content_rating type="oars-1.1" />
</component>
+1 -1
View File
@@ -4,7 +4,7 @@
%global cargo_install_lib 0
Name: pwsp
Version: 1.9.0
Version: 1.10.0
Release: %autorelease
Summary: Lets you play audio files through your microphone
+74 -65
View File
@@ -15,7 +15,7 @@ use std::os::unix::fs::PermissionsExt;
use std::{fs, time::Duration};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::UnixListener,
net::{UnixListener, UnixStream},
time::sleep,
};
@@ -83,83 +83,92 @@ async fn main() -> Result<()> {
async fn commands_loop(listener: UnixListener) -> Result<()> {
loop {
let (mut stream, _addr) = listener.accept().await?;
let (stream, _addr) = listener.accept().await?;
tokio::spawn(async move {
// ---------- Read request (start) ----------
let mut len_bytes = [0u8; 4];
if stream.read_exact(&mut len_bytes).await.is_err() {
eprintln!("Failed to read message length from client!");
return;
}
handle_connection(stream).await;
});
}
}
let request_len = u32::from_le_bytes(len_bytes) as usize;
async fn handle_connection(mut stream: UnixStream) {
// ---------- Read request (start) ----------
let mut len_bytes = [0u8; 4];
if stream.read_exact(&mut len_bytes).await.is_err() {
eprintln!("Failed to read message length from client!");
return;
}
if request_len > MAX_MESSAGE_SIZE {
eprintln!(
"Failed to read message from client: request too large ({} bytes)!",
request_len
);
return;
}
let request_len = u32::from_le_bytes(len_bytes) as usize;
let mut buffer = vec![0u8; request_len];
if stream.read_exact(&mut buffer).await.is_err() {
eprintln!("Failed to read message from client!");
return;
}
if request_len > MAX_MESSAGE_SIZE {
eprintln!(
"Failed to read message from client: request too large ({} bytes)!",
request_len
);
return;
}
let request: Request = match serde_json::from_slice(&buffer) {
Ok(req) => req,
Err(err) => {
let response =
Response::new(false, format!("Failed to parse request: {}", err));
let response_data = match serde_json::to_vec(&response) {
Ok(data) => data,
Err(_) => return, // Should not happen with this simple Response
};
let response_len = response_data.len() as u32;
let _ = stream.write_all(&response_len.to_le_bytes()).await;
let _ = stream.write_all(&response_data).await;
return;
}
};
// ---------- Read request (end) ----------
let mut buffer = Vec::new();
if (&mut stream)
.take(request_len as u64)
.read_to_end(&mut buffer)
.await
.is_err()
|| buffer.len() != request_len
{
eprintln!("Failed to read message from client!");
return;
}
// ---------- Generate response (start) ----------
let command = parse_command(&request);
let response: Response;
if let Some(command) = command {
response = command.execute().await;
} else {
response = Response::new(false, "Unknown command");
}
// ---------- Generate response (end) ----------
// ---------- Send response (start) ----------
let request: Request = match serde_json::from_slice(&buffer) {
Ok(req) => req,
Err(err) => {
let response = Response::new(false, format!("Failed to parse request: {}", err));
let response_data = match serde_json::to_vec(&response) {
Ok(data) => data,
Err(err) => {
eprintln!("Failed to serialize response: {}", err);
return;
}
Err(_) => return, // Should not happen with this simple Response
};
let response_len = response_data.len() as u32;
let _ = stream.write_all(&response_len.to_le_bytes()).await;
let _ = stream.write_all(&response_data).await;
return;
}
};
// ---------- Read request (end) ----------
if stream.write_all(&response_len.to_le_bytes()).await.is_err() {
eprintln!("Failed to write response length to client!");
return;
}
if stream.write_all(&response_data).await.is_err() {
eprintln!("Failed to write response to client!");
return;
}
// ---------- Send response (end) ----------
// ---------- Generate response (start) ----------
let command = parse_command(&request);
let response: Response;
if let Some(command) = command {
response = command.execute().await;
} else {
response = Response::new(false, "Unknown command");
}
// ---------- Generate response (end) ----------
if response.status && response.message.eq("killed") {
std::process::exit(0);
}
});
// ---------- Send response (start) ----------
let response_data = match serde_json::to_vec(&response) {
Ok(data) => data,
Err(err) => {
eprintln!("Failed to serialize response: {}", err);
return;
}
};
let response_len = response_data.len() as u32;
if stream.write_all(&response_len.to_le_bytes()).await.is_err() {
eprintln!("Failed to write response length to client!");
return;
}
if stream.write_all(&response_data).await.is_err() {
eprintln!("Failed to write response to client!");
return;
}
// ---------- Send response (end) ----------
if response.status && response.message.eq("killed") {
std::process::exit(0);
}
}
+19 -2
View File
@@ -1,14 +1,31 @@
use crate::gui::SoundpadGui;
use eframe::{App, Frame as EFrame};
use egui::{CentralPanel, Context};
use egui::{CentralPanel, Context, ThemePreference};
use pwsp::{
types::socket::Request,
types::{config::PreferredTheme, socket::Request},
utils::{daemon::get_daemon_config, gui::make_request_async},
};
use std::time::{Duration, Instant};
impl App for SoundpadGui {
fn logic(&mut self, ctx: &Context, _frame: &mut EFrame) {
// Update theme
let current_theme = match ctx.options(|r| r.theme_preference) {
ThemePreference::System => PreferredTheme::System,
ThemePreference::Light => PreferredTheme::Light,
ThemePreference::Dark => PreferredTheme::Dark,
};
if !self.config.preferred_theme.eq(&current_theme) {
ctx.options_mut(|w| {
w.theme_preference = match self.config.preferred_theme {
PreferredTheme::System => ThemePreference::System,
PreferredTheme::Light => ThemePreference::Light,
PreferredTheme::Dark => ThemePreference::Dark,
}
})
}
// Remove directories
for path in self.app_state.dirs_to_remove.drain() {
self.app_state.dirs.retain(|x| x != &path);
+5 -5
View File
@@ -76,16 +76,16 @@ impl SoundpadGui {
.map(|s| s.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 =
Button::new(RichText::new(name.clone()).atom_max_width(area_size.x))
.frame(false);
if let Some(current_dir) = &self.app_state.current_dir
&& current_dir.eq(&*path)
{
dir_button_text = dir_button_text.color(Color32::WHITE);
dir_button = dir_button.selected(true);
}
let dir_button =
Button::new(dir_button_text.atom_max_width(area_size.x)).frame(false);
let dir_button_response = ui.add(dir_button);
if dir_button_response.clicked() {
dir_to_open = Some(path.clone());
+76 -70
View File
@@ -8,79 +8,85 @@ impl SoundpadGui {
pub fn draw_footer(&mut self, ui: &mut Ui) {
ui.add_space(5.0);
ui.horizontal(|ui| {
// ---------- Microphone selection ----------
let mics = &self.audio_player_state.all_inputs_sorted;
let mut selected_input = self.audio_player_state.current_input.to_owned();
let prev_input = selected_input.to_owned();
ComboBox::from_label(t!("gui.choose_mic_select"))
.height(30.0)
.selected_text(
self.audio_player_state
.all_inputs
.get(&selected_input)
.unwrap_or(&String::new()),
)
.show_ui(ui, |ui| {
for (name, nick) in mics {
ui.selectable_value(&mut selected_input, name.clone(), nick);
}
});
if selected_input != prev_input {
self.set_input(selected_input);
}
// --------------------------------
// ---------- Master Volume Slider ----------
let volume_icon = Self::get_volume_icon(self.audio_player_state.volume);
let volume_label = Label::new(RichText::new(volume_icon).size(18.0));
ui.add_sized([18.0, 18.0], volume_label)
.on_hover_text(format!(
"Master Volume: {:.0}%",
self.audio_player_state.volume * 100.0
));
let should_update_volume = !self.app_state.volume_dragged
&& self
.app_state
.ignore_volume_update_until
.map(|t| Instant::now() > t)
.unwrap_or(true);
if should_update_volume {
self.app_state.volume_slider_value = self.audio_player_state.volume;
}
let volume_slider = Slider::new(&mut self.app_state.volume_slider_value, 0.0..=1.0)
.show_value(false)
.step_by(0.01);
let volume_slider_response = ui.add_sized([150.0, 18.0], volume_slider);
if volume_slider_response.drag_stopped() {
self.app_state.volume_dragged = true;
}
// ------------------------------------------
self.draw_mic_selection(ui);
self.draw_master_volume(ui);
ui.add_space(ui.available_width() - 18.0 * 2.0 - ui.spacing().item_spacing.x * 2.0);
// ---------- Hotkeys button ----------
let hotkeys_button =
Button::new(ICON_KEYBOARD.atom_size(Vec2::new(18.0, 18.0))).frame(false);
let hotkeys_button_response = ui.add_sized([18.0, 18.0], hotkeys_button);
if hotkeys_button_response.clicked() {
self.app_state.show_hotkeys = true;
}
hotkeys_button_response.on_hover_text("Hotkeys (H)");
// --------------------------------
// ---------- Settings button ----------
let settings_button =
Button::new(ICON_SETTINGS.atom_size(Vec2::new(18.0, 18.0))).frame(false);
let settings_button_response = ui.add_sized([18.0, 18.0], settings_button);
if settings_button_response.clicked() {
self.app_state.show_settings = true;
}
// --------------------------------
self.draw_hotkeys_button(ui);
self.draw_settings_button(ui);
});
}
fn draw_mic_selection(&mut self, ui: &mut Ui) {
let mics = &self.audio_player_state.all_inputs_sorted;
let mut selected_input = self.audio_player_state.current_input.to_owned();
let prev_input = selected_input.to_owned();
ComboBox::from_label(t!("gui.choose_mic_select"))
.height(30.0)
.selected_text(
self.audio_player_state
.all_inputs
.get(&selected_input)
.unwrap_or(&String::new()),
)
.show_ui(ui, |ui| {
for (name, nick) in mics {
ui.selectable_value(&mut selected_input, name.clone(), nick);
}
});
if selected_input != prev_input {
self.set_input(selected_input);
}
}
fn draw_master_volume(&mut self, ui: &mut Ui) {
let volume_icon = Self::get_volume_icon(self.audio_player_state.volume);
let volume_label = Label::new(RichText::new(volume_icon).size(18.0));
ui.add_sized([18.0, 18.0], volume_label)
.on_hover_text(format!(
"Master Volume: {:.0}%",
self.audio_player_state.volume * 100.0
));
let should_update_volume = !self.app_state.volume_dragged
&& self
.app_state
.ignore_volume_update_until
.map(|t| Instant::now() > t)
.unwrap_or(true);
if should_update_volume {
self.app_state.volume_slider_value = self.audio_player_state.volume;
}
let volume_slider = Slider::new(&mut self.app_state.volume_slider_value, 0.0..=1.0)
.show_value(false)
.step_by(0.01);
let volume_slider_response = ui.add_sized([150.0, 18.0], volume_slider);
if volume_slider_response.drag_stopped() {
self.app_state.volume_dragged = true;
}
}
fn draw_hotkeys_button(&mut self, ui: &mut Ui) {
let hotkeys_button =
Button::new(ICON_KEYBOARD.atom_size(Vec2::new(18.0, 18.0))).frame(false);
let hotkeys_button_response = ui.add_sized([18.0, 18.0], hotkeys_button);
if hotkeys_button_response.clicked() {
self.app_state.show_hotkeys = true;
}
hotkeys_button_response.on_hover_text("Hotkeys (H)");
}
fn draw_settings_button(&mut self, ui: &mut Ui) {
let settings_button =
Button::new(ICON_SETTINGS.atom_size(Vec2::new(18.0, 18.0))).frame(false);
let settings_button_response = ui.add_sized([18.0, 18.0], settings_button);
if settings_button_response.clicked() {
self.app_state.show_settings = true;
}
}
}
+1 -2
View File
@@ -1,5 +1,5 @@
use crate::gui::SoundpadGui;
use egui::{Button, CollapsingHeader, Color32, FontFamily, Label, RichText, Slider, Ui};
use egui::{Button, CollapsingHeader, FontFamily, Label, RichText, Slider, Ui};
use egui_material_icons::icons::*;
use pwsp::types::{audio_player::TrackInfo, gui::AppState};
use pwsp::utils::gui::format_time_pair;
@@ -32,7 +32,6 @@ impl SoundpadGui {
.to_str()
.unwrap_or_default(),
)
.color(Color32::WHITE)
.family(FontFamily::Monospace),
)
.default_open(true)
+5 -12
View File
@@ -146,32 +146,28 @@ impl SoundpadGui {
ui.label(
RichText::new(t!("gui.hotkeys.column_slot"))
.strong()
.monospace()
.color(Color32::LIGHT_GRAY),
.monospace(),
);
});
header.col(|ui| {
ui.label(
RichText::new(t!("gui.hotkeys.column_sound"))
.strong()
.monospace()
.color(Color32::LIGHT_GRAY),
.monospace(),
);
});
header.col(|ui| {
ui.label(
RichText::new(t!("gui.hotkeys.column_key_chord"))
.strong()
.monospace()
.color(Color32::LIGHT_GRAY),
.monospace(),
);
});
header.col(|ui| {
ui.label(
RichText::new(t!("gui.hotkeys.column_actions"))
.strong()
.monospace()
.color(Color32::LIGHT_GRAY),
.monospace(),
);
});
})
@@ -180,10 +176,7 @@ impl SoundpadGui {
body.row(30.0, |mut row| {
row.col(|_| {});
row.col(|ui| {
ui.label(
RichText::new(t!("gui.hotkeys.no_hotkeys_configured"))
.color(Color32::GRAY),
);
ui.label(RichText::new(t!("gui.hotkeys.no_hotkeys_configured")));
});
row.col(|_| {});
row.col(|_| {});
+36 -1
View File
@@ -1,6 +1,7 @@
use crate::gui::SoundpadGui;
use egui::{Align, Button, Color32, Layout, RichText, Ui};
use egui::{Align, Button, Color32, ComboBox, Layout, RichText, Ui};
use egui_material_icons::icons::ICON_ARROW_BACK;
use pwsp::types::config::PreferredTheme;
use rust_i18n::t;
impl SoundpadGui {
@@ -53,6 +54,40 @@ impl SoundpadGui {
}
// --------------------------------
ui.separator();
// ---------- Selectors -----------
let mut selected_theme = self.config.preferred_theme.clone();
ComboBox::from_label(t!("gui.settings.theme.label"))
.selected_text(match self.config.preferred_theme {
PreferredTheme::System => t!("gui.settings.theme.system"),
PreferredTheme::Light => t!("gui.settings.theme.light"),
PreferredTheme::Dark => t!("gui.settings.theme.dark"),
})
.show_ui(ui, |ui| {
ui.selectable_value(
&mut selected_theme,
PreferredTheme::System,
t!("gui.settings.theme.system"),
);
ui.selectable_value(
&mut selected_theme,
PreferredTheme::Light,
t!("gui.settings.theme.light"),
);
ui.selectable_value(
&mut selected_theme,
PreferredTheme::Dark,
t!("gui.settings.theme.dark"),
);
});
if selected_theme != self.config.preferred_theme {
self.config.preferred_theme = selected_theme;
self.config.save_to_file().ok();
}
// --------------------------------
ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
ui.label(t!(
"gui.settings.version",
+48 -2
View File
@@ -1,7 +1,9 @@
mod gui;
use anyhow::Result;
use anyhow::{Context, Result};
use pwsp::utils::gui::ensure_pwsp_audio_dir;
use rust_i18n::i18n;
use std::{env, path::PathBuf};
i18n!("locales", fallback = "en");
@@ -10,5 +12,49 @@ async fn main() -> Result<()> {
let locale = sys_locale::get_locale().unwrap_or(String::from("en-US"));
rust_i18n::set_locale(&locale);
gui::run().await
let args = env::args().skip(1).collect::<Vec<String>>();
if let Some(uri) = args.first() {
match download_audio_from_url(uri).await {
Ok(path) => println!("Successfully downloaded to: {:?}", path),
Err(e) => eprintln!("Error downloading file: {}", e),
}
} else {
gui::run().await?;
}
Ok(())
}
async fn download_audio_from_url(uri: &str) -> Result<PathBuf> {
let prefix = "soundpad://sound/url/";
let target_url = uri
.strip_prefix(prefix)
.ok_or_else(|| anyhow::anyhow!("URI does not containt an expected prefix: {}", prefix))?;
let file_name_encoded = target_url
.split('/')
.next_back()
.unwrap_or("downloaded_audio.mp3");
let file_name = percent_encoding::percent_decode_str(file_name_encoded)
.decode_utf8()
.unwrap_or_else(|_| file_name_encoded.into())
.into_owned();
let save_path = ensure_pwsp_audio_dir().join(file_name);
let response = reqwest::get(target_url)
.await?
.error_for_status()
.context("Failed to fetch file")?;
let bytes = response.bytes().await?;
tokio::fs::write(&save_path, bytes)
.await
.context("Failed to save file to disk")?;
Ok(save_path)
}
+5 -1
View File
@@ -349,7 +349,11 @@ impl AudioPlayer {
let duration = source.total_duration().map(|d| d.as_secs_f32());
let mixer = self.stream_handle.as_ref().unwrap().mixer();
let mixer = self
.stream_handle
.as_ref()
.ok_or_else(|| anyhow::anyhow!("stream_handle is unexpectedly missing"))?
.mixer();
let sink = Player::connect_new(mixer);
sink.set_volume(self.volume); // Default volume is 1.0 * master
sink.append(source);
+16 -2
View File
@@ -1,4 +1,7 @@
use crate::{types::socket::Request, utils::config::get_config_path};
use crate::{
types::socket::Request,
utils::{config::get_config_path, gui::ensure_pwsp_audio_dir},
};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, fs, path::PathBuf};
@@ -35,6 +38,13 @@ impl DaemonConfig {
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub enum PreferredTheme {
System,
Light,
Dark,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct GuiConfig {
@@ -47,6 +57,8 @@ pub struct GuiConfig {
pub pause_on_exit: bool,
pub dirs: Vec<PathBuf>,
pub preferred_theme: PreferredTheme,
}
impl Default for GuiConfig {
@@ -60,7 +72,9 @@ impl Default for GuiConfig {
save_scale_factor: false,
pause_on_exit: false,
dirs: vec![],
dirs: vec![ensure_pwsp_audio_dir()],
preferred_theme: PreferredTheme::System,
}
}
}
+43
View File
@@ -29,3 +29,46 @@ pub struct AudioDevice {
pub output_fl: Option<Port>,
pub output_fr: Option<Port>,
}
impl AudioDevice {
pub fn new(
id: u32,
nick: Option<&str>,
description: Option<&str>,
name: Option<&str>,
device_type: DeviceType,
) -> Self {
Self {
id,
nick: nick
.or(description)
.or(name)
.unwrap_or_default()
.to_string(),
name: name.unwrap_or_default().to_string(),
device_type,
input_fl: None,
input_fr: None,
output_fl: None,
output_fr: None,
}
}
pub fn add_port(&mut self, port: Port) {
match port.name.as_str() {
"input_FL" => self.input_fl = Some(port),
"input_FR" => self.input_fr = Some(port),
"output_FL" | "capture_FL" => self.output_fl = Some(port),
"output_FR" | "capture_FR" => self.output_fr = Some(port),
"input_MONO" => {
self.input_fl = Some(port.clone());
self.input_fr = Some(port);
}
"output_MONO" | "capture_MONO" => {
self.output_fl = Some(port.clone());
self.output_fr = Some(port);
}
_ => {}
}
}
}
+12
View File
@@ -9,6 +9,7 @@ use crate::{
};
use anyhow::{Result, anyhow};
use std::{
path::PathBuf,
sync::{Arc, Mutex},
time::Instant,
};
@@ -36,6 +37,17 @@ pub fn make_request_async(request: Request) {
});
}
pub fn ensure_pwsp_audio_dir() -> PathBuf {
let audio_dir = dirs::audio_dir().unwrap_or("~/Music".into());
let pwsp_audio_dir = audio_dir.join("PWSP");
if !pwsp_audio_dir.exists() {
std::fs::create_dir_all(&pwsp_audio_dir).ok();
}
pwsp_audio_dir
}
pub fn format_time_pair(position: f32, duration: f32) -> String {
fn format_time(seconds: f32) -> String {
let total_seconds = seconds.round() as u32;
+50 -95
View File
@@ -20,73 +20,61 @@ pub fn setup_pipewire_context() -> Result<(MainLoopRc, ContextRc), String> {
fn parse_global_object(
global_object: &GlobalObject<&DictRef>,
) -> (Option<AudioDevice>, Option<Port>) {
// Only objects with props can be devices/ports
if let Some(props) = global_object.props {
// Only objects with media.class can be devices
if let Some(media_class) = props.get("media.class") {
let node_id = global_object.id;
let node_nick = props.get("node.nick");
let node_name = props.get("node.name");
let node_description = props.get("node.description");
let props = match global_object.props {
Some(p) => p,
None => return (None, None),
};
// Check if the device is an input or output
return if media_class.starts_with("Audio/Source") {
let input_device = AudioDevice {
id: node_id,
nick: node_nick
.unwrap_or(node_description.unwrap_or(node_name.unwrap_or_default()))
.to_string(),
name: node_name.unwrap_or_default().to_string(),
device_type: DeviceType::Input,
if let Some(media_class) = props.get("media.class") {
let node_id = global_object.id;
let node_nick = props.get("node.nick");
let node_name = props.get("node.name");
let node_description = props.get("node.description");
input_fl: None,
input_fr: None,
output_fl: None,
output_fr: None,
};
(Some(input_device), None)
} else if media_class.starts_with("Stream/Output/Audio") {
let output_device = AudioDevice {
id: node_id,
nick: node_nick
.unwrap_or(node_description.unwrap_or(node_name.unwrap_or_default()))
.to_string(),
name: node_name.unwrap_or_default().to_string(),
device_type: DeviceType::Output,
input_fl: None,
input_fr: None,
output_fl: None,
output_fr: None,
};
(Some(output_device), None)
} else {
(None, None)
};
// Check if the object is a port
} else if props.get("port.direction").is_some()
&& let (Some(node_id), Some(port_id), Some(port_name)) = (
props.get("node.id").and_then(|id| id.parse::<u32>().ok()),
props.get("port.id").and_then(|id| id.parse::<u32>().ok()),
props.get("port.name"),
)
{
let port = Port {
if media_class.starts_with("Audio/Source") {
let input_device = AudioDevice::new(
node_id,
port_id,
name: port_name.to_string(),
};
return (None, Some(port));
node_nick,
node_description,
node_name,
DeviceType::Input,
);
return (Some(input_device), None);
} else if media_class.starts_with("Stream/Output/Audio") {
let output_device = AudioDevice::new(
node_id,
node_nick,
node_description,
node_name,
DeviceType::Output,
);
return (Some(output_device), None);
}
return (None, None);
}
if props.get("port.direction").is_some()
&& let (Some(node_id), Some(port_id), Some(port_name)) = (
props.get("node.id").and_then(|id| id.parse::<u32>().ok()),
props.get("port.id").and_then(|id| id.parse::<u32>().ok()),
props.get("port.name"),
)
{
let port = Port {
node_id,
port_id,
name: port_name.to_string(),
};
return (None, Some(port));
}
(None, None)
}
async fn pw_get_global_objects_thread(
main_sender: mpsc::Sender<(Option<AudioDevice>, Option<Port>)>,
pw_receiver: pipewire::channel::Receiver<Terminate>,
init_sender: std::sync::mpsc::SyncSender<Result<(), String>>,
init_sender: tokio::sync::oneshot::Sender<Result<(), String>>,
) {
let (main_loop, context) = match setup_pipewire_context() {
Ok(res) => res,
@@ -147,7 +135,7 @@ pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
// Channels to communicate with pipewire thread
let (main_sender, mut main_receiver) = mpsc::channel(10);
let (pw_sender, pw_receiver) = pipewire::channel::channel();
let (init_sender, init_receiver) = std::sync::mpsc::sync_channel(0);
let (init_sender, init_receiver) = tokio::sync::oneshot::channel();
// Spawn pipewire thread in background
let _pw_thread = tokio::spawn(async move {
@@ -155,7 +143,7 @@ pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
});
// Wait for initialization to complete
if let Err(e) = init_receiver.recv()? {
if let Err(e) = init_receiver.await {
return Err(anyhow!(e));
}
@@ -188,47 +176,14 @@ pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
let node_id = port.node_id;
if let Some(input_device) = input_devices.get_mut(&node_id) {
match port.name.as_str() {
"input_FL" => input_device.input_fl = Some(port),
"input_FR" => input_device.input_fr = Some(port),
"output_FL" => input_device.output_fl = Some(port),
"output_FR" => input_device.output_fr = Some(port),
"capture_FL" => input_device.output_fl = Some(port),
"capture_FR" => input_device.output_fr = Some(port),
"input_MONO" => {
input_device.input_fl = Some(port.clone());
input_device.input_fr = Some(port)
}
"capture_MONO" => {
input_device.output_fl = Some(port.clone());
input_device.output_fr = Some(port);
}
_ => {}
}
input_device.add_port(port);
} else if let Some(output_device) = output_devices.get_mut(&node_id) {
match port.name.as_str() {
"input_FL" => output_device.input_fl = Some(port),
"input_FR" => output_device.input_fr = Some(port),
"output_FL" => output_device.output_fl = Some(port),
"output_FR" => output_device.output_fr = Some(port),
"capture_FL" => output_device.output_fl = Some(port),
"capture_FR" => output_device.output_fr = Some(port),
"output_MONO" => {
output_device.output_fl = Some(port.clone());
output_device.output_fr = Some(port)
}
"capture_MONO" => {
output_device.output_fl = Some(port.clone());
output_device.output_fr = Some(port)
}
_ => {}
}
output_device.add_port(port);
}
}
let mut input_devices: Vec<AudioDevice> = input_devices.values().cloned().collect();
let mut output_devices: Vec<AudioDevice> =
output_devices.values().cloned().collect();
let mut input_devices: Vec<AudioDevice> = input_devices.into_values().collect();
let mut output_devices: Vec<AudioDevice> = output_devices.into_values().collect();
input_devices.sort_by_key(|a| a.id);
output_devices.sort_by_key(|a| a.id);