mirror of
https://github.com/arabianq/pipewire-soundpad.git
synced 2026-04-28 06:21:23 +00:00
feat: add hotkey system (#48)
* feat: add hotkey system for playing individual sounds Slot-based hotkey mappings stored in ~/.config/pwsp/hotkeys.json. Daemon serves hotkey IPC commands for CLI/compositor bindings. GUI supports focused hotkey triggers, a dedicated Hotkeys panel with search and conflict detection, file badges, and a key chord capture dialog. CLI gains play-hotkey, get hotkeys, set hotkey, set hotkey-key, and clear-hotkey subcommands. * feat: add global hotkey support via evdev Listen for keyboard events directly from /dev/input using evdev, enabling hotkeys to work system-wide regardless of window focus or display server (X11, GNOME, KDE Plasma, Hyprland). The daemon spawns async listeners for each keyboard device at startup, tracks modifier state, and triggers playback when a configured chord matches. Requires the user to be in the 'input' group; logs a warning and continues without global hotkeys if devices are inaccessible. * various changes * refactor: route hotkey mutations through daemon IPC GUI no longer writes hotkey config directly to disk. Instead, all mutations (set slot, set key chord, clear chord, remove slot) are sent to the daemon via IPC, which persists the changes. The state thread periodically syncs the hotkey config back from the daemon, so CLI-made changes are reflected in the GUI. New IPC commands: set_hotkey_action (arbitrary action per slot), clear_hotkey_key (remove key chord without removing the slot). Also removes unreachable capture overlay from draw_hotkeys(). * small refactor --------- Co-authored-by: arabian <a.tevg@ya.ru>
This commit is contained in:
+197
-1
@@ -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 {
|
||||
@@ -481,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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+113
-2
@@ -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)]
|
||||
@@ -93,3 +93,114 @@ impl GuiConfig {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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()
|
||||
}
|
||||
}
|
||||
|
||||
+13
-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)]
|
||||
@@ -58,4 +68,6 @@ pub struct AudioPlayerState {
|
||||
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)]
|
||||
|
||||
Reference in New Issue
Block a user