mirror of
https://github.com/arabianq/pipewire-soundpad.git
synced 2026-04-28 14:31:23 +00:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a2418325d | |||
| a948ea2dcd | |||
| a156df346b | |||
| 7a13ae55a6 | |||
| b2b83f5c32 | |||
| f01a0e656c | |||
| 6114b9a7f8 | |||
| b8baeb6226 | |||
| 02306b5893 | |||
| 3add499bd7 | |||
| 3c2e943e18 | |||
| 261f83efd4 | |||
| c6577cd5e0 | |||
| 95761f6a5a | |||
| d6effc972e | |||
| 498d0d25af | |||
| c99d0749e3 | |||
| 151f43f1ab | |||
| 077518019f | |||
| 968eba80e6 | |||
| aa77a8d212 | |||
| 4b50645c93 | |||
| 39648f7781 |
@@ -33,7 +33,7 @@ jobs:
|
||||
- name: Setup Rust toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
toolchain: 1.94.1
|
||||
|
||||
- name: Extract all binary names
|
||||
id: cargo-meta
|
||||
@@ -103,7 +103,7 @@ jobs:
|
||||
flatpak-build:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: bilelmoussaoui/flatpak-github-actions:freedesktop-24.08
|
||||
image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-25.08
|
||||
options: --privileged
|
||||
|
||||
steps:
|
||||
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
- name: Setup Rust toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
toolchain: 1.94.1
|
||||
|
||||
- name: Extract all binary names
|
||||
id: cargo-meta
|
||||
@@ -158,7 +158,7 @@ jobs:
|
||||
needs: prepare
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: bilelmoussaoui/flatpak-github-actions:freedesktop-24.08
|
||||
image: ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-25.08
|
||||
options: --privileged
|
||||
|
||||
steps:
|
||||
|
||||
Generated
+599
-177
File diff suppressed because it is too large
Load Diff
+8
-7
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "pwsp"
|
||||
version = "1.6.2"
|
||||
version = "1.7.0"
|
||||
edition = "2024"
|
||||
authors = ["arabian"]
|
||||
description = "PWSP lets you play audio files through your microphone. Has both CLI and GUI clients."
|
||||
@@ -12,13 +12,13 @@ keywords = ["soundpad", "pipewire", "linux", "cli", "gui"]
|
||||
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1.50.0", features = ["full"] }
|
||||
tokio = { version = "1.51.1", features = ["full"] }
|
||||
async-trait = "0.1.89"
|
||||
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
|
||||
clap = { version = "4.5.60", default-features = false, features = [
|
||||
clap = { version = "4.6.0", default-features = false, features = [
|
||||
"std",
|
||||
"suggestions",
|
||||
"help",
|
||||
@@ -34,23 +34,24 @@ rodio = { version = "0.22.2", default-features = false, features = [
|
||||
"playback",
|
||||
] }
|
||||
pipewire = "0.9.2"
|
||||
evdev = { version = "0.13.2", features = ["tokio"] }
|
||||
rfd = { version = "0.17.2", default-features = false, features = [
|
||||
"xdg-portal",
|
||||
] }
|
||||
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",
|
||||
"rayon",
|
||||
] }
|
||||
eframe = { version = "0.33.3", default-features = false, features = [
|
||||
eframe = { version = "0.34.1", default-features = false, features = [
|
||||
"default_fonts",
|
||||
"glow",
|
||||
"x11",
|
||||
"wayland",
|
||||
] }
|
||||
egui_material_icons = "0.5.0"
|
||||
egui_dnd = "0.14.0"
|
||||
egui_material_icons = "0.6.0"
|
||||
egui_dnd = "0.15.0"
|
||||
|
||||
[[bin]]
|
||||
name = "pwsp-daemon"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
pkgbase = pwsp-bin
|
||||
pkgdesc = Lets you play audio files through your microphone (Pre-built binaries)
|
||||
pkgver = 1.6.2
|
||||
pkgver = 1.7.0
|
||||
pkgrel = 2
|
||||
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.6.2.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.6.2/pwsp-v1.6.2-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 = pwsp-bin-1.7.0.zip :: https://github.com/arabianq/pipewire-soundpad/releases/download/v1.7.0/pwsp-v1.7.0-linux-x64.zip
|
||||
source = pipewire-soundpad-1.7.0.tar.gz :: https://github.com/arabianq/pipewire-soundpad/archive/refs/tags/v1.7.0.tar.gz
|
||||
sha256sums = SKIP
|
||||
sha256sums = SKIP
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maintainer: Alexander Tarasov <a.tevg@ya.ru>
|
||||
pkgname=pwsp-bin
|
||||
_pkgname=pipewire-soundpad
|
||||
pkgver=1.6.2
|
||||
pkgver=1.7.0
|
||||
pkgrel=2
|
||||
pkgdesc="Lets you play audio files through your microphone (Pre-built binaries)"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
pkgbase = pwsp
|
||||
pkgdesc = Lets you play audio files through your microphone
|
||||
pkgver = 1.6.2
|
||||
pkgver = 1.7.0
|
||||
pkgrel = 1
|
||||
url = https://github.com/arabianq/pipewire-soundpad
|
||||
arch = any
|
||||
@@ -10,7 +10,7 @@ pkgbase = pwsp
|
||||
makedepends = cargo
|
||||
makedepends = pipewire
|
||||
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.7.0.tar.gz
|
||||
sha256sums = SKIP
|
||||
|
||||
pkgname = pwsp
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maintainer: Alexander Tarasov <a.tevg@ya.ru>
|
||||
pkgsubn=pwsp
|
||||
pkgname=pwsp
|
||||
pkgver=1.6.2
|
||||
pkgver=1.7.0
|
||||
pkgrel=1
|
||||
pkgdesc="Lets you play audio files through your microphone"
|
||||
arch=('any')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
app-id: ru.arabianq.pwsp
|
||||
runtime: org.freedesktop.Platform
|
||||
runtime-version: "24.08"
|
||||
runtime-version: "25.08"
|
||||
sdk: org.freedesktop.Sdk
|
||||
sdk-extensions:
|
||||
- org.freedesktop.Sdk.Extension.rust-stable
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
%global cargo_install_lib 0
|
||||
|
||||
Name: pwsp
|
||||
Version: 1.6.2
|
||||
Version: 1.7.0
|
||||
Release: %autorelease
|
||||
Summary: Lets you play audio files through your microphone
|
||||
|
||||
|
||||
@@ -68,6 +68,10 @@ enum Actions {
|
||||
#[clap(short, long)]
|
||||
id: Option<u32>,
|
||||
},
|
||||
/// Play a sound by hotkey slot name
|
||||
PlayHotkey { slot: String },
|
||||
/// Remove a hotkey slot
|
||||
ClearHotkey { slot: String },
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
@@ -101,6 +105,8 @@ enum GetCommands {
|
||||
DaemonVersion,
|
||||
/// Full player state
|
||||
FullState,
|
||||
/// All hotkey slots
|
||||
Hotkeys,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
@@ -125,6 +131,10 @@ enum SetCommands {
|
||||
#[clap(short, long)]
|
||||
id: Option<u32>,
|
||||
},
|
||||
/// Assign a sound file to a hotkey slot
|
||||
Hotkey { slot: String, file_path: PathBuf },
|
||||
/// Set the key chord for a hotkey slot (e.g. "Ctrl+Alt+1")
|
||||
HotkeyKey { slot: String, key_chord: String },
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -146,6 +156,8 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
||||
concurrent,
|
||||
} => Request::play(&file_path.to_string_lossy(), concurrent),
|
||||
Actions::ToggleLoop { id } => Request::toggle_loop(id),
|
||||
Actions::PlayHotkey { slot } => Request::play_hotkey(&slot),
|
||||
Actions::ClearHotkey { slot } => Request::clear_hotkey(&slot),
|
||||
},
|
||||
Commands::Get { parameter } => match parameter {
|
||||
GetCommands::IsPaused => Request::get_is_paused(),
|
||||
@@ -158,12 +170,19 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
||||
GetCommands::Inputs => Request::get_inputs(),
|
||||
GetCommands::DaemonVersion => Request::get_daemon_version(),
|
||||
GetCommands::FullState => Request::get_full_state(),
|
||||
GetCommands::Hotkeys => Request::get_hotkeys(),
|
||||
},
|
||||
Commands::Set { parameter } => match parameter {
|
||||
SetCommands::Volume { volume, id } => Request::set_volume(volume, id),
|
||||
SetCommands::Position { position, id } => Request::seek(position, id),
|
||||
SetCommands::Input { name } => Request::set_input(&name),
|
||||
SetCommands::Loop { enabled, id } => Request::set_loop(&enabled, id),
|
||||
SetCommands::Hotkey { slot, file_path } => {
|
||||
Request::set_hotkey(&slot, &file_path.to_string_lossy())
|
||||
}
|
||||
SetCommands::HotkeyKey { slot, key_chord } => {
|
||||
Request::set_hotkey_key(&slot, &key_chord)
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+41
-9
@@ -6,9 +6,11 @@ use pwsp::{
|
||||
create_runtime_dir, get_audio_player, get_daemon_config, get_runtime_dir,
|
||||
is_daemon_running, link_player_to_virtual_mic,
|
||||
},
|
||||
global_hotkeys::start_global_hotkey_listener,
|
||||
pipewire::create_virtual_mic,
|
||||
},
|
||||
};
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::{error::Error, fs, time::Duration};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
@@ -26,18 +28,28 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
||||
|
||||
get_daemon_config(); // Initialize daemon config
|
||||
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 {
|
||||
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}"),
|
||||
}
|
||||
|
||||
sleep(Duration::from_millis(300 * i)).await;
|
||||
sleep(Duration::from_millis(1000)).await;
|
||||
}
|
||||
link_player_to_virtual_mic().await?;
|
||||
});
|
||||
|
||||
tokio::spawn(async {
|
||||
start_global_hotkey_listener().await;
|
||||
});
|
||||
|
||||
let runtime_dir = get_runtime_dir();
|
||||
|
||||
@@ -45,11 +57,15 @@ async fn main() -> Result<(), Box<dyn Error>> {
|
||||
lock_file.lock()?;
|
||||
|
||||
let socket_path = runtime_dir.join("daemon.sock");
|
||||
if fs::metadata(&socket_path).is_ok() {
|
||||
fs::remove_file(&socket_path)?;
|
||||
if let Err(e) = fs::remove_file(&socket_path) {
|
||||
if e.kind() != std::io::ErrorKind::NotFound {
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
let listener = UnixListener::bind(&socket_path)?;
|
||||
fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600))?;
|
||||
|
||||
println!(
|
||||
"Daemon started. Listening on {}",
|
||||
socket_path.to_str().unwrap_or_default()
|
||||
@@ -89,6 +105,14 @@ async fn commands_loop(listener: UnixListener) -> Result<(), Box<dyn Error>> {
|
||||
|
||||
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];
|
||||
if stream.read_exact(&mut buffer).await.is_err() {
|
||||
eprintln!("Failed to read message from client!");
|
||||
@@ -151,9 +175,17 @@ async fn commands_loop(listener: UnixListener) -> Result<(), Box<dyn Error>> {
|
||||
|
||||
async fn player_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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
+380
-29
@@ -1,13 +1,17 @@
|
||||
use crate::gui::SoundpadGui;
|
||||
use egui::{
|
||||
Align, AtomExt, Button, CollapsingHeader, Color32, ComboBox, CursorIcon, FontFamily, Label,
|
||||
Layout, RichText, ScrollArea, Sense, Slider, TextEdit, Ui, Vec2,
|
||||
Align, AtomExt, Button, CollapsingHeader, Color32, ComboBox, CursorIcon, FontFamily, Grid,
|
||||
Label, Layout, RichText, ScrollArea, Sense, Slider, TextEdit, Ui, Vec2,
|
||||
};
|
||||
use egui_dnd::dnd;
|
||||
use egui_material_icons::icons::*;
|
||||
use pwsp::types::socket::Request;
|
||||
use pwsp::types::{audio_player::TrackInfo, gui::AppState};
|
||||
use pwsp::utils::gui::format_time_pair;
|
||||
use std::{error::Error, time::Instant};
|
||||
use pwsp::utils::gui::{format_time_pair, make_request_async};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
enum TrackAction {
|
||||
Pause(u32),
|
||||
@@ -16,19 +20,33 @@ enum TrackAction {
|
||||
Stop(u32),
|
||||
}
|
||||
|
||||
enum HotkeyAction {
|
||||
Remove(String),
|
||||
Capture(String),
|
||||
ClearChord(String),
|
||||
Play(String),
|
||||
}
|
||||
|
||||
impl SoundpadGui {
|
||||
fn get_volume_icon(volume: f32) -> &'static str {
|
||||
if volume > 0.7 {
|
||||
ICON_VOLUME_UP
|
||||
ICON_VOLUME_UP.codepoint
|
||||
} else if volume <= 0.0 {
|
||||
ICON_VOLUME_OFF
|
||||
ICON_VOLUME_OFF.codepoint
|
||||
} else if volume < 0.3 {
|
||||
ICON_VOLUME_MUTE
|
||||
ICON_VOLUME_MUTE.codepoint
|
||||
} else {
|
||||
ICON_VOLUME_DOWN
|
||||
ICON_VOLUME_DOWN.codepoint
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw(&mut self, ui: &mut Ui) {
|
||||
self.draw_header(ui);
|
||||
self.draw_body(ui);
|
||||
ui.separator();
|
||||
self.draw_footer(ui);
|
||||
}
|
||||
|
||||
pub fn draw_waiting_for_daemon(&mut self, ui: &mut Ui) {
|
||||
ui.centered_and_justified(|ui| {
|
||||
ui.label(
|
||||
@@ -39,6 +57,32 @@ impl SoundpadGui {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn draw_hotkey_capture(&mut self, ui: &mut Ui) {
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.add_space(ui.available_height() / 3.0);
|
||||
ui.label(
|
||||
RichText::new("Press a key combination (e.g. Ctrl+Alt+1)")
|
||||
.size(18.0)
|
||||
.color(Color32::YELLOW)
|
||||
.monospace(),
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
let target = if let Some(slot) = &self.app_state.assigning_hotkey_slot {
|
||||
format!("for slot '{}'", slot)
|
||||
} else if let Some(path) = &self.app_state.assigning_hotkey_for_file {
|
||||
format!(
|
||||
"for '{}'",
|
||||
path.file_name().unwrap_or_default().to_string_lossy()
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
ui.label(RichText::new(target).size(16.0));
|
||||
ui.add_space(10.0);
|
||||
ui.label("Press Escape to cancel");
|
||||
});
|
||||
}
|
||||
|
||||
pub fn draw_settings(&mut self, ui: &mut Ui) {
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 5.0;
|
||||
@@ -88,12 +132,256 @@ impl SoundpadGui {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn draw(&mut self, ui: &mut Ui) -> Result<(), Box<dyn Error>> {
|
||||
self.draw_header(ui);
|
||||
self.draw_body(ui);
|
||||
pub fn draw_hotkeys(&mut self, ui: &mut Ui) {
|
||||
let area_size = ui.available_size();
|
||||
ui.vertical(|ui| {
|
||||
ui.set_min_width(area_size.x);
|
||||
ui.set_min_height(area_size.y);
|
||||
ui.spacing_mut().item_spacing.y = 5.0;
|
||||
|
||||
// Header
|
||||
ui.horizontal_top(|ui| {
|
||||
let back_button = Button::new(ICON_ARROW_BACK).frame(false);
|
||||
if ui.add(back_button).clicked() {
|
||||
self.app_state.show_hotkeys = false;
|
||||
}
|
||||
|
||||
ui.add_space(ui.available_width() / 2.0 - 40.0);
|
||||
ui.label(RichText::new("Hotkeys").color(Color32::WHITE).monospace());
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
self.draw_footer(ui);
|
||||
Ok(())
|
||||
|
||||
// Search and Add Command
|
||||
ui.horizontal(|ui| {
|
||||
ui.menu_button(format!("{} Add Command", ICON_ADD.codepoint), |ui| {
|
||||
let mut selected_cmd = None;
|
||||
if ui.button("Toggle Pause").clicked() {
|
||||
selected_cmd = Some(("cmd_toggle_pause", Request::toggle_pause(None)));
|
||||
}
|
||||
if ui.button("Stop Playback").clicked() {
|
||||
selected_cmd = Some(("cmd_stop", Request::stop(None)));
|
||||
}
|
||||
if ui.button("Pause Playback").clicked() {
|
||||
selected_cmd = Some(("cmd_pause", Request::pause(None)));
|
||||
}
|
||||
if ui.button("Resume Playback").clicked() {
|
||||
selected_cmd = Some(("cmd_resume", Request::resume(None)));
|
||||
}
|
||||
if ui.button("Toggle Loop").clicked() {
|
||||
selected_cmd = Some(("cmd_toggle_loop", Request::toggle_loop(None)));
|
||||
}
|
||||
|
||||
if let Some((slot_name, req)) = selected_cmd {
|
||||
make_request_async(Request::set_hotkey_action(slot_name, &req));
|
||||
self.app_state
|
||||
.hotkey_config
|
||||
.set_slot(slot_name.to_string(), req);
|
||||
self.app_state.assigning_hotkey_slot = Some(slot_name.to_string());
|
||||
self.app_state.hotkey_capture_active = true;
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(10.0);
|
||||
|
||||
ui.add_sized(
|
||||
[ui.available_width(), 22.0],
|
||||
TextEdit::singleline(&mut self.app_state.hotkey_search_query)
|
||||
.hint_text("Search hotkeys..."),
|
||||
);
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
ui.add_space(5.0);
|
||||
|
||||
let conflicts = self.app_state.hotkey_config.find_conflicts();
|
||||
let conflict_slots: std::collections::HashSet<String> = conflicts
|
||||
.iter()
|
||||
.flat_map(|(a, b)| vec![a.clone(), b.clone()])
|
||||
.collect();
|
||||
|
||||
let search = self.app_state.hotkey_search_query.to_lowercase();
|
||||
|
||||
// Slots table
|
||||
let mut action: Option<HotkeyAction> = None;
|
||||
let area_size = ui.available_size();
|
||||
|
||||
ScrollArea::vertical().show(ui, |ui| {
|
||||
ui.set_min_width(area_size.x);
|
||||
Grid::new("hotkeys_grid")
|
||||
.striped(true)
|
||||
.num_columns(4)
|
||||
.max_col_width(area_size.x)
|
||||
.min_col_width(area_size.x / 4.0)
|
||||
.spacing([40.0, 10.0])
|
||||
.show(ui, |ui| {
|
||||
// Table header
|
||||
ui.label(
|
||||
RichText::new("Slot")
|
||||
.strong()
|
||||
.monospace()
|
||||
.color(Color32::LIGHT_GRAY),
|
||||
);
|
||||
ui.label(
|
||||
RichText::new("Sound")
|
||||
.strong()
|
||||
.monospace()
|
||||
.color(Color32::LIGHT_GRAY),
|
||||
);
|
||||
ui.label(
|
||||
RichText::new("Key Chord")
|
||||
.strong()
|
||||
.monospace()
|
||||
.color(Color32::LIGHT_GRAY),
|
||||
);
|
||||
ui.label(
|
||||
RichText::new("Actions")
|
||||
.strong()
|
||||
.monospace()
|
||||
.color(Color32::LIGHT_GRAY),
|
||||
);
|
||||
ui.end_row();
|
||||
|
||||
let slots: Vec<_> = self
|
||||
.app_state
|
||||
.hotkey_config
|
||||
.slots
|
||||
.iter()
|
||||
.filter(|s| {
|
||||
if search.is_empty() {
|
||||
return true;
|
||||
}
|
||||
s.slot.to_lowercase().contains(&search)
|
||||
|| format!("{:?}", s.action).to_lowercase().contains(&search)
|
||||
|| s.key_chord
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.to_lowercase()
|
||||
.contains(&search)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
for slot in &slots {
|
||||
ui.horizontal(|ui| {
|
||||
// Conflict badge
|
||||
if conflict_slots.contains(&slot.slot) {
|
||||
ui.label(
|
||||
RichText::new(ICON_WARNING.codepoint)
|
||||
.color(Color32::from_rgb(255, 165, 0)),
|
||||
)
|
||||
.on_hover_text("Key chord conflict");
|
||||
}
|
||||
|
||||
// Slot name
|
||||
let slot_text = RichText::new(&slot.slot).monospace();
|
||||
ui.label(slot_text);
|
||||
});
|
||||
|
||||
// Action description
|
||||
let action_name = match slot.action.name.as_str() {
|
||||
"play" => {
|
||||
if let Some(file_path_str) = slot.action.args.get("file_path") {
|
||||
Path::new(file_path_str)
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
} else {
|
||||
"Play".to_string()
|
||||
}
|
||||
}
|
||||
"toggle_pause" => "Toggle Pause".to_string(),
|
||||
"pause" => "Pause Playback".to_string(),
|
||||
"resume" => "Resume Playback".to_string(),
|
||||
"stop" => "Stop Playback".to_string(),
|
||||
"toggle_loop" => "Toggle Loop".to_string(),
|
||||
other => other.to_string(),
|
||||
};
|
||||
ui.add(Label::new(RichText::new(action_name).monospace()).truncate());
|
||||
|
||||
// Key chord
|
||||
let chord_text = slot.key_chord.as_deref().unwrap_or("(none)");
|
||||
ui.label(RichText::new(chord_text).monospace().color(
|
||||
if slot.key_chord.is_some() {
|
||||
Color32::from_rgb(100, 200, 100)
|
||||
} else {
|
||||
Color32::GRAY
|
||||
},
|
||||
));
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
// Delete button
|
||||
if ui
|
||||
.add(Button::new(ICON_DELETE).frame(false))
|
||||
.on_hover_text("Remove slot")
|
||||
.clicked()
|
||||
{
|
||||
action = Some(HotkeyAction::Remove(slot.slot.clone()));
|
||||
}
|
||||
|
||||
// Set key chord button
|
||||
if ui
|
||||
.add(Button::new(ICON_KEYBOARD).frame(false))
|
||||
.on_hover_text("Set key chord")
|
||||
.clicked()
|
||||
{
|
||||
action = Some(HotkeyAction::Capture(slot.slot.clone()));
|
||||
}
|
||||
|
||||
// Clear key chord
|
||||
if slot.key_chord.is_some()
|
||||
&& ui
|
||||
.add(Button::new(ICON_BACKSPACE).frame(false))
|
||||
.on_hover_text("Clear key chord")
|
||||
.clicked()
|
||||
{
|
||||
action = Some(HotkeyAction::ClearChord(slot.slot.clone()));
|
||||
}
|
||||
|
||||
// Play button
|
||||
if ui
|
||||
.add(Button::new(ICON_PLAY_ARROW).frame(false))
|
||||
.on_hover_text("Play")
|
||||
.clicked()
|
||||
{
|
||||
action = Some(HotkeyAction::Play(slot.slot.clone()));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
|
||||
if slots.is_empty() {
|
||||
ui.label("No hotkey slots configured.");
|
||||
ui.label("");
|
||||
ui.label("");
|
||||
ui.label("");
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if let Some(action) = action {
|
||||
match action {
|
||||
HotkeyAction::Remove(slot) => {
|
||||
make_request_async(Request::clear_hotkey(&slot));
|
||||
self.app_state.hotkey_config.remove_slot(&slot);
|
||||
}
|
||||
HotkeyAction::Capture(slot) => {
|
||||
self.app_state.assigning_hotkey_slot = Some(slot);
|
||||
self.app_state.hotkey_capture_active = true;
|
||||
}
|
||||
HotkeyAction::ClearChord(slot) => {
|
||||
make_request_async(Request::clear_hotkey_key(&slot));
|
||||
self.app_state.hotkey_config.set_key_chord(&slot, None);
|
||||
}
|
||||
HotkeyAction::Play(slot) => {
|
||||
self.play_hotkey_slot(&slot);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn draw_header(&mut self, ui: &mut Ui) {
|
||||
@@ -313,7 +601,7 @@ impl SoundpadGui {
|
||||
let path = item.clone();
|
||||
ui.horizontal(|ui| {
|
||||
handle.ui(ui, |ui| {
|
||||
ui.label(ICON_DRAG_INDICATOR);
|
||||
ui.label(ICON_DRAG_INDICATOR.codepoint);
|
||||
});
|
||||
let name = path
|
||||
.file_name()
|
||||
@@ -345,7 +633,7 @@ impl SoundpadGui {
|
||||
// Context menu
|
||||
dir_button_response.context_menu(|ui| {
|
||||
if ui
|
||||
.button(format!("{} {}", ICON_OPEN_IN_NEW, "Show"))
|
||||
.button(format!("{} {}", ICON_OPEN_IN_NEW.codepoint, "Show"))
|
||||
.clicked()
|
||||
{
|
||||
self.open_dir(&path);
|
||||
@@ -354,7 +642,7 @@ impl SoundpadGui {
|
||||
if ui
|
||||
.button(format!(
|
||||
"{} {}",
|
||||
ICON_OPEN_IN_BROWSER, "Open in File Manager"
|
||||
ICON_OPEN_IN_BROWSER.codepoint, "Open in File Manager"
|
||||
))
|
||||
.clicked()
|
||||
{
|
||||
@@ -365,7 +653,10 @@ impl SoundpadGui {
|
||||
|
||||
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());
|
||||
}
|
||||
});
|
||||
@@ -420,11 +711,23 @@ impl SoundpadGui {
|
||||
for entry_path in files {
|
||||
let file_name = entry_path
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let mut file_button_text = RichText::new(file_name);
|
||||
ui.horizontal(|ui| {
|
||||
// Hotkey badge
|
||||
let hotkey_badge = self.get_hotkey_badge(&entry_path);
|
||||
if let Some(badge) = &hotkey_badge {
|
||||
ui.label(
|
||||
RichText::new(badge)
|
||||
.small()
|
||||
.monospace()
|
||||
.color(Color32::from_rgb(100, 200, 100)),
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
file_button_text = file_button_text.color(Color32::WHITE);
|
||||
@@ -438,7 +741,8 @@ impl SoundpadGui {
|
||||
if i.modifiers.ctrl {
|
||||
self.play_file(&entry_path, true);
|
||||
} else if i.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.play_file(&entry_path, true);
|
||||
@@ -452,20 +756,26 @@ impl SoundpadGui {
|
||||
// Context menu
|
||||
file_button_response.context_menu(|ui| {
|
||||
if ui
|
||||
.button(format!("{} {}", ICON_BOLT, "Play Solo"))
|
||||
.button(format!("{} {}", ICON_BOLT.codepoint, "Play Solo"))
|
||||
.clicked()
|
||||
{
|
||||
self.play_file(&entry_path, false);
|
||||
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.app_state.selected_file = Some(entry_path.clone());
|
||||
}
|
||||
|
||||
if ui
|
||||
.button(format!("{} {}", ICON_SWAP_HORIZ, "Replace Last"))
|
||||
.button(format!(
|
||||
"{} {}",
|
||||
ICON_SWAP_HORIZ.codepoint, "Replace Last"
|
||||
))
|
||||
.clicked()
|
||||
&& let Some(last_track) = self.audio_player_state.tracks.last()
|
||||
{
|
||||
@@ -479,7 +789,7 @@ impl SoundpadGui {
|
||||
if ui
|
||||
.button(format!(
|
||||
"{} {}",
|
||||
ICON_OPEN_IN_BROWSER, "Show in File Manager"
|
||||
ICON_OPEN_IN_BROWSER.codepoint, "Show in File Manager"
|
||||
))
|
||||
.clicked()
|
||||
{
|
||||
@@ -487,6 +797,22 @@ impl SoundpadGui {
|
||||
eprintln!("Failed to open file manager: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
if ui
|
||||
.button(format!(
|
||||
"{} {}",
|
||||
ICON_KEYBOARD.codepoint, "Assign Hotkey"
|
||||
))
|
||||
.clicked()
|
||||
{
|
||||
self.app_state.assigning_hotkey_for_file =
|
||||
Some(entry_path.clone());
|
||||
self.app_state.hotkey_capture_active = true;
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -494,13 +820,28 @@ impl SoundpadGui {
|
||||
});
|
||||
}
|
||||
|
||||
fn get_hotkey_badge(&self, path: &PathBuf) -> Option<String> {
|
||||
for slot in &self.app_state.hotkey_config.slots {
|
||||
if slot.action.name == "play" {
|
||||
if let Some(file_path_str) = slot.action.args.get("file_path") {
|
||||
if Path::new(file_path_str) == path.as_path() {
|
||||
if let Some(chord) = &slot.key_chord {
|
||||
return Some(format!("[{}]", chord));
|
||||
} else {
|
||||
return Some(format!("[{}]", slot.slot));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn draw_footer(&mut self, ui: &mut Ui) {
|
||||
ui.add_space(5.0);
|
||||
ui.horizontal(|ui| {
|
||||
// ---------- Microphone selection ----------
|
||||
let mut mics: Vec<(&String, &String)> =
|
||||
self.audio_player_state.all_inputs.iter().collect();
|
||||
mics.sort_by_key(|(k, _)| *k);
|
||||
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();
|
||||
@@ -514,7 +855,7 @@ impl SoundpadGui {
|
||||
)
|
||||
.show_ui(ui, |ui| {
|
||||
for (name, nick) in mics {
|
||||
ui.selectable_value(&mut selected_input, name.to_owned(), nick);
|
||||
ui.selectable_value(&mut selected_input, name.clone(), nick);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -552,7 +893,17 @@ impl SoundpadGui {
|
||||
}
|
||||
// ------------------------------------------
|
||||
|
||||
ui.add_space(ui.available_width() - 18.0 - ui.spacing().item_spacing.x);
|
||||
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 =
|
||||
|
||||
+261
-37
@@ -1,8 +1,160 @@
|
||||
use crate::gui::SoundpadGui;
|
||||
use egui::{Context, Id, Key, Modifiers};
|
||||
use pwsp::types::socket::Request;
|
||||
use pwsp::utils::gui::make_request_async;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Convert an egui Key + Modifiers to a normalized chord string like "Ctrl+Shift+A".
|
||||
fn chord_from_event(modifiers: &Modifiers, key: &Key) -> Option<String> {
|
||||
let key_name = match key {
|
||||
Key::A => "A",
|
||||
Key::B => "B",
|
||||
Key::C => "C",
|
||||
Key::D => "D",
|
||||
Key::E => "E",
|
||||
Key::F => "F",
|
||||
Key::G => "G",
|
||||
Key::H => "H",
|
||||
Key::I => "I",
|
||||
Key::J => "J",
|
||||
Key::K => "K",
|
||||
Key::L => "L",
|
||||
Key::M => "M",
|
||||
Key::N => "N",
|
||||
Key::O => "O",
|
||||
Key::P => "P",
|
||||
Key::Q => "Q",
|
||||
Key::R => "R",
|
||||
Key::S => "S",
|
||||
Key::T => "T",
|
||||
Key::U => "U",
|
||||
Key::V => "V",
|
||||
Key::W => "W",
|
||||
Key::X => "X",
|
||||
Key::Y => "Y",
|
||||
Key::Z => "Z",
|
||||
Key::Num0 => "0",
|
||||
Key::Num1 => "1",
|
||||
Key::Num2 => "2",
|
||||
Key::Num3 => "3",
|
||||
Key::Num4 => "4",
|
||||
Key::Num5 => "5",
|
||||
Key::Num6 => "6",
|
||||
Key::Num7 => "7",
|
||||
Key::Num8 => "8",
|
||||
Key::Num9 => "9",
|
||||
Key::F1 => "F1",
|
||||
Key::F2 => "F2",
|
||||
Key::F3 => "F3",
|
||||
Key::F4 => "F4",
|
||||
Key::F5 => "F5",
|
||||
Key::F6 => "F6",
|
||||
Key::F7 => "F7",
|
||||
Key::F8 => "F8",
|
||||
Key::F9 => "F9",
|
||||
Key::F10 => "F10",
|
||||
Key::F11 => "F11",
|
||||
Key::F12 => "F12",
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
// Require at least one modifier for hotkey chords (ignoring command/Super due to Wayland/Niri bug)
|
||||
if !modifiers.ctrl && !modifiers.alt && !modifiers.shift {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut parts = vec![];
|
||||
if modifiers.ctrl {
|
||||
parts.push("Ctrl");
|
||||
}
|
||||
if modifiers.alt {
|
||||
parts.push("Alt");
|
||||
}
|
||||
if modifiers.shift {
|
||||
parts.push("Shift");
|
||||
}
|
||||
// We intentionally ignore modifiers.command (Super) here to bypass a Wayland/Niri bug
|
||||
// where the Super key modifier is constantly active.
|
||||
|
||||
parts.push(key_name);
|
||||
|
||||
Some(parts.join("+"))
|
||||
}
|
||||
|
||||
/// Parse a chord string back to (Modifiers, Key) for matching.
|
||||
pub fn parse_chord(chord: &str) -> Option<(Modifiers, Key)> {
|
||||
let parts: Vec<&str> = chord.split('+').collect();
|
||||
if parts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut modifiers = Modifiers::NONE;
|
||||
for &part in &parts[..parts.len() - 1] {
|
||||
match part {
|
||||
"Ctrl" => modifiers.ctrl = true,
|
||||
"Alt" => modifiers.alt = true,
|
||||
"Shift" => modifiers.shift = true,
|
||||
"Super" => modifiers.command = true,
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
|
||||
let key = match parts[parts.len() - 1] {
|
||||
"A" => Key::A,
|
||||
"B" => Key::B,
|
||||
"C" => Key::C,
|
||||
"D" => Key::D,
|
||||
"E" => Key::E,
|
||||
"F" => Key::F,
|
||||
"G" => Key::G,
|
||||
"H" => Key::H,
|
||||
"I" => Key::I,
|
||||
"J" => Key::J,
|
||||
"K" => Key::K,
|
||||
"L" => Key::L,
|
||||
"M" => Key::M,
|
||||
"N" => Key::N,
|
||||
"O" => Key::O,
|
||||
"P" => Key::P,
|
||||
"Q" => Key::Q,
|
||||
"R" => Key::R,
|
||||
"S" => Key::S,
|
||||
"T" => Key::T,
|
||||
"U" => Key::U,
|
||||
"V" => Key::V,
|
||||
"W" => Key::W,
|
||||
"X" => Key::X,
|
||||
"Y" => Key::Y,
|
||||
"Z" => Key::Z,
|
||||
"0" => Key::Num0,
|
||||
"1" => Key::Num1,
|
||||
"2" => Key::Num2,
|
||||
"3" => Key::Num3,
|
||||
"4" => Key::Num4,
|
||||
"5" => Key::Num5,
|
||||
"6" => Key::Num6,
|
||||
"7" => Key::Num7,
|
||||
"8" => Key::Num8,
|
||||
"9" => Key::Num9,
|
||||
"F1" => Key::F1,
|
||||
"F2" => Key::F2,
|
||||
"F3" => Key::F3,
|
||||
"F4" => Key::F4,
|
||||
"F5" => Key::F5,
|
||||
"F6" => Key::F6,
|
||||
"F7" => Key::F7,
|
||||
"F8" => Key::F8,
|
||||
"F9" => Key::F9,
|
||||
"F10" => Key::F10,
|
||||
"F11" => Key::F11,
|
||||
"F12" => Key::F12,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some((modifiers, key))
|
||||
}
|
||||
|
||||
impl SoundpadGui {
|
||||
fn key_pressed(&self, ctx: &Context, key: Key) -> bool {
|
||||
ctx.input(|i| i.key_pressed(key))
|
||||
@@ -29,12 +181,71 @@ impl SoundpadGui {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle hotkey capture mode: listen for a key chord to assign
|
||||
if self.app_state.hotkey_capture_active {
|
||||
if self.key_pressed(ctx, Key::Escape) {
|
||||
self.app_state.hotkey_capture_active = false;
|
||||
self.app_state.assigning_hotkey_slot = None;
|
||||
self.app_state.assigning_hotkey_for_file = None;
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to capture a chord from any key press
|
||||
let captured = ctx.input(|i| {
|
||||
for event in &i.events {
|
||||
if let egui::Event::Key {
|
||||
key,
|
||||
pressed: true,
|
||||
modifiers: mods,
|
||||
..
|
||||
} = event
|
||||
&& let Some(chord) = chord_from_event(mods, key)
|
||||
{
|
||||
return Some(chord);
|
||||
}
|
||||
}
|
||||
None
|
||||
});
|
||||
|
||||
if let Some(chord) = captured {
|
||||
if let Some(slot) = self.app_state.assigning_hotkey_slot.take() {
|
||||
make_request_async(Request::set_hotkey_key(&slot, &chord));
|
||||
self.app_state
|
||||
.hotkey_config
|
||||
.set_key_chord(&slot, Some(chord));
|
||||
} else if let Some(file_path) = self.app_state.assigning_hotkey_for_file.take() {
|
||||
// Auto-create a slot from the file name
|
||||
let slot_name = file_path
|
||||
.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let action = Request::play(&file_path.to_string_lossy(), false);
|
||||
make_request_async(Request::set_hotkey_action(&slot_name, &action));
|
||||
make_request_async(Request::set_hotkey_key(&slot_name, &chord));
|
||||
self.app_state
|
||||
.hotkey_config
|
||||
.set_slot(slot_name.clone(), action);
|
||||
self.app_state
|
||||
.hotkey_config
|
||||
.set_key_chord(&slot_name, Some(chord));
|
||||
}
|
||||
self.app_state.hotkey_capture_active = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Open/close settings
|
||||
if !search_focused && self.key_pressed(ctx, Key::I) {
|
||||
self.app_state.show_settings = !self.app_state.show_settings;
|
||||
}
|
||||
|
||||
if !self.app_state.show_settings {
|
||||
// Toggle hotkeys view
|
||||
if !search_focused && self.key_pressed(ctx, Key::H) {
|
||||
self.app_state.show_hotkeys = !self.app_state.show_hotkeys;
|
||||
}
|
||||
|
||||
if !self.app_state.show_settings && !self.app_state.show_hotkeys {
|
||||
// Pause / resume audio on space
|
||||
if !search_focused && self.key_pressed(ctx, Key::Space) {
|
||||
self.play_toggle();
|
||||
@@ -57,17 +268,18 @@ impl SoundpadGui {
|
||||
}
|
||||
|
||||
// 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 self.key_pressed(ctx, Key::Enter) {
|
||||
if let Some(path) = self.app_state.selected_file.clone() {
|
||||
if modifiers.ctrl {
|
||||
self.play_file(path, true);
|
||||
self.play_file(&path, true);
|
||||
} else if modifiers.shift
|
||||
&& let Some(last_track) = self.audio_player_state.tracks.last()
|
||||
{
|
||||
self.stop(Some(last_track.id));
|
||||
self.play_file(path, true);
|
||||
self.play_file(&path, true);
|
||||
} else {
|
||||
self.play_file(path, false);
|
||||
self.play_file(&path, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,29 +291,23 @@ impl SoundpadGui {
|
||||
let mut dirs: Vec<PathBuf> = self.app_state.dirs.iter().cloned().collect();
|
||||
dirs.sort();
|
||||
|
||||
let current_dir_index: i8;
|
||||
if let Some(current_dir) = &self.app_state.current_dir {
|
||||
if let Some(index) = dirs.iter().position(|x| x == current_dir) {
|
||||
current_dir_index = index as i8;
|
||||
} else {
|
||||
current_dir_index = -1;
|
||||
}
|
||||
} else {
|
||||
current_dir_index = -1;
|
||||
}
|
||||
let current_dir_index = self
|
||||
.app_state
|
||||
.current_dir
|
||||
.as_ref()
|
||||
.and_then(|cd| dirs.iter().position(|x| x == cd));
|
||||
|
||||
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 =
|
||||
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]);
|
||||
self.open_dir(&dirs[new_dir_index]);
|
||||
} else if self.app_state.current_dir.is_some() {
|
||||
let files = self.get_filtered_files();
|
||||
|
||||
@@ -113,22 +319,40 @@ impl SoundpadGui {
|
||||
.app_state
|
||||
.selected_file
|
||||
.as_ref()
|
||||
.and_then(|f| files.iter().position(|x| x == f))
|
||||
.map(|i| i as i64)
|
||||
.unwrap_or(-1);
|
||||
.and_then(|f| files.iter().position(|x| x == f));
|
||||
|
||||
let mut new_files_index =
|
||||
current_files_index - arrow_up_pressed as i64 + arrow_down_pressed as i64;
|
||||
let new_files_index =
|
||||
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 {
|
||||
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].clone());
|
||||
}
|
||||
}
|
||||
|
||||
self.app_state.selected_file = Some(files[new_files_index as usize].clone());
|
||||
// Check for hotkey chord triggers
|
||||
let slots_to_play: Vec<String> = ctx.input(|i| {
|
||||
let mut result = vec![];
|
||||
for slot in &self.app_state.hotkey_config.slots {
|
||||
if let Some(chord) = &slot.key_chord
|
||||
&& let Some((mods, key)) = parse_chord(chord)
|
||||
&& i.modifiers == mods
|
||||
&& i.key_pressed(key)
|
||||
{
|
||||
result.push(slot.slot.clone());
|
||||
}
|
||||
}
|
||||
result
|
||||
});
|
||||
|
||||
for slot in slots_to_play {
|
||||
self.play_hotkey_slot(&slot);
|
||||
}
|
||||
}
|
||||
// });
|
||||
}
|
||||
|
||||
+16
-4
@@ -9,6 +9,7 @@ use pwsp::{
|
||||
types::{
|
||||
audio_player::PlayerState,
|
||||
config::GuiConfig,
|
||||
config::HotkeyConfig,
|
||||
gui::{AppState, AudioPlayerState},
|
||||
socket::Request,
|
||||
},
|
||||
@@ -24,8 +25,8 @@ use std::{
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
const SUPPORTED_EXTENSIONS: [&str; 11] = [
|
||||
"mp3", "wav", "ogg", "flac", "mp4", "m4a", "aac", "mov", "mkv", "webm", "avi",
|
||||
const SUPPORTED_EXTENSIONS: [&str; 12] = [
|
||||
"mp3", "wav", "ogg", "flac", "mp4", "m4a", "aac", "mov", "mkv", "mka", "webm", "avi",
|
||||
];
|
||||
|
||||
struct SoundpadGui {
|
||||
@@ -52,13 +53,17 @@ impl SoundpadGui {
|
||||
};
|
||||
|
||||
soundpad_gui.app_state.dirs = config.dirs;
|
||||
soundpad_gui.app_state.hotkey_config = HotkeyConfig::load().unwrap_or_default();
|
||||
|
||||
soundpad_gui
|
||||
}
|
||||
|
||||
pub fn play_toggle(&mut self) {
|
||||
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 {
|
||||
PlayerState::Playing => (Some(PlayerState::Paused), Some(Request::pause(None))),
|
||||
PlayerState::Paused => (Some(PlayerState::Playing), Some(Request::resume(None))),
|
||||
@@ -71,7 +76,10 @@ impl SoundpadGui {
|
||||
}
|
||||
|
||||
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.state = state;
|
||||
}
|
||||
@@ -142,6 +150,10 @@ impl SoundpadGui {
|
||||
make_request_async(Request::stop(id));
|
||||
}
|
||||
|
||||
pub fn play_hotkey_slot(&mut self, slot: &str) {
|
||||
make_request_async(Request::play_hotkey(slot));
|
||||
}
|
||||
|
||||
pub fn get_filtered_files(&self) -> Vec<PathBuf> {
|
||||
let mut files: Vec<PathBuf> = self.app_state.files.iter().cloned().collect();
|
||||
files.sort();
|
||||
|
||||
+23
-5
@@ -8,7 +8,7 @@ use pwsp::{
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
impl App for SoundpadGui {
|
||||
fn update(&mut self, ctx: &Context, _frame: &mut EFrame) {
|
||||
fn logic(&mut self, ctx: &Context, _frame: &mut EFrame) {
|
||||
// Remove directories
|
||||
for path in self.app_state.dirs_to_remove.drain() {
|
||||
self.app_state.dirs.retain(|x| x != &path);
|
||||
@@ -77,7 +77,13 @@ impl App for SoundpadGui {
|
||||
|
||||
// Sync audio player state
|
||||
{
|
||||
let guard = self.audio_player_state_shared.lock().unwrap();
|
||||
let mut guard = self
|
||||
.audio_player_state_shared
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(config) = guard.hotkey_config.take() {
|
||||
self.app_state.hotkey_config = config;
|
||||
}
|
||||
self.audio_player_state = guard.clone();
|
||||
}
|
||||
|
||||
@@ -94,23 +100,35 @@ impl App for SoundpadGui {
|
||||
|
||||
// Handle input
|
||||
self.handle_input(ctx);
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut EFrame) {
|
||||
// Draw UI
|
||||
CentralPanel::default().show(ctx, |ui| {
|
||||
CentralPanel::default().show_inside(ui, |ui| {
|
||||
if !self.audio_player_state.is_daemon_running {
|
||||
self.draw_waiting_for_daemon(ui);
|
||||
return;
|
||||
}
|
||||
|
||||
if self.app_state.hotkey_capture_active {
|
||||
self.draw_hotkey_capture(ui);
|
||||
return;
|
||||
}
|
||||
|
||||
if self.app_state.show_settings {
|
||||
self.draw_settings(ui);
|
||||
return;
|
||||
}
|
||||
|
||||
self.draw(ui).ok();
|
||||
if self.app_state.show_hotkeys {
|
||||
self.draw_hotkeys(ui);
|
||||
return;
|
||||
}
|
||||
|
||||
self.draw(ui);
|
||||
});
|
||||
|
||||
// Request repaint
|
||||
ctx.request_repaint_after_secs(1.0 / 60.0);
|
||||
ui.request_repaint_after_secs(1.0 / 60.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,12 +278,22 @@ impl AudioPlayer {
|
||||
file_path: &Path,
|
||||
concurrent: bool,
|
||||
) -> Result<u32, Box<dyn Error>> {
|
||||
if !file_path.exists() {
|
||||
return Err(format!("File does not exist: {}", file_path.display()).into());
|
||||
let path_buf = file_path.to_path_buf();
|
||||
|
||||
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)?;
|
||||
match Decoder::try_from(file) {
|
||||
let file = fs::File::open(&path_buf)?;
|
||||
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) => {
|
||||
if !concurrent {
|
||||
self.tracks.clear();
|
||||
@@ -312,7 +322,7 @@ impl AudioPlayer {
|
||||
|
||||
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 {
|
||||
if let Some(sound) = self.tracks.get_mut(&id) {
|
||||
if let Ok(file) = fs::File::open(&sound.path) {
|
||||
if let Some(sound) = self.tracks.get(&id) {
|
||||
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) {
|
||||
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.play();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.tracks
|
||||
.retain(|_, sound| !sound.sink.empty() || sound.looped);
|
||||
|
||||
+282
-26
@@ -1,9 +1,11 @@
|
||||
use crate::{
|
||||
types::{
|
||||
audio_player::{FullState, PlayerState},
|
||||
socket::Response,
|
||||
config::HotkeyConfig,
|
||||
socket::{Request, Response},
|
||||
},
|
||||
utils::{
|
||||
commands::parse_command,
|
||||
daemon::get_audio_player,
|
||||
pipewire::{get_all_devices, get_device},
|
||||
},
|
||||
@@ -90,6 +92,35 @@ pub struct GetDaemonVersionCommand {}
|
||||
|
||||
pub struct GetFullStateCommand {}
|
||||
|
||||
pub struct GetHotkeysCommand {}
|
||||
|
||||
pub struct SetHotkeyCommand {
|
||||
pub slot: Option<String>,
|
||||
pub file_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
pub struct SetHotkeyKeyCommand {
|
||||
pub slot: Option<String>,
|
||||
pub key_chord: Option<String>,
|
||||
}
|
||||
|
||||
pub struct ClearHotkeyCommand {
|
||||
pub slot: Option<String>,
|
||||
}
|
||||
|
||||
pub struct PlayHotkeyCommand {
|
||||
pub slot: Option<String>,
|
||||
}
|
||||
|
||||
pub struct SetHotkeyActionCommand {
|
||||
pub slot: Option<String>,
|
||||
pub action: Option<Request>,
|
||||
}
|
||||
|
||||
pub struct ClearHotkeyKeyCommand {
|
||||
pub slot: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Executable for PingCommand {
|
||||
async fn execute(&self) -> Response {
|
||||
@@ -107,7 +138,10 @@ impl Executable for KillCommand {
|
||||
#[async_trait]
|
||||
impl Executable for PauseCommand {
|
||||
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);
|
||||
Response::new(true, "Audio was paused")
|
||||
}
|
||||
@@ -116,7 +150,10 @@ impl Executable for PauseCommand {
|
||||
#[async_trait]
|
||||
impl Executable for ResumeCommand {
|
||||
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);
|
||||
Response::new(true, "Audio was resumed")
|
||||
}
|
||||
@@ -125,7 +162,10 @@ impl Executable for ResumeCommand {
|
||||
#[async_trait]
|
||||
impl Executable for TogglePauseCommand {
|
||||
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 {
|
||||
return Response::new(false, "Audio is not playing");
|
||||
@@ -163,7 +203,10 @@ impl Executable for TogglePauseCommand {
|
||||
#[async_trait]
|
||||
impl Executable for StopCommand {
|
||||
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);
|
||||
Response::new(true, "Audio was stopped")
|
||||
}
|
||||
@@ -172,7 +215,10 @@ impl Executable for StopCommand {
|
||||
#[async_trait]
|
||||
impl Executable for IsPausedCommand {
|
||||
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();
|
||||
Response::new(true, is_paused)
|
||||
}
|
||||
@@ -181,7 +227,10 @@ impl Executable for IsPausedCommand {
|
||||
#[async_trait]
|
||||
impl Executable for GetStateCommand {
|
||||
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();
|
||||
match serde_json::to_string(&state) {
|
||||
Ok(json) => Response::new(true, json),
|
||||
@@ -193,7 +242,10 @@ impl Executable for GetStateCommand {
|
||||
#[async_trait]
|
||||
impl Executable for GetVolumeCommand {
|
||||
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);
|
||||
|
||||
if let Some(volume) = volume {
|
||||
@@ -208,7 +260,10 @@ impl Executable for GetVolumeCommand {
|
||||
impl Executable for SetVolumeCommand {
|
||||
async fn execute(&self) -> Response {
|
||||
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);
|
||||
Response::new(true, format!("Audio volume was set to {}", volume))
|
||||
} else {
|
||||
@@ -220,7 +275,10 @@ impl Executable for SetVolumeCommand {
|
||||
#[async_trait]
|
||||
impl Executable for GetPositionCommand {
|
||||
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);
|
||||
Response::new(true, position.to_string())
|
||||
}
|
||||
@@ -230,7 +288,10 @@ impl Executable for GetPositionCommand {
|
||||
impl Executable for SeekCommand {
|
||||
async fn execute(&self) -> Response {
|
||||
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) {
|
||||
Ok(_) => Response::new(true, format!("Audio position was set to {}", position)),
|
||||
Err(err) => Response::new(false, err.to_string()),
|
||||
@@ -244,7 +305,10 @@ impl Executable for SeekCommand {
|
||||
#[async_trait]
|
||||
impl Executable for GetDurationCommand {
|
||||
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) {
|
||||
Ok(duration) => Response::new(true, duration.to_string()),
|
||||
Err(err) => Response::new(false, err.to_string()),
|
||||
@@ -256,7 +320,10 @@ impl Executable for GetDurationCommand {
|
||||
impl Executable for PlayCommand {
|
||||
async fn execute(&self) -> Response {
|
||||
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
|
||||
.play(file_path, self.concurrent.unwrap_or(false))
|
||||
.await
|
||||
@@ -273,7 +340,10 @@ impl Executable for PlayCommand {
|
||||
#[async_trait]
|
||||
impl Executable for GetTracksCommand {
|
||||
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();
|
||||
match serde_json::to_string(&tracks) {
|
||||
Ok(json) => Response::new(true, json),
|
||||
@@ -285,7 +355,10 @@ impl Executable for GetTracksCommand {
|
||||
#[async_trait]
|
||||
impl Executable for GetCurrentInputCommand {
|
||||
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 Ok(input_device) = get_device(input_device_name).await {
|
||||
Response::new(
|
||||
@@ -327,7 +400,10 @@ impl Executable for GetAllInputsCommand {
|
||||
impl Executable for SetCurrentInputCommand {
|
||||
async fn execute(&self) -> Response {
|
||||
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 {
|
||||
Ok(_) => Response::new(true, "Input device was set"),
|
||||
Err(err) => Response::new(false, err.to_string()),
|
||||
@@ -341,7 +417,10 @@ impl Executable for SetCurrentInputCommand {
|
||||
#[async_trait]
|
||||
impl Executable for SetLoopCommand {
|
||||
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 {
|
||||
Some(enabled) => {
|
||||
@@ -356,7 +435,10 @@ impl Executable for SetLoopCommand {
|
||||
#[async_trait]
|
||||
impl Executable for ToggleLoopCommand {
|
||||
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(track) = audio_player.tracks.get_mut(&id) {
|
||||
track.looped = !track.looped;
|
||||
@@ -391,21 +473,30 @@ impl Executable for GetFullStateCommand {
|
||||
let mut all_inputs = HashMap::new();
|
||||
let mut current_input_nick = String::new();
|
||||
|
||||
let audio_player = get_audio_player().await.lock().await;
|
||||
let current_input_name = audio_player.input_device_name.as_deref();
|
||||
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(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 {
|
||||
if device.name == "pwsp-virtual-mic" {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
let full_state = FullState {
|
||||
state: audio_player.get_state(),
|
||||
@@ -421,3 +512,168 @@ impl Executable for GetFullStateCommand {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Executable for GetHotkeysCommand {
|
||||
async fn execute(&self) -> Response {
|
||||
match HotkeyConfig::load() {
|
||||
Ok(config) => match serde_json::to_string(&config) {
|
||||
Ok(json) => Response::new(true, json),
|
||||
Err(err) => Response::new(false, format!("Failed to serialize hotkeys: {}", err)),
|
||||
},
|
||||
Err(err) => Response::new(false, format!("Failed to load hotkeys: {}", err)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Executable for SetHotkeyCommand {
|
||||
async fn execute(&self) -> Response {
|
||||
let Some(slot) = &self.slot else {
|
||||
return Response::new(false, "Missing slot name");
|
||||
};
|
||||
let Some(file_path) = &self.file_path else {
|
||||
return Response::new(false, "Missing file path");
|
||||
};
|
||||
|
||||
let mut config = match HotkeyConfig::load() {
|
||||
Ok(c) => c,
|
||||
Err(err) => return Response::new(false, format!("Failed to load hotkeys: {}", err)),
|
||||
};
|
||||
|
||||
config.set_slot(
|
||||
slot.clone(),
|
||||
Request::play(&file_path.to_string_lossy(), false),
|
||||
);
|
||||
|
||||
match config.save() {
|
||||
Ok(_) => Response::new(true, format!("Hotkey slot '{}' set", slot)),
|
||||
Err(err) => Response::new(false, format!("Failed to save hotkeys: {}", err)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Executable for SetHotkeyKeyCommand {
|
||||
async fn execute(&self) -> Response {
|
||||
let Some(slot) = &self.slot else {
|
||||
return Response::new(false, "Missing slot name");
|
||||
};
|
||||
let Some(key_chord) = &self.key_chord else {
|
||||
return Response::new(false, "Missing key chord");
|
||||
};
|
||||
|
||||
let mut config = match HotkeyConfig::load() {
|
||||
Ok(c) => c,
|
||||
Err(err) => return Response::new(false, format!("Failed to load hotkeys: {}", err)),
|
||||
};
|
||||
|
||||
if !config.set_key_chord(slot, Some(key_chord.clone())) {
|
||||
return Response::new(false, format!("Slot '{}' not found", slot));
|
||||
}
|
||||
|
||||
match config.save() {
|
||||
Ok(_) => Response::new(
|
||||
true,
|
||||
format!("Key chord for slot '{}' set to '{}'", slot, key_chord),
|
||||
),
|
||||
Err(err) => Response::new(false, format!("Failed to save hotkeys: {}", err)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Executable for ClearHotkeyCommand {
|
||||
async fn execute(&self) -> Response {
|
||||
let Some(slot) = &self.slot else {
|
||||
return Response::new(false, "Missing slot name");
|
||||
};
|
||||
|
||||
let mut config = match HotkeyConfig::load() {
|
||||
Ok(c) => c,
|
||||
Err(err) => return Response::new(false, format!("Failed to load hotkeys: {}", err)),
|
||||
};
|
||||
|
||||
if config.remove_slot(slot) {
|
||||
match config.save() {
|
||||
Ok(_) => Response::new(true, format!("Hotkey slot '{}' cleared", slot)),
|
||||
Err(err) => Response::new(false, format!("Failed to save hotkeys: {}", err)),
|
||||
}
|
||||
} else {
|
||||
Response::new(false, format!("Slot '{}' not found", slot))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Executable for PlayHotkeyCommand {
|
||||
async fn execute(&self) -> Response {
|
||||
let Some(slot) = &self.slot else {
|
||||
return Response::new(false, "Missing slot name");
|
||||
};
|
||||
|
||||
let config = match HotkeyConfig::load() {
|
||||
Ok(c) => c,
|
||||
Err(err) => return Response::new(false, format!("Failed to load hotkeys: {}", err)),
|
||||
};
|
||||
|
||||
let Some(hotkey_slot) = config.find_slot(slot) else {
|
||||
return Response::new(false, format!("Slot '{}' not found", slot));
|
||||
};
|
||||
|
||||
let action = hotkey_slot.action.clone();
|
||||
|
||||
if let Some(cmd) = parse_command(&action) {
|
||||
cmd.execute().await
|
||||
} else {
|
||||
Response::new(false, "Unknown command in hotkey slot".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Executable for SetHotkeyActionCommand {
|
||||
async fn execute(&self) -> Response {
|
||||
let Some(slot) = &self.slot else {
|
||||
return Response::new(false, "Missing slot name");
|
||||
};
|
||||
let Some(action) = &self.action else {
|
||||
return Response::new(false, "Missing or invalid action");
|
||||
};
|
||||
|
||||
let mut config = match HotkeyConfig::load() {
|
||||
Ok(c) => c,
|
||||
Err(err) => return Response::new(false, format!("Failed to load hotkeys: {}", err)),
|
||||
};
|
||||
|
||||
config.set_slot(slot.clone(), action.clone());
|
||||
|
||||
match config.save() {
|
||||
Ok(_) => Response::new(true, format!("Hotkey slot '{}' set", slot)),
|
||||
Err(err) => Response::new(false, format!("Failed to save hotkeys: {}", err)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Executable for ClearHotkeyKeyCommand {
|
||||
async fn execute(&self) -> Response {
|
||||
let Some(slot) = &self.slot else {
|
||||
return Response::new(false, "Missing slot name");
|
||||
};
|
||||
|
||||
let mut config = match HotkeyConfig::load() {
|
||||
Ok(c) => c,
|
||||
Err(err) => return Response::new(false, format!("Failed to load hotkeys: {}", err)),
|
||||
};
|
||||
|
||||
if !config.set_key_chord(slot, None) {
|
||||
return Response::new(false, format!("Slot '{}' not found", slot));
|
||||
}
|
||||
|
||||
match config.save() {
|
||||
Ok(_) => Response::new(true, format!("Key chord for slot '{}' cleared", slot)),
|
||||
Err(err) => Response::new(false, format!("Failed to save hotkeys: {}", err)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+125
-6
@@ -1,6 +1,6 @@
|
||||
use crate::utils::config::get_config_path;
|
||||
use crate::{types::socket::Request, utils::config::get_config_path};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{error::Error, fs, path::PathBuf};
|
||||
use std::{collections::HashMap, error::Error, fs, path::PathBuf};
|
||||
|
||||
#[derive(Default, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
@@ -12,11 +12,12 @@ pub struct DaemonConfig {
|
||||
impl DaemonConfig {
|
||||
pub fn save_to_file(&self) -> Result<(), Box<dyn Error>> {
|
||||
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() {
|
||||
fs::create_dir_all(config_dir)?;
|
||||
}
|
||||
}
|
||||
|
||||
let config_json = serde_json::to_string_pretty(self)?;
|
||||
fs::write(config_path, config_json.as_bytes())?;
|
||||
@@ -26,7 +27,10 @@ impl DaemonConfig {
|
||||
pub fn load_from_file() -> Result<DaemonConfig, Box<dyn Error>> {
|
||||
let config_path = get_config_path()?.join("daemon.json");
|
||||
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 {
|
||||
pub fn save_to_file(&mut self) -> Result<(), Box<dyn Error>> {
|
||||
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() {
|
||||
fs::create_dir_all(config_dir)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Do not save scale factor if user does not want to
|
||||
if !self.save_scale_factor {
|
||||
@@ -82,6 +87,120 @@ impl GuiConfig {
|
||||
pub fn load_from_file() -> Result<GuiConfig, Box<dyn Error>> {
|
||||
let config_path = get_config_path()?.join("gui.json");
|
||||
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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct HotkeySlot {
|
||||
pub slot: String,
|
||||
pub action: Request,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub key_chord: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct HotkeyConfig {
|
||||
#[serde(default)]
|
||||
pub slots: Vec<HotkeySlot>,
|
||||
}
|
||||
|
||||
impl HotkeyConfig {
|
||||
pub fn config_path() -> Result<PathBuf, Box<dyn Error>> {
|
||||
Ok(get_config_path()?.join("hotkeys.json"))
|
||||
}
|
||||
|
||||
pub fn load() -> Result<HotkeyConfig, Box<dyn Error>> {
|
||||
let path = Self::config_path()?;
|
||||
if !path.exists() {
|
||||
return Ok(HotkeyConfig::default());
|
||||
}
|
||||
let bytes = fs::read(&path)?;
|
||||
match serde_json::from_slice::<HotkeyConfig>(&bytes) {
|
||||
Ok(config) => Ok(config),
|
||||
Err(_) => Ok(HotkeyConfig::default()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<(), Box<dyn Error>> {
|
||||
let path = Self::config_path()?;
|
||||
if let Some(dir) = path.parent()
|
||||
&& !dir.exists()
|
||||
{
|
||||
fs::create_dir_all(dir)?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(self)?;
|
||||
fs::write(path, json.as_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn find_slot(&self, slot: &str) -> Option<&HotkeySlot> {
|
||||
self.slots.iter().find(|s| s.slot == slot)
|
||||
}
|
||||
|
||||
pub fn find_slot_mut(&mut self, slot: &str) -> Option<&mut HotkeySlot> {
|
||||
self.slots.iter_mut().find(|s| s.slot == slot)
|
||||
}
|
||||
|
||||
pub fn set_slot(&mut self, slot: String, action: Request) {
|
||||
if let Some(existing) = self.find_slot_mut(&slot) {
|
||||
existing.action = action;
|
||||
} else {
|
||||
self.slots.push(HotkeySlot {
|
||||
slot,
|
||||
action,
|
||||
key_chord: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_key_chord(&mut self, slot: &str, key_chord: Option<String>) -> bool {
|
||||
if let Some(existing) = self.find_slot_mut(slot) {
|
||||
existing.key_chord = key_chord;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_slot(&mut self, slot: &str) -> bool {
|
||||
let len = self.slots.len();
|
||||
self.slots.retain(|s| s.slot != slot);
|
||||
self.slots.len() != len
|
||||
}
|
||||
|
||||
/// Returns pairs of slot names that share the same key chord.
|
||||
pub fn find_conflicts(&self) -> Vec<(String, String)> {
|
||||
let mut conflicts = vec![];
|
||||
let mut chord_map: HashMap<&str, Vec<&str>> = HashMap::new();
|
||||
|
||||
for s in &self.slots {
|
||||
if let Some(chord) = &s.key_chord {
|
||||
chord_map.entry(chord.as_str()).or_default().push(&s.slot);
|
||||
}
|
||||
}
|
||||
|
||||
for slots in chord_map.values() {
|
||||
if slots.len() > 1 {
|
||||
for i in 0..slots.len() {
|
||||
for j in (i + 1)..slots.len() {
|
||||
conflicts.push((slots[i].to_string(), slots[j].to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
conflicts
|
||||
}
|
||||
|
||||
/// Find which slot(s) have the given key chord.
|
||||
pub fn slots_for_chord(&self, chord: &str) -> Vec<&HotkeySlot> {
|
||||
self.slots
|
||||
.iter()
|
||||
.filter(|s| s.key_chord.as_deref() == Some(chord))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
+14
-1
@@ -1,4 +1,7 @@
|
||||
use crate::types::audio_player::{PlayerState, TrackInfo};
|
||||
use crate::types::{
|
||||
audio_player::{PlayerState, TrackInfo},
|
||||
config::HotkeyConfig,
|
||||
};
|
||||
|
||||
use egui::Id;
|
||||
|
||||
@@ -42,6 +45,13 @@ pub struct AppState {
|
||||
|
||||
pub selected_file: Option<PathBuf>,
|
||||
pub files: HashSet<PathBuf>,
|
||||
|
||||
pub show_hotkeys: bool,
|
||||
pub hotkey_config: HotkeyConfig,
|
||||
pub hotkey_search_query: String,
|
||||
pub assigning_hotkey_slot: Option<String>,
|
||||
pub assigning_hotkey_for_file: Option<PathBuf>,
|
||||
pub hotkey_capture_active: bool,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone)]
|
||||
@@ -55,6 +65,9 @@ pub struct AudioPlayerState {
|
||||
|
||||
pub current_input: String,
|
||||
pub all_inputs: HashMap<String, String>,
|
||||
pub all_inputs_sorted: Vec<(String, String)>,
|
||||
|
||||
pub is_daemon_running: bool,
|
||||
|
||||
pub hotkey_config: Option<HotkeyConfig>,
|
||||
}
|
||||
|
||||
+36
-1
@@ -1,7 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Request {
|
||||
pub name: String,
|
||||
pub args: HashMap<String, String>,
|
||||
@@ -173,6 +173,41 @@ impl Request {
|
||||
pub fn get_full_state() -> Self {
|
||||
Request::new("get_full_state", vec![])
|
||||
}
|
||||
|
||||
pub fn get_hotkeys() -> Self {
|
||||
Request::new("get_hotkeys", vec![])
|
||||
}
|
||||
|
||||
pub fn set_hotkey(slot: &str, file_path: &str) -> Self {
|
||||
Request::new("set_hotkey", vec![("slot", slot), ("file_path", file_path)])
|
||||
}
|
||||
|
||||
pub fn set_hotkey_key(slot: &str, key_chord: &str) -> Self {
|
||||
Request::new(
|
||||
"set_hotkey_key",
|
||||
vec![("slot", slot), ("key_chord", key_chord)],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn clear_hotkey(slot: &str) -> Self {
|
||||
Request::new("clear_hotkey", vec![("slot", slot)])
|
||||
}
|
||||
|
||||
pub fn play_hotkey(slot: &str) -> Self {
|
||||
Request::new("play_hotkey", vec![("slot", slot)])
|
||||
}
|
||||
|
||||
pub fn set_hotkey_action(slot: &str, action: &Request) -> Self {
|
||||
let action_json = serde_json::to_string(action).unwrap_or_default();
|
||||
Request::new(
|
||||
"set_hotkey_action",
|
||||
vec![("slot", slot), ("action", &action_json)],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn clear_hotkey_key(slot: &str) -> Self {
|
||||
Request::new("clear_hotkey_key", vec![("slot", slot)])
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -72,6 +72,40 @@ pub fn parse_command(request: &Request) -> Option<Box<dyn Executable + Send>> {
|
||||
"toggle_loop" => Some(Box::new(ToggleLoopCommand { id })),
|
||||
"get_daemon_version" => Some(Box::new(GetDaemonVersionCommand {})),
|
||||
"get_full_state" => Some(Box::new(GetFullStateCommand {})),
|
||||
"get_hotkeys" => Some(Box::new(GetHotkeysCommand {})),
|
||||
"set_hotkey" => {
|
||||
let slot = request.args.get("slot").cloned();
|
||||
let file_path = request
|
||||
.args
|
||||
.get("file_path")
|
||||
.and_then(|s| s.parse::<PathBuf>().ok());
|
||||
Some(Box::new(SetHotkeyCommand { slot, file_path }))
|
||||
}
|
||||
"set_hotkey_key" => {
|
||||
let slot = request.args.get("slot").cloned();
|
||||
let key_chord = request.args.get("key_chord").cloned();
|
||||
Some(Box::new(SetHotkeyKeyCommand { slot, key_chord }))
|
||||
}
|
||||
"clear_hotkey" => {
|
||||
let slot = request.args.get("slot").cloned();
|
||||
Some(Box::new(ClearHotkeyCommand { slot }))
|
||||
}
|
||||
"play_hotkey" => {
|
||||
let slot = request.args.get("slot").cloned();
|
||||
Some(Box::new(PlayHotkeyCommand { slot }))
|
||||
}
|
||||
"set_hotkey_action" => {
|
||||
let slot = request.args.get("slot").cloned();
|
||||
let action = request
|
||||
.args
|
||||
.get("action")
|
||||
.and_then(|s| serde_json::from_str::<Request>(s).ok());
|
||||
Some(Box::new(SetHotkeyActionCommand { slot, action }))
|
||||
}
|
||||
"clear_hotkey_key" => {
|
||||
let slot = request.args.get("slot").cloned();
|
||||
Some(Box::new(ClearHotkeyKeyCommand { slot }))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
+8
-3
@@ -6,6 +6,7 @@ use crate::{
|
||||
},
|
||||
utils::pipewire::{create_link, get_device},
|
||||
};
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::PathBuf;
|
||||
use std::{error::Error, fs};
|
||||
use tokio::{
|
||||
@@ -17,11 +18,14 @@ use tokio::{
|
||||
|
||||
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
|
||||
.get_or_init(|| async {
|
||||
.get_or_try_init(|| async {
|
||||
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
|
||||
}
|
||||
@@ -81,6 +85,7 @@ pub fn create_runtime_dir() -> Result<(), Box<dyn Error>> {
|
||||
if !runtime_dir.exists() {
|
||||
fs::create_dir_all(&runtime_dir)?;
|
||||
}
|
||||
fs::set_permissions(&runtime_dir, fs::Permissions::from_mode(0o700))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
use crate::{types::config::HotkeyConfig, utils::commands::parse_command};
|
||||
use evdev::{Device, EventStream, EventSummary, KeyCode};
|
||||
|
||||
struct ModifierState {
|
||||
ctrl: bool,
|
||||
alt: bool,
|
||||
shift: bool,
|
||||
meta: bool,
|
||||
}
|
||||
|
||||
impl ModifierState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
shift: false,
|
||||
meta: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, key: KeyCode, pressed: bool) {
|
||||
match key {
|
||||
KeyCode::KEY_LEFTCTRL | KeyCode::KEY_RIGHTCTRL => self.ctrl = pressed,
|
||||
KeyCode::KEY_LEFTALT | KeyCode::KEY_RIGHTALT => self.alt = pressed,
|
||||
KeyCode::KEY_LEFTSHIFT | KeyCode::KEY_RIGHTSHIFT => self.shift = pressed,
|
||||
KeyCode::KEY_LEFTMETA | KeyCode::KEY_RIGHTMETA => self.meta = pressed,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn any_active(&self) -> bool {
|
||||
self.ctrl || self.alt || self.shift || self.meta
|
||||
}
|
||||
|
||||
fn is_modifier(key: KeyCode) -> bool {
|
||||
matches!(
|
||||
key,
|
||||
KeyCode::KEY_LEFTCTRL
|
||||
| KeyCode::KEY_RIGHTCTRL
|
||||
| KeyCode::KEY_LEFTALT
|
||||
| KeyCode::KEY_RIGHTALT
|
||||
| KeyCode::KEY_LEFTSHIFT
|
||||
| KeyCode::KEY_RIGHTSHIFT
|
||||
| KeyCode::KEY_LEFTMETA
|
||||
| KeyCode::KEY_RIGHTMETA
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn evdev_key_name(key: KeyCode) -> Option<&'static str> {
|
||||
match key {
|
||||
KeyCode::KEY_A => Some("A"),
|
||||
KeyCode::KEY_B => Some("B"),
|
||||
KeyCode::KEY_C => Some("C"),
|
||||
KeyCode::KEY_D => Some("D"),
|
||||
KeyCode::KEY_E => Some("E"),
|
||||
KeyCode::KEY_F => Some("F"),
|
||||
KeyCode::KEY_G => Some("G"),
|
||||
KeyCode::KEY_H => Some("H"),
|
||||
KeyCode::KEY_I => Some("I"),
|
||||
KeyCode::KEY_J => Some("J"),
|
||||
KeyCode::KEY_K => Some("K"),
|
||||
KeyCode::KEY_L => Some("L"),
|
||||
KeyCode::KEY_M => Some("M"),
|
||||
KeyCode::KEY_N => Some("N"),
|
||||
KeyCode::KEY_O => Some("O"),
|
||||
KeyCode::KEY_P => Some("P"),
|
||||
KeyCode::KEY_Q => Some("Q"),
|
||||
KeyCode::KEY_R => Some("R"),
|
||||
KeyCode::KEY_S => Some("S"),
|
||||
KeyCode::KEY_T => Some("T"),
|
||||
KeyCode::KEY_U => Some("U"),
|
||||
KeyCode::KEY_V => Some("V"),
|
||||
KeyCode::KEY_W => Some("W"),
|
||||
KeyCode::KEY_X => Some("X"),
|
||||
KeyCode::KEY_Y => Some("Y"),
|
||||
KeyCode::KEY_Z => Some("Z"),
|
||||
KeyCode::KEY_1 => Some("1"),
|
||||
KeyCode::KEY_2 => Some("2"),
|
||||
KeyCode::KEY_3 => Some("3"),
|
||||
KeyCode::KEY_4 => Some("4"),
|
||||
KeyCode::KEY_5 => Some("5"),
|
||||
KeyCode::KEY_6 => Some("6"),
|
||||
KeyCode::KEY_7 => Some("7"),
|
||||
KeyCode::KEY_8 => Some("8"),
|
||||
KeyCode::KEY_9 => Some("9"),
|
||||
KeyCode::KEY_0 => Some("0"),
|
||||
KeyCode::KEY_F1 => Some("F1"),
|
||||
KeyCode::KEY_F2 => Some("F2"),
|
||||
KeyCode::KEY_F3 => Some("F3"),
|
||||
KeyCode::KEY_F4 => Some("F4"),
|
||||
KeyCode::KEY_F5 => Some("F5"),
|
||||
KeyCode::KEY_F6 => Some("F6"),
|
||||
KeyCode::KEY_F7 => Some("F7"),
|
||||
KeyCode::KEY_F8 => Some("F8"),
|
||||
KeyCode::KEY_F9 => Some("F9"),
|
||||
KeyCode::KEY_F10 => Some("F10"),
|
||||
KeyCode::KEY_F11 => Some("F11"),
|
||||
KeyCode::KEY_F12 => Some("F12"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_chord(modifiers: &ModifierState, key_name: &str) -> String {
|
||||
let mut parts = Vec::with_capacity(5);
|
||||
if modifiers.ctrl {
|
||||
parts.push("Ctrl");
|
||||
}
|
||||
if modifiers.alt {
|
||||
parts.push("Alt");
|
||||
}
|
||||
if modifiers.shift {
|
||||
parts.push("Shift");
|
||||
}
|
||||
if modifiers.meta {
|
||||
parts.push("Super");
|
||||
}
|
||||
parts.push(key_name);
|
||||
parts.join("+")
|
||||
}
|
||||
|
||||
fn is_keyboard(device: &Device) -> bool {
|
||||
device
|
||||
.supported_keys()
|
||||
.is_some_and(|keys| keys.contains(KeyCode::KEY_A) && keys.contains(KeyCode::KEY_Z))
|
||||
}
|
||||
|
||||
async fn handle_device_events(mut stream: EventStream) {
|
||||
let mut modifiers = ModifierState::new();
|
||||
|
||||
loop {
|
||||
match stream.next_event().await {
|
||||
Ok(event) => {
|
||||
if let EventSummary::Key(_, key, value) = event.destructure() {
|
||||
// 0 = released, 1 = pressed, 2 = repeat
|
||||
if value == 0 || value == 1 {
|
||||
modifiers.update(key, value == 1);
|
||||
}
|
||||
|
||||
// Only trigger on press, skip modifiers and bare keys
|
||||
if value != 1 || ModifierState::is_modifier(key) || !modifiers.any_active() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(key_name) = evdev_key_name(key) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let chord = build_chord(&modifiers, key_name);
|
||||
|
||||
let config = match HotkeyConfig::load() {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let slots = config.slots_for_chord(&chord);
|
||||
for slot in slots {
|
||||
if let Some(cmd) = parse_command(&slot.action) {
|
||||
cmd.execute().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Global hotkeys: device read error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start_global_hotkey_listener() {
|
||||
let keyboards: Vec<_> = evdev::enumerate()
|
||||
.filter(|(_, dev)| is_keyboard(dev))
|
||||
.collect();
|
||||
|
||||
if keyboards.is_empty() {
|
||||
eprintln!(
|
||||
"Global hotkeys: no keyboard devices found. \
|
||||
Make sure your user is in the 'input' group."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
println!(
|
||||
"Global hotkeys: found {} keyboard device(s)",
|
||||
keyboards.len()
|
||||
);
|
||||
|
||||
for (path, device) in keyboards {
|
||||
match device.into_event_stream() {
|
||||
Ok(stream) => {
|
||||
println!("Global hotkeys: listening on {}", path.display());
|
||||
tokio::spawn(handle_device_events(stream));
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Global hotkeys: failed to open {}: {}", path.display(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
-3
@@ -1,7 +1,7 @@
|
||||
use crate::{
|
||||
types::{
|
||||
audio_player::FullState,
|
||||
config::GuiConfig,
|
||||
config::{GuiConfig, HotkeyConfig},
|
||||
gui::AudioPlayerState,
|
||||
socket::{Request, Response},
|
||||
},
|
||||
@@ -10,6 +10,7 @@ use crate::{
|
||||
use std::{
|
||||
error::Error,
|
||||
sync::{Arc, Mutex},
|
||||
time::Instant,
|
||||
};
|
||||
use tokio::time::{Duration, sleep};
|
||||
|
||||
@@ -49,13 +50,16 @@ pub fn format_time_pair(position: f32, duration: f32) -> String {
|
||||
pub fn start_app_state_thread(audio_player_state_shared: Arc<Mutex<AudioPlayerState>>) {
|
||||
tokio::spawn(async move {
|
||||
let sleep_duration = Duration::from_secs_f32(1.0 / 60.0);
|
||||
let mut last_hotkey_poll = Instant::now();
|
||||
|
||||
loop {
|
||||
let is_running = is_daemon_running().unwrap_or(false);
|
||||
|
||||
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;
|
||||
}
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
@@ -69,7 +73,9 @@ pub fn start_app_state_thread(audio_player_state_shared: Arc<Mutex<AudioPlayerSt
|
||||
let full_state: FullState =
|
||||
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() {
|
||||
Some(new_state) => {
|
||||
@@ -86,10 +92,37 @@ pub fn start_app_state_thread(audio_player_state_shared: Arc<Mutex<AudioPlayerSt
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
if 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;
|
||||
}
|
||||
|
||||
// Poll hotkey config at a lower frequency (~every 2 seconds)
|
||||
if last_hotkey_poll.elapsed() >= Duration::from_secs(2) {
|
||||
let hotkey_res = make_request(Request::get_hotkeys())
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if hotkey_res.status {
|
||||
if let Ok(config) = serde_json::from_str::<HotkeyConfig>(&hotkey_res.message) {
|
||||
let mut guard = audio_player_state_shared
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
guard.hotkey_config = Some(config);
|
||||
}
|
||||
}
|
||||
last_hotkey_poll = Instant::now();
|
||||
}
|
||||
|
||||
sleep(sleep_duration).await;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod commands;
|
||||
pub mod config;
|
||||
pub mod daemon;
|
||||
pub mod global_hotkeys;
|
||||
pub mod gui;
|
||||
pub mod pipewire;
|
||||
|
||||
+16
-18
@@ -57,10 +57,11 @@ fn parse_global_object(
|
||||
};
|
||||
// Check if the object is a port
|
||||
} else if props.get("port.direction").is_some() {
|
||||
let node_id = props.get("node.id").unwrap().parse::<u32>().unwrap();
|
||||
let port_id = props.get("port.id").unwrap().parse::<u32>().unwrap();
|
||||
let port_name = props.get("port.name").unwrap();
|
||||
|
||||
if 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,
|
||||
@@ -70,6 +71,7 @@ fn parse_global_object(
|
||||
return (None, Some(port));
|
||||
}
|
||||
}
|
||||
}
|
||||
(None, None)
|
||||
}
|
||||
|
||||
@@ -151,8 +153,7 @@ pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>), B
|
||||
for port in ports {
|
||||
let node_id = port.node_id;
|
||||
|
||||
if input_devices.contains_key(&node_id) {
|
||||
let input_device = input_devices.get_mut(&node_id).unwrap();
|
||||
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),
|
||||
@@ -170,8 +171,7 @@ pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>), B
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
} else if output_devices.contains_key(&node_id) {
|
||||
let output_device = output_devices.get_mut(&node_id).unwrap();
|
||||
} 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),
|
||||
@@ -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>> {
|
||||
let (mut input_devices, output_devices) = get_all_devices().await?;
|
||||
input_devices.extend(output_devices);
|
||||
let (input_devices, output_devices) = get_all_devices().await?;
|
||||
|
||||
for device in input_devices {
|
||||
if device.name == device_name
|
||||
input_devices
|
||||
.into_iter()
|
||||
.chain(output_devices)
|
||||
.find(|device| {
|
||||
device.name == device_name
|
||||
|| device.nick == device_name
|
||||
|| device.name.contains(device_name)
|
||||
|| device.nick.contains(device_name)
|
||||
{
|
||||
return Ok(device);
|
||||
}
|
||||
}
|
||||
|
||||
Err("Device not found".into())
|
||||
})
|
||||
.ok_or_else(|| "Device not found".into())
|
||||
}
|
||||
|
||||
pub fn create_virtual_mic() -> Result<pipewire::channel::Sender<Terminate>, Box<dyn Error>> {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user