diff --git a/Cargo.lock b/Cargo.lock index 493b0e3..482522e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -341,6 +341,18 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "block2" version = "0.5.1" @@ -1157,6 +1169,19 @@ dependencies = [ "num-traits", ] +[[package]] +name = "evdev" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25b686663ba7f08d92880ff6ba22170f1df4e83629341cba34cf82cd65ebea99" +dependencies = [ + "bitvec", + "cfg-if", + "libc", + "nix 0.29.0", + "tokio", +] + [[package]] name = "event-listener" version = "5.4.1" @@ -1301,6 +1326,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures-core" version = "0.3.32" @@ -1935,7 +1966,7 @@ dependencies = [ "cookie-factory", "libc", "libspa-sys", - "nix", + "nix 0.30.1", "nom 8.0.0", "system-deps", ] @@ -2121,6 +2152,18 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nix" version = "0.30.1" @@ -2717,7 +2760,7 @@ dependencies = [ "libc", "libspa", "libspa-sys", - "nix", + "nix 0.30.1", "once_cell", "pipewire-sys", "thiserror 2.0.18", @@ -2870,6 +2913,7 @@ dependencies = [ "egui", "egui_dnd", "egui_material_icons", + "evdev", "itertools 0.14.0", "opener", "pipewire", @@ -2922,6 +2966,12 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3628,6 +3678,12 @@ dependencies = [ "version-compare", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "target-lexicon" version = "0.13.3" @@ -4873,6 +4929,15 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "x11-dl" version = "2.21.0" diff --git a/Cargo.toml b/Cargo.toml index 0f75dd0..dc05b5b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ 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", ] } diff --git a/src/bin/cli.rs b/src/bin/cli.rs index 5ea8cea..c509ff5 100644 --- a/src/bin/cli.rs +++ b/src/bin/cli.rs @@ -68,6 +68,10 @@ enum Actions { #[clap(short, long)] id: Option, }, + /// 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, }, + /// 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> { 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> { 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) + } }, }; diff --git a/src/bin/daemon.rs b/src/bin/daemon.rs index f0fadc7..ee9fcc6 100644 --- a/src/bin/daemon.rs +++ b/src/bin/daemon.rs @@ -6,6 +6,7 @@ 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, }, }; @@ -46,6 +47,10 @@ async fn main() -> Result<(), Box> { } }); + tokio::spawn(async { + start_global_hotkey_listener().await; + }); + let runtime_dir = get_runtime_dir(); let lock_file = fs::File::create(runtime_dir.join("daemon.lock"))?; diff --git a/src/gui/draw.rs b/src/gui/draw.rs index ae9f7f6..81a6376 100644 --- a/src/gui/draw.rs +++ b/src/gui/draw.rs @@ -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> { - 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 = 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 = 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 { + 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 = diff --git a/src/gui/input.rs b/src/gui/input.rs index 52a00b1..9ca1c7e 100644 --- a/src/gui/input.rs +++ b/src/gui/input.rs @@ -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 { + 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(); @@ -123,6 +334,25 @@ impl SoundpadGui { self.app_state.selected_file = Some(files[new_files_index].clone()); } } + + // Check for hotkey chord triggers + let slots_to_play: Vec = 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); + } } // }); } diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 648583a..5114fae 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -9,6 +9,7 @@ use pwsp::{ types::{ audio_player::PlayerState, config::GuiConfig, + config::HotkeyConfig, gui::{AppState, AudioPlayerState}, socket::Request, }, @@ -52,6 +53,7 @@ impl SoundpadGui { }; soundpad_gui.app_state.dirs = config.dirs; + soundpad_gui.app_state.hotkey_config = HotkeyConfig::load().unwrap_or_default(); soundpad_gui } @@ -148,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 { let mut files: Vec = self.app_state.files.iter().cloned().collect(); files.sort(); diff --git a/src/gui/update.rs b/src/gui/update.rs index 764edd4..888c792 100644 --- a/src/gui/update.rs +++ b/src/gui/update.rs @@ -77,10 +77,13 @@ impl App for SoundpadGui { // Sync audio player state { - let guard = self + 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(); } @@ -107,12 +110,22 @@ impl App for SoundpadGui { 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 diff --git a/src/types/commands.rs b/src/types/commands.rs index 9e0ce29..2aa2dbe 100644 --- a/src/types/commands.rs +++ b/src/types/commands.rs @@ -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, + pub file_path: Option, +} + +pub struct SetHotkeyKeyCommand { + pub slot: Option, + pub key_chord: Option, +} + +pub struct ClearHotkeyCommand { + pub slot: Option, +} + +pub struct PlayHotkeyCommand { + pub slot: Option, +} + +pub struct SetHotkeyActionCommand { + pub slot: Option, + pub action: Option, +} + +pub struct ClearHotkeyKeyCommand { + pub slot: Option, +} + #[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)), + } + } +} diff --git a/src/types/config.rs b/src/types/config.rs index f28e1da..0c6ce76 100644 --- a/src/types/config.rs +++ b/src/types/config.rs @@ -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, +} + +#[derive(Default, Clone, Debug, Serialize, Deserialize)] +pub struct HotkeyConfig { + #[serde(default)] + pub slots: Vec, +} + +impl HotkeyConfig { + pub fn config_path() -> Result> { + Ok(get_config_path()?.join("hotkeys.json")) + } + + pub fn load() -> Result> { + let path = Self::config_path()?; + if !path.exists() { + return Ok(HotkeyConfig::default()); + } + let bytes = fs::read(&path)?; + match serde_json::from_slice::(&bytes) { + Ok(config) => Ok(config), + Err(_) => Ok(HotkeyConfig::default()), + } + } + + pub fn save(&self) -> Result<(), Box> { + 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) -> 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() + } +} diff --git a/src/types/gui.rs b/src/types/gui.rs index 64b4c2f..dccd7fc 100644 --- a/src/types/gui.rs +++ b/src/types/gui.rs @@ -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, pub files: HashSet, + + pub show_hotkeys: bool, + pub hotkey_config: HotkeyConfig, + pub hotkey_search_query: String, + pub assigning_hotkey_slot: Option, + pub assigning_hotkey_for_file: Option, + 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, } diff --git a/src/types/socket.rs b/src/types/socket.rs index 263ba5d..7fda4cd 100644 --- a/src/types/socket.rs +++ b/src/types/socket.rs @@ -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, @@ -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)] diff --git a/src/utils/commands.rs b/src/utils/commands.rs index ce86e8e..048af7d 100644 --- a/src/utils/commands.rs +++ b/src/utils/commands.rs @@ -72,6 +72,40 @@ pub fn parse_command(request: &Request) -> Option> { "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::().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::(s).ok()); + Some(Box::new(SetHotkeyActionCommand { slot, action })) + } + "clear_hotkey_key" => { + let slot = request.args.get("slot").cloned(); + Some(Box::new(ClearHotkeyKeyCommand { slot })) + } _ => None, } } diff --git a/src/utils/global_hotkeys.rs b/src/utils/global_hotkeys.rs new file mode 100644 index 0000000..c94bc8b --- /dev/null +++ b/src/utils/global_hotkeys.rs @@ -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); + } + } + } +} diff --git a/src/utils/gui.rs b/src/utils/gui.rs index 271c538..cfe42ab 100644 --- a/src/utils/gui.rs +++ b/src/utils/gui.rs @@ -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,6 +50,7 @@ pub fn format_time_pair(position: f32, duration: f32) -> String { pub fn start_app_state_thread(audio_player_state_shared: Arc>) { 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); @@ -105,6 +107,22 @@ pub fn start_app_state_thread(audio_player_state_shared: Arc= 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::(&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; } }); diff --git a/src/utils/mod.rs b/src/utils/mod.rs index e23bcd1..85577f1 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,5 +1,6 @@ pub mod commands; pub mod config; pub mod daemon; +pub mod global_hotkeys; pub mod gui; pub mod pipewire;