Compare commits

...

20 Commits

Author SHA1 Message Date
arabianq 330c3d79d4 cargo update 2026-01-28 03:40:43 +03:00
arabianq dff20daace deps: bump clap to 4.5.55 2026-01-28 03:39:39 +03:00
arabianq cdc44328a8 docs: update README to include new features for collapsible audio tracks, drag and drop directories, and automatic device detection 2026-01-28 03:38:22 +03:00
arabianq ac61a71dcb feat: bump version to 1.5.0 2026-01-28 03:35:04 +03:00
arabianq 71c800c396 docs(assets): update screenshot image 2026-01-28 03:34:32 +03:00
arabianq 577a6d279b fix(daemon): remove unnecessary ExecStartPre sleep command 2026-01-28 03:32:29 +03:00
arabianq 49e01f0318 fix(gui): correct calculation of vertical separator's position 2026-01-28 03:31:54 +03:00
arabianq 5ea9b3b0ba feat(daemon): implementet get full-state command 2026-01-28 02:41:33 +03:00
arabianq ca85d4c369 refactor: remove redundant device linking in play method 2026-01-28 02:28:23 +03:00
arabianq 4499b1d3aa feat(gui): now directories can be reordered using drag and drop 2026-01-28 02:10:36 +03:00
arabianq d385e5356e refactor: simplify device retrieval in link_player_to_virtual_mic function 2026-01-28 01:30:03 +03:00
arabianq b4a0dc6a83 feat: now pwsp will automatically detect when input device is connected/disconnected and properly link/unlink it 2026-01-28 01:26:43 +03:00
arabianq 2e570b3bb0 fix: navigating through files using keyboard now works correctly with filtered files 2026-01-28 00:45:52 +03:00
arabianq ee4554286e refactor: improved filtering functionality 2026-01-28 00:45:20 +03:00
arabianq 2c6f0d932e refactor: refactor input handling for Enter key and directory navigation 2026-01-28 00:34:44 +03:00
arabianq 4e7606fdc6 feat: remove escape key functionality from input handling 2026-01-28 00:28:34 +03:00
arabianq 03df631690 refactor: enhance search field focus functionality and input handling 2026-01-28 00:28:08 +03:00
arabianq 6df826f210 feat: you can now collapse every audio track 2026-01-28 00:03:56 +03:00
arabianq cdf306cfe9 feat: make vertical separator in GUI adjustable 2026-01-27 23:51:14 +03:00
arabianq 74a436b171 fix: add serde default attribute to DaemonConfig and GuiConfig structs 2026-01-27 23:50:50 +03:00
19 changed files with 467 additions and 342 deletions
Generated
+74 -14
View File
@@ -182,7 +182,7 @@ dependencies = [
"bitflags 2.10.0",
"cexpr",
"clang-sys",
"itertools",
"itertools 0.13.0",
"proc-macro2",
"quote",
"regex",
@@ -396,9 +396,9 @@ dependencies = [
[[package]]
name = "clap"
version = "4.5.54"
version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394"
checksum = "3e34525d5bbbd55da2bb745d34b36121baac88d07619a9a09cfcf4a6c0832785"
dependencies = [
"clap_builder",
"clap_derive",
@@ -406,9 +406,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.54"
version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00"
checksum = "59a20016a20a3da95bef50ec7238dbd09baeef4311dcdd38ec15aba69812fb61"
dependencies = [
"anstyle",
"clap_lex",
@@ -417,9 +417,9 @@ dependencies = [
[[package]]
name = "clap_derive"
version = "4.5.49"
version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
dependencies = [
"heck",
"proc-macro2",
@@ -461,6 +461,16 @@ dependencies = [
"memchr",
]
[[package]]
name = "concat-idents"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f76990911f2267d837d9d0ad060aa63aaad170af40904b29461734c339030d4d"
dependencies = [
"quote",
"syn",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
@@ -810,6 +820,29 @@ dependencies = [
"winit",
]
[[package]]
name = "egui_animation"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3db554dd3784f469d804f7dc25d1b14a2e00f1608d7af60218ccbced720a6e8"
dependencies = [
"egui",
"hello_egui_utils",
"simple-easing",
]
[[package]]
name = "egui_dnd"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f535b8df7ca89f781954feaa505899c8955b550074ecafcaa3c040f67aec3d46"
dependencies = [
"egui",
"egui_animation",
"simple-easing",
"web-time",
]
[[package]]
name = "egui_glow"
version = "0.33.3"
@@ -1180,6 +1213,16 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hello_egui_utils"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5d09c2c7f3aa61624b1bec320be9029f30e06769f92e39ac99a6d6e01024ae8"
dependencies = [
"concat-idents",
"egui",
]
[[package]]
name = "hermit-abi"
version = "0.5.2"
@@ -1327,6 +1370,15 @@ dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.17"
@@ -2240,14 +2292,16 @@ checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773"
[[package]]
name = "pwsp"
version = "1.4.0"
version = "1.5.0"
dependencies = [
"async-trait",
"clap",
"dirs",
"eframe",
"egui",
"egui_dnd",
"egui_material_icons",
"itertools 0.14.0",
"pipewire",
"rfd",
"rodio",
@@ -2569,6 +2623,12 @@ version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
name = "simple-easing"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "832ddd7df0d98d6fd93b973c330b7c8e0742d5cb8f1afc7dea89dba4d2531aa1"
[[package]]
name = "slab"
version = "0.4.11"
@@ -3941,18 +4001,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.33"
version = "0.8.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd"
checksum = "71ddd76bcebeed25db614f82bf31a9f4222d3fbba300e6fb6c00afa26cbd4d9d"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.33"
version = "0.8.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1"
checksum = "d8187381b52e32220d50b255276aa16a084ec0a9017a0ca2152a1f55c539758d"
dependencies = [
"proc-macro2",
"quote",
@@ -4015,9 +4075,9 @@ dependencies = [
[[package]]
name = "zmij"
version = "1.0.16"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65"
checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439"
[[package]]
name = "zune-core"
+4 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "pwsp"
version = "1.4.0"
version = "1.5.0"
edition = "2024"
authors = ["arabian"]
description = "PWSP lets you play audio files through your microphone. Has both CLI and GUI clients."
@@ -18,8 +18,9 @@ async-trait = "0.1.89"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
clap = { version = "4.5.54", default-features = false, features = ["std", "suggestions", "help", "usage", "error-context", "derive"] }
clap = { version = "4.5.55", default-features = false, features = ["std", "suggestions", "help", "usage", "error-context", "derive"] }
dirs = "6.0.0"
itertools = "0.14.0"
rodio = { version = "0.21.1", default-features = false, features = ["symphonia-all", "playback"] }
pipewire = "0.9.2"
@@ -28,6 +29,7 @@ rfd = { version = "0.17.2", default-features = false, features = ["xdg-portal"]}
egui = { version = "0.33.3", default-features = false, features = ["default_fonts", "rayon"] }
eframe = { version = "0.33.3", default-features = false, features = ["default_fonts", "glow", "x11", "wayland"] }
egui_material_icons = "0.5.0"
egui_dnd = "0.14.0"
[[bin]]
name = "pwsp-daemon"
+3 -1
View File
@@ -24,6 +24,9 @@ chats on platforms like **Discord, Zoom, or Teamspeak**.
* **Position slider** to fast-forward or rewind the audio.
* **Persistent Configuration**: The list of added directories and your selected audio output device are saved
automatically, so you won't need to reconfigure them every time you launch the application.
* **Collapsible Audio Tracks**: You can collapse every audio track to save space.
* **Drag and Drop Directories**: Reorder your sound directories easily using drag and drop.
* **Automatic Device Detection**: PWSP automatically detects when an input device is connected or disconnected and handles linking/unlinking.
# **⚙️ How It Works**
@@ -179,7 +182,6 @@ pwsp-cli --help
| Key | Action |
| :----------------------- | :--------------------------------------------------- |
| **Esc** | Close application |
| **Space** | Pause / Resume audio |
| **Backspace** | Stop all audio tracks |
| **Enter** | Play selected file (stops all other tracks) |
-1
View File
@@ -3,7 +3,6 @@ Description=Pipewire Soundpad Daemon
After=pipewire.service
[Service]
ExecStartPre=/usr/bin/sleep 10
ExecStart=/usr/bin/pwsp-daemon
Restart=no
RuntimeDirectory=pwsp
Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 84 KiB

+1 -1
View File
@@ -4,7 +4,7 @@
%global cargo_install_lib 0
Name: pwsp
Version: 1.4.0
Version: 1.5.0
Release: %autorelease
Summary: Lets you play audio files through your microphone
+3
View File
@@ -92,6 +92,8 @@ enum GetCommands {
Input,
/// All audio inputs
Inputs,
/// Full player state
FullState,
}
#[derive(Subcommand, Debug)]
@@ -146,6 +148,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
GetCommands::Tracks => Request::get_tracks(),
GetCommands::Input => Request::get_input(),
GetCommands::Inputs => Request::get_inputs(),
GetCommands::FullState => Request::get_full_state(),
},
Commands::Set { parameter } => match parameter {
SetCommands::Volume { volume, id } => Request::set_volume(volume, id),
+58 -44
View File
@@ -1,12 +1,13 @@
use crate::gui::{SUPPORTED_EXTENSIONS, SoundpadGui};
use crate::gui::SoundpadGui;
use egui::{
Align, AtomExt, Button, Color32, ComboBox, FontFamily, Label, Layout, RichText, ScrollArea,
Slider, TextEdit, Ui, Vec2,
Align, AtomExt, Button, CollapsingHeader, Color32, ComboBox, CursorIcon, FontFamily, Label,
Layout, RichText, ScrollArea, Sense, Slider, TextEdit, Ui, Vec2,
};
use egui_dnd::dnd;
use egui_material_icons::icons;
use pwsp::types::audio_player::TrackInfo;
use pwsp::utils::gui::format_time_pair;
use std::{error::Error, path::PathBuf, time::Instant};
use std::{error::Error, time::Instant};
use pwsp::types::gui::AppState;
@@ -95,11 +96,6 @@ impl SoundpadGui {
fn draw_header(&mut self, ui: &mut Ui) {
ui.vertical_centered_justified(|ui| {
self.draw_controls(ui);
});
}
fn draw_controls(&mut self, ui: &mut Ui) {
if self.audio_player_state.tracks.is_empty() {
ui.label("No tracks playing");
return;
@@ -109,7 +105,7 @@ impl SoundpadGui {
let mut action = None;
for track in tracks {
ui.label(
CollapsingHeader::new(
RichText::new(
track
.path
@@ -120,10 +116,13 @@ impl SoundpadGui {
)
.color(Color32::WHITE)
.family(FontFamily::Monospace),
);
)
.default_open(true)
.show(ui, |ui| {
if let Some(act) = Self::draw_track_control(ui, &mut self.app_state, &track) {
action = Some(act);
}
});
ui.separator();
}
@@ -135,6 +134,7 @@ impl SoundpadGui {
TrackAction::Stop(id) => self.stop(Some(id)),
}
}
});
}
fn draw_track_control(
@@ -260,11 +260,37 @@ impl SoundpadGui {
}
fn draw_body(&mut self, ui: &mut Ui) {
let dirs_size = Vec2::new(ui.available_width() / 4.0, ui.available_height() - 40.0);
let left_panel_width = self
.config
.left_panel_width
.max(100.0)
.min(ui.available_width() - 100.0);
let dirs_size = Vec2::new(left_panel_width, ui.available_height() - 40.0);
ui.horizontal(|ui| {
self.draw_dirs(ui, dirs_size);
ui.separator();
let (rect, response) = ui.allocate_at_least(
Vec2::new(ui.spacing().item_spacing.x, ui.available_height()),
Sense::click_and_drag(),
);
if ui.is_rect_visible(rect) {
let stroke = ui.visuals().widgets.noninteractive.bg_stroke;
ui.painter().vline(rect.center().x, rect.y_range(), stroke);
}
let vertical_separator_response =
response.on_hover_and_drag_cursor(CursorIcon::ResizeHorizontal);
if vertical_separator_response.dragged() {
self.config.left_panel_width += vertical_separator_response.drag_delta().x;
self.config.left_panel_width = self.config.left_panel_width.clamp(100.0, 500.0);
}
if vertical_separator_response.drag_stopped() {
self.config.save_to_file().ok();
}
let files_size = Vec2::new(ui.available_width(), ui.available_height() - 40.0);
self.draw_files(ui, files_size);
@@ -279,10 +305,14 @@ impl SoundpadGui {
ScrollArea::vertical().id_salt(0).show(ui, |ui| {
ui.set_min_width(area_size.x);
let mut dirs: Vec<PathBuf> = self.app_state.dirs.iter().cloned().collect();
dirs.sort();
for path in dirs.iter() {
let mut dirs = self.app_state.dirs.clone();
dnd(ui, "dnd_directories").show_vec(&mut dirs, |ui, item, handle, _state| {
let path = item.clone();
ui.horizontal(|ui| {
handle.ui(ui, |ui| {
ui.label(icons::ICON_DRAG_INDICATOR);
});
let name = path
.file_name()
.map(|s| s.to_string_lossy().to_string())
@@ -290,7 +320,7 @@ impl SoundpadGui {
let mut dir_button_text = RichText::new(name.clone());
if let Some(current_dir) = &self.app_state.current_dir {
if current_dir.eq(path) {
if current_dir.eq(&path) {
dir_button_text = dir_button_text.color(Color32::WHITE);
}
}
@@ -300,7 +330,7 @@ impl SoundpadGui {
let dir_button_response = ui.add(dir_button);
if dir_button_response.clicked() {
self.open_dir(path);
self.open_dir(&path);
}
let delete_dir_button = Button::new(icons::ICON_DELETE).frame(false);
@@ -310,7 +340,8 @@ impl SoundpadGui {
self.remove_dir(&path.clone());
}
});
}
});
self.app_state.dirs = dirs;
ui.horizontal(|ui| {
let add_dirs_button = Button::new(icons::ICON_ADD).frame(false);
@@ -334,12 +365,17 @@ impl SoundpadGui {
fn draw_files(&mut self, ui: &mut Ui, area_size: Vec2) {
ui.vertical(|ui| {
ui.horizontal(|ui| {
let search_field = ui.add_sized(
let search_field_response = ui.add_sized(
[ui.available_width(), 22.0],
TextEdit::singleline(&mut self.app_state.search_query).hint_text("Search..."),
);
self.app_state.search_field_id = Some(search_field.id);
if self.app_state.force_focus_search {
search_field_response.request_focus();
self.app_state.force_focus_search = false;
}
self.app_state.search_field_id = Some(search_field_response.id);
});
ui.separator();
@@ -349,37 +385,15 @@ impl SoundpadGui {
ui.set_min_height(area_size.y);
ui.vertical(|ui| {
let mut files: Vec<PathBuf> = self.app_state.files.iter().cloned().collect();
files.sort();
let files = self.get_filtered_files();
for entry_path in files {
if entry_path.is_dir() {
continue;
}
if !SUPPORTED_EXTENSIONS
.contains(&entry_path.extension().unwrap_or_default().to_str().unwrap())
{
continue;
}
let file_name = entry_path
.file_name()
.unwrap()
.to_string_lossy()
.to_string();
let search_query = self
.app_state
.search_query
.to_lowercase()
.trim()
.to_string();
if !file_name.to_lowercase().contains(search_query.as_str()) {
continue;
}
let mut file_button_text = RichText::new(file_name);
if let Some(current_file) = &self.app_state.selected_file {
if current_file.eq(&entry_path) {
+51 -58
View File
@@ -1,30 +1,47 @@
use crate::gui::SoundpadGui;
use egui::{Context, Key};
use egui::{Context, Key, Modifiers};
use std::path::PathBuf;
impl SoundpadGui {
pub fn handle_input(&mut self, ctx: &Context) {
if ctx.memory(|reader| { reader.focused() }.is_some()) {
return;
fn key_pressed(&self, ctx: &Context, key: Key) -> bool {
ctx.input(|i| i.key_pressed(key))
}
ctx.input(|i| {
// Close app on espace
if i.key_pressed(Key::Escape) {
std::process::exit(0);
fn modifiers(&self, ctx: &Context) -> Modifiers {
ctx.input(|i| i.modifiers)
}
pub fn handle_input(&mut self, ctx: &Context) {
let modifiers = self.modifiers(ctx);
// Open/close settings
if i.key_pressed(Key::I) {
if self.key_pressed(ctx, Key::I) {
self.app_state.show_settings = !self.app_state.show_settings;
}
if i.key_pressed(Key::Enter) && self.app_state.selected_file.is_some() {
if !self.app_state.show_settings {
// Pause / resume audio on space
if self.key_pressed(ctx, Key::Space) {
self.play_toggle();
}
// Stop all audio tracks on backspace
if self.key_pressed(ctx, Key::Backspace) {
self.stop(None);
}
// Focus search field
if self.key_pressed(ctx, Key::Slash) {
self.app_state.force_focus_search = true;
}
// Play selected file on Enter
if self.key_pressed(ctx, Key::Enter) && self.app_state.selected_file.is_some() {
let path = &self.app_state.selected_file.clone().unwrap();
if i.modifiers.ctrl {
if modifiers.ctrl {
self.play_file(path, true);
} else if i.modifiers.shift
} else if modifiers.shift
&& let Some(last_track) = self.audio_player_state.tracks.last()
{
self.stop(Some(last_track.id));
@@ -34,31 +51,12 @@ impl SoundpadGui {
}
}
if !self.app_state.show_settings {
// Pause / resume audio on space
if i.key_pressed(Key::Space) {
self.play_toggle();
}
// Stop all audio tracks on backspace
if i.key_pressed(Key::Backspace) {
self.stop(None);
}
// Focus search field
if i.key_pressed(Key::Slash) && self.app_state.search_field_id.is_some() {
self.app_state.force_focus_id = self.app_state.search_field_id;
}
// Iterate through dirs if there are some
if i.modifiers.ctrl {
let arrow_up_pressed = i.key_pressed(Key::ArrowUp);
let arrow_down_pressed = i.key_pressed(Key::ArrowDown);
if arrow_up_pressed || arrow_down_pressed {
if i.modifiers.shift && !self.app_state.dirs.is_empty() {
let mut dirs: Vec<PathBuf> =
self.app_state.dirs.iter().cloned().collect();
// Iterate through dirs and files with Ctrl + Up/Down
let arrow_up_pressed = self.key_pressed(ctx, Key::ArrowUp);
let arrow_down_pressed = self.key_pressed(ctx, Key::ArrowDown);
if modifiers.ctrl && (arrow_up_pressed || arrow_down_pressed) {
if modifiers.shift && !self.app_state.dirs.is_empty() {
let mut dirs: Vec<PathBuf> = self.app_state.dirs.iter().cloned().collect();
dirs.sort();
let current_dir_index: i8;
@@ -74,8 +72,8 @@ impl SoundpadGui {
let mut new_dir_index: i8;
new_dir_index = current_dir_index - arrow_up_pressed as i8
+ arrow_down_pressed as i8;
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;
@@ -85,25 +83,22 @@ impl SoundpadGui {
self.open_dir(&dirs[new_dir_index as usize]);
} else if self.app_state.current_dir.is_some() {
let mut files: Vec<PathBuf> =
self.app_state.files.iter().cloned().collect();
files.sort();
let files = self.get_filtered_files();
let current_files_index: i64;
if let Some(selected_file) = &self.app_state.selected_file {
if let Some(index) = files.iter().position(|x| x == selected_file) {
current_files_index = index as i64;
} else {
current_files_index = -1;
}
} else {
current_files_index = -1;
if files.is_empty() {
return;
}
let mut new_files_index: i64;
let current_files_index = self
.app_state
.selected_file
.as_ref()
.and_then(|f| files.iter().position(|x| x == f))
.map(|i| i as i64)
.unwrap_or(-1);
new_files_index = current_files_index - arrow_up_pressed as i64
+ arrow_down_pressed as i64;
let mut new_files_index =
current_files_index - arrow_up_pressed as i64 + arrow_down_pressed as i64;
if new_files_index < 0 {
new_files_index = (files.len() - 1) as i64;
@@ -111,12 +106,10 @@ impl SoundpadGui {
new_files_index = 0;
}
self.app_state.selected_file =
Some(files[new_files_index as usize].clone());
self.app_state.selected_file = Some(files[new_files_index as usize].clone());
}
}
}
}
});
// });
}
}
+45 -2
View File
@@ -4,6 +4,7 @@ mod update;
use eframe::{HardwareAcceleration, NativeOptions, icon_data::from_png_bytes, run_native};
use egui::{Context, Vec2, ViewportBuilder};
use itertools::Itertools;
use pwsp::{
types::{
audio_player::PlayerState,
@@ -87,15 +88,16 @@ impl SoundpadGui {
let file_dialog = FileDialog::new();
if let Some(paths) = file_dialog.pick_folders() {
for path in paths {
self.app_state.dirs.insert(path);
self.app_state.dirs.push(path);
}
self.app_state.dirs = self.app_state.dirs.iter().unique().cloned().collect();
self.config.dirs = self.app_state.dirs.clone();
self.config.save_to_file().ok();
}
}
pub fn remove_dir(&mut self, path: &PathBuf) {
self.app_state.dirs.remove(path);
self.app_state.dirs.retain(|x| x != path);
if let Some(current_dir) = &self.app_state.current_dir
&& current_dir == path
{
@@ -145,6 +147,47 @@ impl SoundpadGui {
pub fn stop(&mut self, id: Option<u32>) {
make_request_async(Request::stop(id));
}
pub fn get_filtered_files(&self) -> Vec<PathBuf> {
let mut files: Vec<PathBuf> = self.app_state.files.iter().cloned().collect();
files.sort();
let search_query = self.app_state.search_query.to_lowercase();
let search_query = search_query.trim();
files
.into_iter()
.filter(|entry_path| {
if entry_path.is_dir() {
return false;
}
if !SUPPORTED_EXTENSIONS.contains(
&entry_path
.extension()
.unwrap_or_default()
.to_str()
.unwrap_or_default(),
) {
return false;
}
if !search_query.is_empty() {
let file_name = entry_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
if !file_name.to_lowercase().contains(search_query) {
return false;
}
}
true
})
.collect()
}
}
pub async fn run() -> Result<(), Box<dyn Error>> {
+12 -7
View File
@@ -9,6 +9,13 @@ use std::time::{Duration, Instant};
impl App for SoundpadGui {
fn update(&mut self, ctx: &Context, _frame: &mut EFrame) {
// Save directories if changed
if !self.config.dirs.eq(&self.app_state.dirs) {
self.config.dirs = self.app_state.dirs.clone();
self.config.save_to_file().ok();
}
// Seek and volume requests
let mut seek_requests = vec![];
let mut volume_requests = vec![];
@@ -57,11 +64,13 @@ impl App for SoundpadGui {
}
}
// Sync audio player state
{
let guard = self.audio_player_state_shared.lock().unwrap();
self.audio_player_state = guard.clone();
}
// Handle scale factor changes
let old_scale_factor = self.config.scale_factor;
let new_scale_factor = ctx.zoom_factor().clamp(0.5, 2.0);
@@ -72,8 +81,10 @@ impl App for SoundpadGui {
self.config.save_to_file().ok();
}
// Handle input
self.handle_input(ctx);
// Draw UI
CentralPanel::default().show(ctx, |ui| {
if !self.audio_player_state.is_daemon_running {
self.draw_waiting_for_daemon(ui);
@@ -86,15 +97,9 @@ impl App for SoundpadGui {
}
self.draw(ui).ok();
if let Some(force_focus_id) = self.app_state.force_focus_id {
ui.memory_mut(|reder| {
reder.request_focus(force_focus_id);
});
self.app_state.force_focus_id = None;
}
});
// Request repaint
ctx.request_repaint_after_secs(1.0 / 60.0);
}
}
+57 -36
View File
@@ -1,8 +1,8 @@
use crate::{
types::pipewire::{AudioDevice, DeviceType, Terminate},
types::pipewire::{DeviceType, Terminate},
utils::{
daemon::get_daemon_config,
pipewire::{create_link, get_all_devices, get_device},
pipewire::{create_link, get_device},
},
};
use rodio::{Decoder, OutputStream, OutputStreamBuilder, Sink, Source};
@@ -34,6 +34,15 @@ pub struct TrackInfo {
pub paused: bool,
}
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
pub struct FullState {
pub state: PlayerState,
pub tracks: Vec<TrackInfo>,
pub volume: f32,
pub current_input: String,
pub all_inputs: HashMap<String, String>,
}
pub struct PlayingSound {
pub id: u32,
pub sink: Sink,
@@ -49,7 +58,7 @@ pub struct AudioPlayer {
pub next_id: u32,
input_link_sender: Option<pipewire::channel::Sender<Terminate>>,
pub current_input_device: Option<AudioDevice>,
pub input_device_name: Option<String>,
pub volume: f32, // Master volume
}
@@ -58,13 +67,6 @@ impl AudioPlayer {
pub async fn new() -> Result<Self, Box<dyn Error>> {
let daemon_config = get_daemon_config();
let default_volume = daemon_config.default_volume.unwrap_or(1.0);
let mut default_input_device: Option<AudioDevice> = None;
if let Some(name) = daemon_config.default_input_name
&& let Ok(device) = get_device(&name).await
&& device.device_type == DeviceType::Input
{
default_input_device = Some(device);
}
let stream_handle = OutputStreamBuilder::open_default_stream()?;
@@ -74,12 +76,12 @@ impl AudioPlayer {
next_id: 1,
input_link_sender: None,
current_input_device: default_input_device.clone(),
input_device_name: daemon_config.default_input_name.clone(),
volume: default_volume,
};
if default_input_device.is_some() {
if audio_player.input_device_name.is_some() {
audio_player.link_devices().await?;
}
@@ -89,7 +91,10 @@ impl AudioPlayer {
fn abort_link_thread(&mut self) {
if let Some(sender) = &self.input_link_sender {
match sender.send(Terminate {}) {
Ok(_) => println!("Sent terminate signal to link thread"),
Ok(_) => {
println!("Sent terminate signal to link thread");
self.input_link_sender = None;
}
Err(_) => eprintln!("Failed to send terminate signal to link thread"),
}
}
@@ -98,42 +103,43 @@ impl AudioPlayer {
async fn link_devices(&mut self) -> Result<(), Box<dyn Error>> {
self.abort_link_thread();
if self.current_input_device.is_none() {
let input_device;
if let Some(input_device_name) = &self.input_device_name {
if let Ok(device) = get_device(input_device_name).await {
input_device = device;
} else {
eprintln!(
"Could not find selected input device {}, skipping device linking",
input_device_name
);
return Ok(());
}
} else {
eprintln!("No input device selected, skipping device linking");
return Ok(());
}
let (input_devices, _) = get_all_devices().await?;
let mut pwsp_daemon_input: Option<AudioDevice> = None;
for input_device in input_devices {
if input_device.name == "pwsp-virtual-mic" {
pwsp_daemon_input = Some(input_device);
break;
}
}
if pwsp_daemon_input.is_none() {
eprintln!("Could not find pwsp-daemon input device, skipping device linking");
let daemon_input;
if let Ok(device) = get_device("pwsp-virtual-mic").await {
daemon_input = device;
} else {
eprintln!("Could not find pwsp-virtual-mic device, skipping device linking");
return Ok(());
}
let pwsp_daemon_input = pwsp_daemon_input.unwrap();
let current_input_device = self.current_input_device.clone().unwrap();
let Some(output_fl) = current_input_device.output_fl.clone() else {
let Some(output_fl) = input_device.output_fl.clone() else {
eprintln!("Failed to get pwsp-daemon output_fl");
return Ok(());
};
let Some(output_fr) = current_input_device.output_fr.clone() else {
let Some(output_fr) = input_device.output_fr.clone() else {
eprintln!("Failed to get pwsp-daemon output_fr");
return Ok(());
};
let Some(input_fl) = pwsp_daemon_input.input_fl.clone() else {
let Some(input_fl) = daemon_input.input_fl.clone() else {
eprintln!("Failed to get pwsp-daemon input_fl");
return Ok(());
};
let Some(input_fr) = pwsp_daemon_input.input_fr.clone() else {
let Some(input_fr) = daemon_input.input_fr.clone() else {
eprintln!("Failed to get pwsp-daemon input_fr");
return Ok(());
};
@@ -292,8 +298,6 @@ impl AudioPlayer {
self.tracks.insert(id, sound);
self.link_devices().await?;
Ok(id)
}
Err(err) => Err(err.into()),
@@ -333,6 +337,23 @@ impl AudioPlayer {
}
pub async fn update(&mut self) {
if let Some(input_device_name) = &self.input_device_name {
// Unlink devices if selected input device was removed
if self.input_link_sender.is_some() && get_device(input_device_name).await.is_err() {
// Selected input device was removed
eprintln!(
"Selected input device {} was removed, unlinking devices",
input_device_name
);
self.abort_link_thread();
}
// Link devices if not linked
else if self.input_link_sender.is_none() {
self.link_devices().await.ok();
}
}
// Handle looped sounds
let mut restarts = vec![];
for (id, sound) in &self.tracks {
@@ -363,7 +384,7 @@ impl AudioPlayer {
return Err("Selected device is not an input device".into());
}
self.current_input_device = Some(input_device);
self.input_device_name = Some(name.to_string());
self.link_devices().await?;
+50 -4
View File
@@ -1,9 +1,15 @@
use crate::{
types::{audio_player::PlayerState, socket::Response},
utils::{daemon::get_audio_player, pipewire::get_all_devices},
types::{
audio_player::{FullState, PlayerState},
socket::Response,
},
utils::{
daemon::get_audio_player,
pipewire::{get_all_devices, get_device},
},
};
use async_trait::async_trait;
use std::path::PathBuf;
use std::{collections::HashMap, path::PathBuf};
#[async_trait]
pub trait Executable {
@@ -76,6 +82,8 @@ pub struct ToggleLoopCommand {
pub id: Option<u32>,
}
pub struct GetFullStateCommand {}
#[async_trait]
impl Executable for PingCommand {
async fn execute(&self) -> Response {
@@ -254,11 +262,15 @@ impl Executable for GetTracksCommand {
impl Executable for GetCurrentInputCommand {
async fn execute(&self) -> Response {
let audio_player = get_audio_player().await.lock().await;
if let Some(input_device) = &audio_player.current_input_device {
if let Some(input_device_name) = &audio_player.input_device_name {
if let Ok(input_device) = get_device(input_device_name).await {
Response::new(
true,
format!("{} - {}", input_device.name, input_device.nick),
)
} else {
Response::new(false, "Failed to get current input device")
}
} else {
Response::new(false, "No input device selected")
}
@@ -334,3 +346,37 @@ impl Executable for ToggleLoopCommand {
}
}
}
#[async_trait]
impl Executable for GetFullStateCommand {
async fn execute(&self) -> Response {
let (input_devices, _output_devices) = get_all_devices().await.unwrap();
let mut all_inputs = HashMap::new();
let mut current_input_nick = String::new();
let audio_player = get_audio_player().await.lock().await;
for device in input_devices {
if device.name == "pwsp-virtual-mic" {
continue;
}
if let Some(current_input_name) = &audio_player.input_device_name {
if device.name == *current_input_name {
current_input_nick = format!("{} - {}", device.name, device.nick);
}
}
all_inputs.insert(device.name, device.nick);
}
let full_state = FullState {
state: audio_player.get_state(),
tracks: audio_player.get_tracks(),
volume: audio_player.volume,
current_input: current_input_nick,
all_inputs,
};
Response::new(true, serde_json::to_string(&full_state).unwrap())
}
}
+7 -3
View File
@@ -1,8 +1,9 @@
use crate::utils::config::get_config_path;
use serde::{Deserialize, Serialize};
use std::{collections::HashSet, error::Error, fs, path::PathBuf};
use std::{error::Error, fs, path::PathBuf};
#[derive(Default, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DaemonConfig {
pub default_input_name: Option<String>,
pub default_volume: Option<f32>,
@@ -30,28 +31,31 @@ impl DaemonConfig {
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct GuiConfig {
pub scale_factor: f32,
pub left_panel_width: f32,
pub save_volume: bool,
pub save_input: bool,
pub save_scale_factor: bool,
pub pause_on_exit: bool,
pub dirs: HashSet<PathBuf>,
pub dirs: Vec<PathBuf>,
}
impl Default for GuiConfig {
fn default() -> Self {
GuiConfig {
scale_factor: 1.0,
left_panel_width: 280.0,
save_volume: false,
save_input: false,
save_scale_factor: false,
pause_on_exit: false,
dirs: HashSet::default(),
dirs: vec![],
}
}
}
+4 -4
View File
@@ -28,19 +28,19 @@ pub struct AppState {
pub show_settings: bool,
pub volume_dragged: bool,
pub force_focus_search: bool,
pub volume_slider_value: f32,
pub search_field_id: Option<Id>,
pub ignore_volume_update_until: Option<Instant>,
pub current_dir: Option<PathBuf>,
pub dirs: HashSet<PathBuf>,
pub dirs: Vec<PathBuf>,
pub selected_file: Option<PathBuf>,
pub files: HashSet<PathBuf>,
pub search_field_id: Option<Id>,
pub force_focus_id: Option<Id>,
}
#[derive(Default, Debug, Clone)]
+4
View File
@@ -155,6 +155,10 @@ impl Request {
}
Request::new("toggle_loop", args)
}
pub fn get_full_state() -> Self {
Request::new("get_full_state", vec![])
}
}
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
+1
View File
@@ -69,6 +69,7 @@ pub fn parse_command(request: &Request) -> Option<Box<dyn Executable + Send>> {
Some(Box::new(SetLoopCommand { enabled, id }))
}
"toggle_loop" => Some(Box::new(ToggleLoopCommand { id })),
"get_full_state" => Some(Box::new(GetFullStateCommand {})),
_ => None,
}
}
+11 -27
View File
@@ -2,10 +2,9 @@ use crate::{
types::{
audio_player::AudioPlayer,
config::DaemonConfig,
pipewire::AudioDevice,
socket::{Request, Response},
},
utils::pipewire::{create_link, get_all_devices},
utils::pipewire::{create_link, get_device},
};
use std::path::PathBuf;
use std::{error::Error, fs};
@@ -36,37 +35,22 @@ pub fn get_daemon_config() -> DaemonConfig {
}
pub async fn link_player_to_virtual_mic() -> Result<(), Box<dyn Error>> {
let (input_devices, output_devices) = get_all_devices().await?;
let mut pwsp_daemon_output: Option<AudioDevice> = None;
for output_device in output_devices {
if output_device.name == "alsa_playback.pwsp-daemon" {
pwsp_daemon_output = Some(output_device);
break;
}
}
if pwsp_daemon_output.is_none() {
eprintln!("Could not find pwsp-daemon output device, skipping device linking");
let pwsp_daemon_output;
if let Ok(device) = get_device("alsa_playback.pwsp-daemon").await {
pwsp_daemon_output = device;
} else {
eprintln!("Could not find alsa_playback.pwsp-daemon device, skipping device linking");
return Ok(());
}
let mut pwsp_daemon_input: Option<AudioDevice> = None;
for input_device in input_devices {
if input_device.name == "pwsp-virtual-mic" {
pwsp_daemon_input = Some(input_device);
break;
}
}
if pwsp_daemon_input.is_none() {
eprintln!("Could not find pwsp-daemon input device, skipping device linking");
let pwsp_daemon_input;
if let Ok(device) = get_device("pwsp-virtual-mic").await {
pwsp_daemon_input = device;
} else {
eprintln!("Could not find pwsp-virtual-mic device, skipping device linking");
return Ok(());
}
let pwsp_daemon_output = pwsp_daemon_output.unwrap();
let pwsp_daemon_input = pwsp_daemon_input.unwrap();
let output_fl = pwsp_daemon_output
.clone()
.output_fl
+16 -72
View File
@@ -1,6 +1,6 @@
use crate::{
types::{
audio_player::{PlayerState, TrackInfo},
audio_player::FullState,
config::GuiConfig,
gui::AudioPlayerState,
socket::{Request, Response},
@@ -8,7 +8,6 @@ use crate::{
utils::daemon::{is_daemon_running, make_request},
};
use std::{
collections::HashMap,
error::Error,
sync::{Arc, Mutex},
};
@@ -63,73 +62,13 @@ pub fn start_app_state_thread(audio_player_state_shared: Arc<Mutex<AudioPlayerSt
continue;
}
let state_req = Request::get_state();
let tracks_req = Request::get_tracks();
let volume_req = Request::get_volume();
let current_input_req = Request::get_input();
let all_inputs_req = Request::get_inputs();
let full_state_req = Request::get_full_state();
let full_state_res = make_request(full_state_req).await.unwrap_or_default();
let (state_res, tracks_res, volume_res, current_input_res, all_inputs_res) = tokio::join!(
make_request(state_req),
make_request(tracks_req),
make_request(volume_req),
make_request(current_input_req),
make_request(all_inputs_req),
);
if full_state_res.status {
let full_state: FullState =
serde_json::from_str(&full_state_res.message).unwrap_or_default();
let state_res = state_res.unwrap_or_default();
let tracks_res = tracks_res.unwrap_or_default();
let volume_res = volume_res.unwrap_or_default();
let current_input_res = current_input_res.unwrap_or_default();
let all_inputs_res = all_inputs_res.unwrap_or_default();
let state = match state_res.status {
true => serde_json::from_str::<PlayerState>(&state_res.message).unwrap(),
false => PlayerState::default(),
};
let tracks = match tracks_res.status {
true => {
serde_json::from_str::<Vec<TrackInfo>>(&tracks_res.message).unwrap_or_default()
}
false => vec![],
};
let volume = match volume_res.status {
true => volume_res.message.parse::<f32>().unwrap(),
false => 0.0,
};
let current_input = match current_input_res.status {
true => current_input_res
.message
.as_str()
.split(" - ")
.collect::<Vec<&str>>()
.first()
.unwrap()
.to_string(),
false => String::new(),
};
let all_inputs = match all_inputs_res.status {
true => all_inputs_res
.message
.as_str()
.split(';')
.filter_map(|entry| {
let entry = entry.trim();
if entry.is_empty() {
return None;
}
entry
.split_once(" - ")
.map(|(k, v)| (k.trim().to_string(), v.trim().to_string()))
})
.collect::<HashMap<String, String>>(),
false => HashMap::new(),
};
{
let mut guard = audio_player_state_shared.lock().unwrap();
guard.state = match guard.new_state.clone() {
@@ -137,12 +76,17 @@ pub fn start_app_state_thread(audio_player_state_shared: Arc<Mutex<AudioPlayerSt
guard.new_state = None;
new_state
}
None => state,
None => full_state.state,
};
guard.tracks = tracks.clone();
guard.volume = volume;
guard.current_input = current_input;
guard.all_inputs = all_inputs;
guard.tracks = full_state.tracks;
guard.volume = full_state.volume;
guard.current_input = full_state
.current_input
.split(" - ")
.next()
.unwrap_or_default()
.to_string();
guard.all_inputs = full_state.all_inputs;
guard.is_daemon_running = true;
}