mirror of
https://github.com/arabianq/pipewire-soundpad.git
synced 2026-06-19 20:23:33 +00:00
Refactor to Cargo Workspace (#129)
* Refactor project into a Cargo workspace with distinct packages - Created a root `Cargo.toml` defining a workspace. - Moved `src/types` and `src/utils` into a new `pwsp-lib` crate for shared logic. - Split binaries into their own crates: `pwsp-daemon`, `pwsp-cli`, and `pwsp-gui`. - Shifted all dependencies into `[workspace.dependencies]` for centralized version management. - Updated import paths across all crates (e.g. from `pwsp::` to `pwsp_lib::`). - Updated build scripts, GitHub actions, Flatpak manifest, and AUR PKGBUILD to support the new workspace structure. - Ensured no core application logic was altered. Co-authored-by: arabianq <55220741+arabianq@users.noreply.github.com> * Fix cargo-deb build process in GitHub actions for workspace architecture Co-authored-by: arabianq <55220741+arabianq@users.noreply.github.com> * Fix cargo-deb asset discovery by using exact target/release paths Co-authored-by: arabianq <55220741+arabianq@users.noreply.github.com> * refactor deps in Cargo.toml files * fix incorrect assets path --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
6c59137639
commit
0476329798
@@ -0,0 +1,209 @@
|
||||
use crate::types::{commands::*, socket::Request};
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn parse_command(request: &Request) -> Option<Box<dyn Executable + Send>> {
|
||||
let id = request.args.get("id").and_then(|s| s.parse::<u32>().ok());
|
||||
|
||||
match request.name.as_str() {
|
||||
"ping" => Some(Box::new(PingCommand {})),
|
||||
"kill" => Some(Box::new(KillCommand {})),
|
||||
"pause" => Some(Box::new(PauseCommand { id })),
|
||||
"resume" => Some(Box::new(ResumeCommand { id })),
|
||||
"toggle_pause" => Some(Box::new(TogglePauseCommand { id })),
|
||||
"stop" => Some(Box::new(StopCommand { id })),
|
||||
"is_paused" => Some(Box::new(IsPausedCommand {})),
|
||||
"get_state" => Some(Box::new(GetStateCommand {})),
|
||||
"get_volume" => Some(Box::new(GetVolumeCommand { id })),
|
||||
"set_volume" => {
|
||||
let volume = request
|
||||
.args
|
||||
.get("volume")
|
||||
.unwrap_or(&String::new())
|
||||
.parse::<f32>()
|
||||
.ok();
|
||||
Some(Box::new(SetVolumeCommand { volume, id }))
|
||||
}
|
||||
"get_position" => Some(Box::new(GetPositionCommand { id })),
|
||||
"seek" => {
|
||||
let position = request
|
||||
.args
|
||||
.get("position")
|
||||
.unwrap_or(&String::new())
|
||||
.parse::<f32>()
|
||||
.ok();
|
||||
Some(Box::new(SeekCommand { position, id }))
|
||||
}
|
||||
"get_duration" => Some(Box::new(GetDurationCommand { id })),
|
||||
"play" => {
|
||||
let file_path = request
|
||||
.args
|
||||
.get("file_path")
|
||||
.unwrap_or(&String::new())
|
||||
.parse::<PathBuf>()
|
||||
.ok();
|
||||
let concurrent = request
|
||||
.args
|
||||
.get("concurrent")
|
||||
.unwrap_or(&String::new())
|
||||
.parse::<bool>()
|
||||
.ok();
|
||||
Some(Box::new(PlayCommand {
|
||||
file_path,
|
||||
concurrent,
|
||||
}))
|
||||
}
|
||||
"get_tracks" => Some(Box::new(GetTracksCommand {})),
|
||||
"get_input" => Some(Box::new(GetCurrentInputCommand {})),
|
||||
"get_inputs" => Some(Box::new(GetAllInputsCommand {})),
|
||||
"set_input" => {
|
||||
let name = Some(request.args.get("input_name").unwrap_or(&String::new())).cloned();
|
||||
Some(Box::new(SetCurrentInputCommand { name }))
|
||||
}
|
||||
"set_loop" => {
|
||||
let enabled = request
|
||||
.args
|
||||
.get("enabled")
|
||||
.unwrap_or(&String::new())
|
||||
.parse::<bool>()
|
||||
.ok();
|
||||
Some(Box::new(SetLoopCommand { enabled, id }))
|
||||
}
|
||||
"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 }))
|
||||
}
|
||||
"set_hotkey_action_and_key" => {
|
||||
let slot = request.args.get("slot").cloned();
|
||||
let action = request
|
||||
.args
|
||||
.get("action")
|
||||
.and_then(|s| serde_json::from_str::<Request>(s).ok());
|
||||
let key_chord = request.args.get("key_chord").cloned();
|
||||
Some(Box::new(SetHotkeyActionAndKeyCommand {
|
||||
slot,
|
||||
action,
|
||||
key_chord,
|
||||
}))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::socket::Request;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn test_parse_set_volume_valid() {
|
||||
let mut args = HashMap::new();
|
||||
args.insert("volume".to_string(), "0.5".to_string());
|
||||
args.insert("id".to_string(), "1".to_string());
|
||||
let request = Request {
|
||||
name: "set_volume".to_string(),
|
||||
args,
|
||||
};
|
||||
|
||||
let cmd = parse_command(&request);
|
||||
assert!(cmd.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_set_volume_missing_volume() {
|
||||
let mut args = HashMap::new();
|
||||
args.insert("id".to_string(), "1".to_string());
|
||||
let request = Request {
|
||||
name: "set_volume".to_string(),
|
||||
args,
|
||||
};
|
||||
|
||||
let cmd = parse_command(&request);
|
||||
assert!(cmd.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_set_volume_invalid_volume() {
|
||||
let mut args = HashMap::new();
|
||||
args.insert("volume".to_string(), "not-a-float".to_string());
|
||||
let request = Request {
|
||||
name: "set_volume".to_string(),
|
||||
args,
|
||||
};
|
||||
|
||||
let cmd = parse_command(&request);
|
||||
assert!(cmd.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_set_volume_missing_id() {
|
||||
let mut args = HashMap::new();
|
||||
args.insert("volume".to_string(), "0.5".to_string());
|
||||
let request = Request {
|
||||
name: "set_volume".to_string(),
|
||||
args,
|
||||
};
|
||||
|
||||
let cmd = parse_command(&request);
|
||||
assert!(cmd.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_set_volume_invalid_id() {
|
||||
let mut args = HashMap::new();
|
||||
args.insert("id".to_string(), "not-an-int".to_string());
|
||||
args.insert("volume".to_string(), "0.5".to_string());
|
||||
let request = Request {
|
||||
name: "set_volume".to_string(),
|
||||
args,
|
||||
};
|
||||
|
||||
let cmd = parse_command(&request);
|
||||
assert!(cmd.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_set_volume_empty_args() {
|
||||
let request = Request {
|
||||
name: "set_volume".to_string(),
|
||||
args: HashMap::new(),
|
||||
};
|
||||
|
||||
let cmd = parse_command(&request);
|
||||
assert!(cmd.is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
use anyhow::Result;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn get_config_path() -> Result<PathBuf> {
|
||||
let config_path = dirs::config_dir().expect("Failed to obtain config dir");
|
||||
Ok(config_path.join("pwsp"))
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
use crate::types::{
|
||||
audio_player::AudioPlayer,
|
||||
config::DaemonConfig,
|
||||
socket::{MAX_MESSAGE_SIZE, Request, Response},
|
||||
};
|
||||
|
||||
use anyhow::Result;
|
||||
use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt};
|
||||
use std::path::PathBuf;
|
||||
use std::{env, error::Error, fs};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::UnixStream,
|
||||
sync::{Mutex, OnceCell},
|
||||
time::{Duration, sleep},
|
||||
};
|
||||
|
||||
static AUDIO_PLAYER: OnceCell<Mutex<AudioPlayer>> = OnceCell::const_new();
|
||||
|
||||
pub async fn get_audio_player() -> Result<&'static Mutex<AudioPlayer>, String> {
|
||||
AUDIO_PLAYER
|
||||
.get_or_try_init(|| async {
|
||||
println!("Initializing audio player");
|
||||
match AudioPlayer::new().await {
|
||||
Ok(player) => Ok(Mutex::new(player)),
|
||||
Err(err) => Err(err.to_string()),
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn get_daemon_config() -> DaemonConfig {
|
||||
DaemonConfig::load_from_file().unwrap_or_else(|_| {
|
||||
let config = DaemonConfig::default();
|
||||
config.save_to_file().ok();
|
||||
config
|
||||
})
|
||||
}
|
||||
|
||||
fn get_current_uid() -> u32 {
|
||||
rustix::process::geteuid().as_raw()
|
||||
}
|
||||
|
||||
pub fn get_runtime_dir() -> PathBuf {
|
||||
dirs::runtime_dir().unwrap_or_else(|| {
|
||||
let uid = get_current_uid();
|
||||
env::temp_dir().join(format!("pwsp-{}", uid))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_runtime_dir() -> Result<()> {
|
||||
let runtime_dir = get_runtime_dir();
|
||||
|
||||
if runtime_dir.exists() {
|
||||
let meta = fs::symlink_metadata(&runtime_dir)?;
|
||||
if meta.is_symlink() {
|
||||
return Err(anyhow::anyhow!("Runtime directory is a symlink"));
|
||||
}
|
||||
let uid = get_current_uid();
|
||||
if meta.uid() != uid {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Runtime directory is owned by another user"
|
||||
));
|
||||
}
|
||||
if meta.permissions().mode() & 0o777 != 0o700 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Runtime directory has incorrect permissions"
|
||||
));
|
||||
}
|
||||
} else {
|
||||
fs::DirBuilder::new()
|
||||
.recursive(true)
|
||||
.mode(0o700)
|
||||
.create(&runtime_dir)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_daemon_running() -> Result<bool> {
|
||||
let lock_file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(false)
|
||||
.open(get_runtime_dir().join("daemon.lock"))?;
|
||||
match lock_file.try_lock() {
|
||||
Ok(_) => Ok(false),
|
||||
Err(_) => Ok(true),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn wait_for_daemon() -> Result<()> {
|
||||
if is_daemon_running()? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("Daemon not found, waiting for it...");
|
||||
while !is_daemon_running()? {
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
println!("Found running daemon");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn make_request(request: Request) -> Result<Response, Box<dyn Error + Send + Sync>> {
|
||||
let socket_path = get_runtime_dir().join("daemon.sock");
|
||||
let mut stream = UnixStream::connect(socket_path).await?;
|
||||
|
||||
// ---------- Send request (start) ----------
|
||||
let request_data = serde_json::to_vec(&request)?;
|
||||
let request_len = request_data.len() as u32;
|
||||
if stream.write_all(&request_len.to_le_bytes()).await.is_err() {
|
||||
return Err("Failed to send request length".into());
|
||||
};
|
||||
if stream.write_all(&request_data).await.is_err() {
|
||||
return Err("Failed to send request".into());
|
||||
}
|
||||
// ---------- Send request (end) ----------
|
||||
|
||||
// ---------- Read response (start) ----------
|
||||
let mut len_bytes = [0u8; 4];
|
||||
if stream.read_exact(&mut len_bytes).await.is_err() {
|
||||
return Err("Failed to read response length".into());
|
||||
}
|
||||
let response_len = u32::from_le_bytes(len_bytes) as usize;
|
||||
|
||||
if response_len > MAX_MESSAGE_SIZE {
|
||||
eprintln!(
|
||||
"Failed to read response from daemon: response too large ({} bytes)!",
|
||||
response_len
|
||||
);
|
||||
return Err("Response too large".into());
|
||||
}
|
||||
|
||||
let mut buffer = vec![0u8; response_len];
|
||||
if stream.read_exact(&mut buffer).await.is_err() {
|
||||
return Err("Failed to read response".into());
|
||||
};
|
||||
// ---------- Read response (end) ----------
|
||||
|
||||
Ok(serde_json::from_slice(&buffer)?)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
use crate::{
|
||||
types::{
|
||||
audio_player::FullState,
|
||||
config::{GuiConfig, HotkeyConfig},
|
||||
gui::AudioPlayerState,
|
||||
socket::{Request, Response},
|
||||
},
|
||||
utils::daemon::{is_daemon_running, make_request},
|
||||
};
|
||||
use anyhow::{Result, anyhow};
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::{Arc, Mutex},
|
||||
time::Instant,
|
||||
};
|
||||
use tokio::time::{Duration, sleep};
|
||||
|
||||
pub fn get_gui_config() -> GuiConfig {
|
||||
GuiConfig::load_from_file().unwrap_or_else(|_| {
|
||||
let mut config = GuiConfig::default();
|
||||
config.save_to_file().ok();
|
||||
config
|
||||
})
|
||||
}
|
||||
|
||||
pub fn make_request_sync(request: Request) -> Result<Response> {
|
||||
tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current()
|
||||
.block_on(make_request(request))
|
||||
.map_err(|e| anyhow!(e))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn make_request_async(request: Request) {
|
||||
tokio::spawn(async move {
|
||||
make_request(request).await.ok();
|
||||
});
|
||||
}
|
||||
|
||||
pub fn ensure_pwsp_audio_dir() -> PathBuf {
|
||||
let audio_dir = dirs::audio_dir().unwrap_or("~/Music".into());
|
||||
let pwsp_audio_dir = audio_dir.join("PWSP");
|
||||
|
||||
if !pwsp_audio_dir.exists() {
|
||||
std::fs::create_dir_all(&pwsp_audio_dir).ok();
|
||||
}
|
||||
|
||||
pwsp_audio_dir
|
||||
}
|
||||
|
||||
pub fn format_time_pair(position: f32, duration: f32) -> String {
|
||||
fn format_time(seconds: f32) -> String {
|
||||
let total_seconds = seconds.round() as u32;
|
||||
let minutes = total_seconds / 60;
|
||||
let secs = total_seconds % 60;
|
||||
format!("{:02}:{:02}", minutes, secs)
|
||||
}
|
||||
|
||||
format!("{}/{}", format_time(position), format_time(duration))
|
||||
}
|
||||
|
||||
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_or_else(|e| e.into_inner());
|
||||
guard.is_daemon_running = false;
|
||||
}
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
let full_state_req = Request::get_full_state();
|
||||
let full_state_res = make_request(full_state_req).await.unwrap_or_default();
|
||||
|
||||
if full_state_res.status {
|
||||
let full_state: FullState =
|
||||
serde_json::from_str(&full_state_res.message).unwrap_or_default();
|
||||
|
||||
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) => {
|
||||
guard.new_state = None;
|
||||
new_state
|
||||
}
|
||||
None => full_state.state,
|
||||
};
|
||||
guard.tracks = full_state.tracks;
|
||||
guard.volume = full_state.volume;
|
||||
guard.current_input = full_state
|
||||
.current_input
|
||||
.split(" - ")
|
||||
.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
|
||||
&& 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod commands;
|
||||
pub mod config;
|
||||
pub mod daemon;
|
||||
pub mod global_hotkeys;
|
||||
pub mod gui;
|
||||
pub mod pipewire;
|
||||
@@ -0,0 +1,383 @@
|
||||
use crate::types::pipewire::{AudioDevice, DeviceType, Port, Terminate};
|
||||
use anyhow::{Result, anyhow};
|
||||
use pipewire::{
|
||||
context::ContextRc, link::Link, main_loop::MainLoopRc, properties::properties,
|
||||
registry::GlobalObject, spa::utils::dict::DictRef,
|
||||
};
|
||||
use std::{collections::HashMap, thread};
|
||||
use tokio::{
|
||||
sync::mpsc,
|
||||
time::{Duration, timeout},
|
||||
};
|
||||
|
||||
pub fn setup_pipewire_context() -> Result<(MainLoopRc, ContextRc), String> {
|
||||
pipewire::init();
|
||||
let main_loop = MainLoopRc::new(None).map_err(|e| e.to_string())?;
|
||||
let context = ContextRc::new(&main_loop, None).map_err(|e| e.to_string())?;
|
||||
Ok((main_loop, context))
|
||||
}
|
||||
|
||||
fn parse_global_object(
|
||||
global_object: &GlobalObject<&DictRef>,
|
||||
) -> (Option<AudioDevice>, Option<Port>) {
|
||||
let props = match global_object.props {
|
||||
Some(p) => p,
|
||||
None => return (None, None),
|
||||
};
|
||||
|
||||
if let Some(media_class) = props.get("media.class") {
|
||||
let node_id = global_object.id;
|
||||
let node_nick = props.get("node.nick");
|
||||
let node_name = props.get("node.name");
|
||||
let node_description = props.get("node.description");
|
||||
|
||||
if media_class.starts_with("Audio/Source") {
|
||||
let input_device = AudioDevice::new(
|
||||
node_id,
|
||||
node_nick,
|
||||
node_description,
|
||||
node_name,
|
||||
DeviceType::Input,
|
||||
);
|
||||
return (Some(input_device), None);
|
||||
} else if media_class.starts_with("Stream/Output/Audio") {
|
||||
let output_device = AudioDevice::new(
|
||||
node_id,
|
||||
node_nick,
|
||||
node_description,
|
||||
node_name,
|
||||
DeviceType::Output,
|
||||
);
|
||||
return (Some(output_device), None);
|
||||
}
|
||||
return (None, None);
|
||||
}
|
||||
|
||||
if props.get("port.direction").is_some()
|
||||
&& 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,
|
||||
name: port_name.to_string(),
|
||||
};
|
||||
return (None, Some(port));
|
||||
}
|
||||
|
||||
(None, None)
|
||||
}
|
||||
|
||||
async fn pw_get_global_objects_thread(
|
||||
main_sender: mpsc::Sender<(Option<AudioDevice>, Option<Port>)>,
|
||||
pw_receiver: pipewire::channel::Receiver<Terminate>,
|
||||
init_sender: tokio::sync::oneshot::Sender<Result<(), String>>,
|
||||
) {
|
||||
let (main_loop, context) = match setup_pipewire_context() {
|
||||
Ok(res) => res,
|
||||
Err(e) => {
|
||||
let _ = init_sender.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Stop main loop on Terminate message
|
||||
let _receiver = pw_receiver.attach(main_loop.loop_(), {
|
||||
let _main_loop = main_loop.clone();
|
||||
move |_| _main_loop.quit()
|
||||
});
|
||||
|
||||
let core = match context.connect(None) {
|
||||
Ok(core) => core,
|
||||
Err(e) => {
|
||||
let _ = init_sender.send(Err(format!("Failed to connect to pipewire context: {}", e)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let registry = match core.get_registry() {
|
||||
Ok(registry) => registry,
|
||||
Err(e) => {
|
||||
let _ = init_sender.send(Err(format!(
|
||||
"Failed to get registry from pipewire context: {}",
|
||||
e
|
||||
)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let _listener = registry
|
||||
.add_listener_local()
|
||||
.global(move |global| {
|
||||
// Try to parse every global object pipewire finds
|
||||
let (device, port) = parse_global_object(global);
|
||||
|
||||
// Send message to the main thread
|
||||
let sender_clone = main_sender.clone();
|
||||
tokio::task::spawn(async move {
|
||||
sender_clone.send((device, port)).await.ok();
|
||||
});
|
||||
})
|
||||
.register();
|
||||
|
||||
// Signal successful initialization
|
||||
if init_sender.send(Ok(())).is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
main_loop.run();
|
||||
}
|
||||
|
||||
pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
|
||||
// Channels to communicate with pipewire thread
|
||||
let (main_sender, mut main_receiver) = mpsc::channel(10);
|
||||
let (pw_sender, pw_receiver) = pipewire::channel::channel();
|
||||
let (init_sender, init_receiver) = tokio::sync::oneshot::channel();
|
||||
|
||||
// Spawn pipewire thread in background
|
||||
let _pw_thread = tokio::spawn(async move {
|
||||
pw_get_global_objects_thread(main_sender, pw_receiver, init_sender).await
|
||||
});
|
||||
|
||||
// Wait for initialization to complete
|
||||
if let Err(e) = init_receiver.await {
|
||||
return Err(anyhow!(e));
|
||||
}
|
||||
|
||||
let mut input_devices: HashMap<u32, AudioDevice> = HashMap::new();
|
||||
let mut output_devices: HashMap<u32, AudioDevice> = HashMap::new();
|
||||
let mut ports: Vec<Port> = vec![];
|
||||
|
||||
loop {
|
||||
// If we don't receive a message in 100ms, we can assume that pipewire thread is finished
|
||||
match timeout(Duration::from_millis(100), main_receiver.recv()).await {
|
||||
Ok(Some((device, port))) => {
|
||||
if let Some(device) = device {
|
||||
match device.device_type {
|
||||
DeviceType::Input => {
|
||||
input_devices.insert(device.id, device);
|
||||
}
|
||||
DeviceType::Output => {
|
||||
output_devices.insert(device.id, device);
|
||||
}
|
||||
}
|
||||
} else if let Some(port) = port {
|
||||
ports.push(port);
|
||||
}
|
||||
}
|
||||
Ok(None) | Err(_) => {
|
||||
// Pipewire thread is finished and we can collect our devices
|
||||
let _ = pw_sender.send(Terminate {});
|
||||
|
||||
for port in ports {
|
||||
let node_id = port.node_id;
|
||||
|
||||
if let Some(input_device) = input_devices.get_mut(&node_id) {
|
||||
input_device.add_port(port);
|
||||
} else if let Some(output_device) = output_devices.get_mut(&node_id) {
|
||||
output_device.add_port(port);
|
||||
}
|
||||
}
|
||||
|
||||
let mut input_devices: Vec<AudioDevice> = input_devices.into_values().collect();
|
||||
let mut output_devices: Vec<AudioDevice> = output_devices.into_values().collect();
|
||||
|
||||
input_devices.sort_by_key(|a| a.id);
|
||||
output_devices.sort_by_key(|a| a.id);
|
||||
|
||||
return Ok((input_devices, output_devices));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_device(device_name: &str) -> Result<AudioDevice> {
|
||||
let (input_devices, output_devices) = get_all_devices().await?;
|
||||
|
||||
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)
|
||||
})
|
||||
.ok_or_else(|| anyhow!("Device not found"))
|
||||
}
|
||||
|
||||
pub fn create_virtual_mic() -> Result<pipewire::channel::Sender<Terminate>> {
|
||||
let (pw_sender, pw_receiver) = pipewire::channel::channel::<Terminate>();
|
||||
let (init_sender, init_receiver) = std::sync::mpsc::sync_channel(0);
|
||||
|
||||
let _pw_thread = thread::spawn(move || {
|
||||
let (main_loop, context) = match setup_pipewire_context() {
|
||||
Ok(res) => res,
|
||||
Err(e) => {
|
||||
let _ = init_sender.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let core = match context.connect(None) {
|
||||
Ok(core) => core,
|
||||
Err(e) => {
|
||||
let _ =
|
||||
init_sender.send(Err(format!("Failed to connect to pipewire context: {}", e)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let props = properties!(
|
||||
"factory.name" => "support.null-audio-sink",
|
||||
"node.name" => "pwsp-virtual-mic",
|
||||
"node.description" => "PWSP Virtual Mic",
|
||||
"media.class" => "Audio/Source/Virtual",
|
||||
"audio.position" => "[ FL FR ]",
|
||||
"audio.channels" => "2",
|
||||
"object.linger" => "false", // Destroy the node on app exit
|
||||
);
|
||||
|
||||
let _node = match core.create_object::<pipewire::node::Node>("adapter", &props) {
|
||||
Ok(node) => node,
|
||||
Err(e) => {
|
||||
let _ = init_sender.send(Err(format!("Failed to create virtual mic: {}", e)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let _receiver = pw_receiver.attach(main_loop.loop_(), {
|
||||
let _main_loop = main_loop.clone();
|
||||
move |_| _main_loop.quit()
|
||||
});
|
||||
|
||||
println!("Virtual mic created");
|
||||
if init_sender.send(Ok(())).is_err() {
|
||||
return;
|
||||
}
|
||||
main_loop.run();
|
||||
});
|
||||
|
||||
if let Err(e) = init_receiver.recv()? {
|
||||
return Err(anyhow!(e));
|
||||
}
|
||||
|
||||
Ok(pw_sender)
|
||||
}
|
||||
|
||||
pub async fn link_player_to_virtual_mic() -> Result<pipewire::channel::Sender<Terminate>> {
|
||||
let pwsp_daemon_output = match get_device("pwsp-daemon").await {
|
||||
Ok(device) => device,
|
||||
Err(_) => {
|
||||
return Err(anyhow!(
|
||||
"Could not find alsa_playback.pwsp-daemon device, skipping device linking"
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let pwsp_daemon_input = match get_device("pwsp-virtual-mic").await {
|
||||
Ok(device) => device,
|
||||
Err(_) => {
|
||||
return Err(anyhow!(
|
||||
"Could not find pwsp-virtual-mic device, skipping device linking"
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let output_fl = match pwsp_daemon_output.output_fl {
|
||||
Some(port) => port,
|
||||
None => return Err(anyhow!("Failed to get pwsp-daemon output_fl")),
|
||||
};
|
||||
let output_fr = match pwsp_daemon_output.output_fr {
|
||||
Some(port) => port,
|
||||
None => return Err(anyhow!("Failed to get pwsp-daemon output_fr")),
|
||||
};
|
||||
let input_fl = match pwsp_daemon_input.input_fl {
|
||||
Some(port) => port,
|
||||
None => return Err(anyhow!("Failed to get pwsp-virtual-mic input_fl")),
|
||||
};
|
||||
let input_fr = match pwsp_daemon_input.input_fr {
|
||||
Some(port) => port,
|
||||
None => return Err(anyhow!("Failed to get pwsp-virtual-mic input_fr")),
|
||||
};
|
||||
|
||||
create_link(output_fl, output_fr, input_fl, input_fr)
|
||||
}
|
||||
|
||||
pub fn create_link(
|
||||
output_fl: Port,
|
||||
output_fr: Port,
|
||||
input_fl: Port,
|
||||
input_fr: Port,
|
||||
) -> Result<pipewire::channel::Sender<Terminate>> {
|
||||
let (pw_sender, pw_receiver) = pipewire::channel::channel::<Terminate>();
|
||||
let (init_sender, init_receiver) = std::sync::mpsc::sync_channel(0);
|
||||
|
||||
let _pw_thread = thread::spawn(move || {
|
||||
let (main_loop, context) = match setup_pipewire_context() {
|
||||
Ok(res) => res,
|
||||
Err(e) => {
|
||||
let _ = init_sender.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let core = match context.connect(None) {
|
||||
Ok(core) => core,
|
||||
Err(e) => {
|
||||
let _ =
|
||||
init_sender.send(Err(format!("Failed to connect to pipewire context: {}", e)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let props_fl = properties! {
|
||||
"link.output.node" => format!("{}", output_fl.node_id).as_str(),
|
||||
"link.output.port" => format!("{}", output_fl.port_id).as_str(),
|
||||
"link.input.node" => format!("{}", input_fl.node_id).as_str(),
|
||||
"link.input.port" => format!("{}", input_fl.port_id).as_str(),
|
||||
};
|
||||
let props_fr = properties! {
|
||||
"link.output.node" => format!("{}", output_fr.node_id).as_str(),
|
||||
"link.output.port" => format!("{}", output_fr.port_id).as_str(),
|
||||
"link.input.node" => format!("{}", input_fr.node_id).as_str(),
|
||||
"link.input.port" => format!("{}", input_fr.port_id).as_str(),
|
||||
};
|
||||
|
||||
let _link_fl = match core.create_object::<Link>("link-factory", &props_fl) {
|
||||
Ok(link) => link,
|
||||
Err(e) => {
|
||||
let _ = init_sender.send(Err(format!("Failed to create link FL: {}", e)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _link_fr = match core.create_object::<Link>("link-factory", &props_fr) {
|
||||
Ok(link) => link,
|
||||
Err(e) => {
|
||||
let _ = init_sender.send(Err(format!("Failed to create link FR: {}", e)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let _receiver = pw_receiver.attach(main_loop.loop_(), {
|
||||
let _main_loop = main_loop.clone();
|
||||
move |_| _main_loop.quit()
|
||||
});
|
||||
|
||||
println!(
|
||||
"Link created: FL: {}-{} FR: {}-{}",
|
||||
output_fl.node_id, input_fl.node_id, output_fr.node_id, input_fr.node_id
|
||||
);
|
||||
if init_sender.send(Ok(())).is_err() {
|
||||
return;
|
||||
}
|
||||
main_loop.run();
|
||||
});
|
||||
|
||||
if let Err(e) = init_receiver.recv()? {
|
||||
return Err(anyhow!(e));
|
||||
}
|
||||
|
||||
Ok(pw_sender)
|
||||
}
|
||||
Reference in New Issue
Block a user