mirror of
https://github.com/arabianq/pipewire-soundpad.git
synced 2026-04-28 14:31:23 +00:00
a156df346b
* 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>
234 lines
6.9 KiB
Rust
234 lines
6.9 KiB
Rust
mod draw;
|
|
mod input;
|
|
mod update;
|
|
|
|
use eframe::{HardwareAcceleration, NativeOptions, icon_data::from_png_bytes, run_native};
|
|
use egui::{Context, Vec2, ViewportBuilder};
|
|
use itertools::Itertools;
|
|
use pwsp::{
|
|
types::{
|
|
audio_player::PlayerState,
|
|
config::GuiConfig,
|
|
config::HotkeyConfig,
|
|
gui::{AppState, AudioPlayerState},
|
|
socket::Request,
|
|
},
|
|
utils::{
|
|
daemon::get_daemon_config,
|
|
gui::{get_gui_config, make_request_async, make_request_sync, start_app_state_thread},
|
|
},
|
|
};
|
|
use rfd::FileDialog;
|
|
use std::{
|
|
error::Error,
|
|
path::PathBuf,
|
|
sync::{Arc, Mutex},
|
|
};
|
|
|
|
const SUPPORTED_EXTENSIONS: [&str; 12] = [
|
|
"mp3", "wav", "ogg", "flac", "mp4", "m4a", "aac", "mov", "mkv", "mka", "webm", "avi",
|
|
];
|
|
|
|
struct SoundpadGui {
|
|
pub app_state: AppState,
|
|
pub config: GuiConfig,
|
|
pub audio_player_state: AudioPlayerState,
|
|
pub audio_player_state_shared: Arc<Mutex<AudioPlayerState>>,
|
|
}
|
|
|
|
impl SoundpadGui {
|
|
fn new(ctx: &Context) -> Self {
|
|
let audio_player_state = Arc::new(Mutex::new(AudioPlayerState::default()));
|
|
start_app_state_thread(audio_player_state.clone());
|
|
|
|
let config = get_gui_config();
|
|
|
|
ctx.set_zoom_factor(config.scale_factor);
|
|
|
|
let mut soundpad_gui = SoundpadGui {
|
|
app_state: AppState::default(),
|
|
config: config.clone(),
|
|
audio_player_state: AudioPlayerState::default(),
|
|
audio_player_state_shared: audio_player_state.clone(),
|
|
};
|
|
|
|
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_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))),
|
|
PlayerState::Stopped => (None, None),
|
|
}
|
|
};
|
|
|
|
if let Some(req) = request {
|
|
make_request_async(req);
|
|
}
|
|
|
|
if let Some(state) = new_state {
|
|
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;
|
|
}
|
|
}
|
|
|
|
pub fn open_file(&mut self) {
|
|
let file_dialog = FileDialog::new().add_filter("Audio File", &SUPPORTED_EXTENSIONS);
|
|
if let Some(path) = file_dialog.pick_file() {
|
|
self.play_file(&path, false);
|
|
}
|
|
}
|
|
|
|
pub fn add_dirs(&mut self) {
|
|
let file_dialog = FileDialog::new();
|
|
if let Some(paths) = file_dialog.pick_folders() {
|
|
for path in paths {
|
|
self.app_state.dirs.push(path);
|
|
}
|
|
self.app_state.dirs = self.app_state.dirs.iter().unique().cloned().collect();
|
|
self.config.dirs = self.app_state.dirs.clone();
|
|
self.config.save_to_file().ok();
|
|
}
|
|
}
|
|
|
|
pub fn open_dir(&mut self, path: &PathBuf) {
|
|
self.app_state.current_dir = Some(path.clone());
|
|
match path.read_dir() {
|
|
Ok(read_dir) => {
|
|
self.app_state.files = read_dir
|
|
.filter_map(|res| res.ok())
|
|
.map(|entry| entry.path())
|
|
.collect();
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Failed to read directory {:?}: {}", path, e);
|
|
self.app_state.files.clear();
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn play_file(&mut self, path: &PathBuf, concurrent: bool) {
|
|
make_request_async(Request::play(&path.to_string_lossy(), concurrent));
|
|
}
|
|
|
|
pub fn set_input(&mut self, name: String) {
|
|
make_request_async(Request::set_input(&name));
|
|
|
|
if self.config.save_input {
|
|
let mut daemon_config = get_daemon_config();
|
|
daemon_config.default_input_name = Some(name);
|
|
daemon_config.save_to_file().ok();
|
|
}
|
|
}
|
|
|
|
pub fn toggle_loop(&mut self, id: Option<u32>) {
|
|
make_request_async(Request::toggle_loop(id));
|
|
}
|
|
|
|
pub fn pause(&mut self, id: Option<u32>) {
|
|
make_request_async(Request::pause(id));
|
|
}
|
|
|
|
pub fn resume(&mut self, id: Option<u32>) {
|
|
make_request_async(Request::resume(id));
|
|
}
|
|
|
|
pub fn stop(&mut self, id: Option<u32>) {
|
|
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();
|
|
|
|
let search_query = self.app_state.search_query.to_lowercase();
|
|
let search_query = search_query.trim();
|
|
|
|
files
|
|
.into_iter()
|
|
.filter(|entry_path| {
|
|
if entry_path.is_dir() {
|
|
return false;
|
|
}
|
|
|
|
if !SUPPORTED_EXTENSIONS.contains(
|
|
&entry_path
|
|
.extension()
|
|
.unwrap_or_default()
|
|
.to_str()
|
|
.unwrap_or_default(),
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
if !search_query.is_empty() {
|
|
let file_name = entry_path
|
|
.file_name()
|
|
.unwrap_or_default()
|
|
.to_string_lossy()
|
|
.to_string();
|
|
|
|
if !file_name.to_lowercase().contains(search_query) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
true
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
pub async fn run() -> Result<(), Box<dyn Error>> {
|
|
const ICON: &[u8] = include_bytes!("../../assets/icon.png");
|
|
|
|
let options = NativeOptions {
|
|
vsync: true,
|
|
centered: true,
|
|
hardware_acceleration: HardwareAcceleration::Preferred,
|
|
|
|
viewport: ViewportBuilder::default()
|
|
.with_app_id("ru.arabianq.pwsp")
|
|
.with_inner_size(Vec2::new(1200.0, 800.0))
|
|
.with_min_inner_size(Vec2::new(800.0, 600.0))
|
|
.with_icon(from_png_bytes(ICON)?),
|
|
|
|
..Default::default()
|
|
};
|
|
|
|
match run_native(
|
|
"Pipewire Soundpad",
|
|
options,
|
|
Box::new(|cc| {
|
|
egui_material_icons::initialize(&cc.egui_ctx);
|
|
Ok(Box::new(SoundpadGui::new(&cc.egui_ctx)))
|
|
}),
|
|
) {
|
|
Ok(_) => {
|
|
let config = get_gui_config();
|
|
if config.pause_on_exit {
|
|
make_request_sync(Request::pause(None)).ok();
|
|
}
|
|
Ok(())
|
|
}
|
|
Err(e) => Err(e.into()),
|
|
}
|
|
}
|