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:
+415
-68
@@ -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,6 +20,13 @@ 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 {
|
||||
@@ -29,6 +40,13 @@ impl SoundpadGui {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
ui.separator();
|
||||
self.draw_footer(ui);
|
||||
Ok(())
|
||||
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();
|
||||
|
||||
// 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) {
|
||||
@@ -427,72 +715,104 @@ impl SoundpadGui {
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
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);
|
||||
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 file_button = Button::new(file_button_text).frame(false);
|
||||
let file_button_response = ui.add(file_button);
|
||||
if file_button_response.clicked() {
|
||||
ui.input(|i| {
|
||||
if i.modifiers.ctrl {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
let file_button = Button::new(file_button_text).frame(false);
|
||||
let file_button_response = ui.add(file_button);
|
||||
if file_button_response.clicked() {
|
||||
ui.input(|i| {
|
||||
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()
|
||||
{
|
||||
self.stop(Some(last_track.id));
|
||||
self.play_file(&entry_path, true);
|
||||
} else {
|
||||
self.play_file(&entry_path, false);
|
||||
}
|
||||
});
|
||||
self.app_state.selected_file = Some(entry_path.clone());
|
||||
}
|
||||
|
||||
// Context menu
|
||||
file_button_response.context_menu(|ui| {
|
||||
if ui
|
||||
.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.codepoint, "Add New"))
|
||||
.clicked()
|
||||
{
|
||||
self.play_file(&entry_path, true);
|
||||
} else if i.modifiers.shift
|
||||
self.app_state.selected_file = Some(entry_path.clone());
|
||||
}
|
||||
|
||||
if ui
|
||||
.button(format!(
|
||||
"{} {}",
|
||||
ICON_SWAP_HORIZ.codepoint, "Replace Last"
|
||||
))
|
||||
.clicked()
|
||||
&& let Some(last_track) = self.audio_player_state.tracks.last()
|
||||
{
|
||||
self.stop(Some(last_track.id));
|
||||
self.play_file(&entry_path, true);
|
||||
} else {
|
||||
self.play_file(&entry_path, false);
|
||||
self.app_state.selected_file = Some(entry_path.clone());
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
if ui
|
||||
.button(format!(
|
||||
"{} {}",
|
||||
ICON_OPEN_IN_BROWSER.codepoint, "Show in File Manager"
|
||||
))
|
||||
.clicked()
|
||||
{
|
||||
if let Err(e) = opener::reveal(&entry_path) {
|
||||
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();
|
||||
}
|
||||
});
|
||||
self.app_state.selected_file = Some(entry_path.clone());
|
||||
}
|
||||
|
||||
// Context menu
|
||||
file_button_response.context_menu(|ui| {
|
||||
if ui
|
||||
.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.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.codepoint, "Replace Last"))
|
||||
.clicked()
|
||||
&& let Some(last_track) = self.audio_player_state.tracks.last()
|
||||
{
|
||||
self.stop(Some(last_track.id));
|
||||
self.play_file(&entry_path, true);
|
||||
self.app_state.selected_file = Some(entry_path.clone());
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
if ui
|
||||
.button(format!(
|
||||
"{} {}",
|
||||
ICON_OPEN_IN_BROWSER.codepoint, "Show in File Manager"
|
||||
))
|
||||
.clicked()
|
||||
{
|
||||
if let Err(e) = opener::reveal(&entry_path) {
|
||||
eprintln!("Failed to open file manager: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -500,6 +820,23 @@ 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| {
|
||||
@@ -556,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 =
|
||||
|
||||
Reference in New Issue
Block a user