feat: Add persistent configuration and enumerate PipeWire audio devices

This commit is contained in:
2026-05-27 16:03:07 -04:00
parent e0ba002c12
commit dac53fc2ad
8 changed files with 352 additions and 24 deletions
+65 -24
View File
@@ -1,8 +1,10 @@
use crate::core::{CoreController, messages::{CoreCommand, UiEvent}};
use crate::network::PeerState;
use crate::audio::pw_cli::{AudioDevice, enumerate_audio_devices};
use crate::config::AppConfig;
use iced::widget::{
container, column, row, text, button, text_input, scrollable, slider, checkbox, Column, Space,
container, column, row, text, button, text_input, scrollable, slider, checkbox, pick_list, Column, Space,
};
use iced::{
Color, Background, Border, Element, Subscription, Task, Theme, Event, keyboard,
@@ -35,8 +37,8 @@ pub enum AppMessage {
TogglePtt(bool),
StartSettingHotkey,
PeerVolumeChanged(EndpointId, f32),
InputDeviceChanged(String),
OutputDeviceChanged(String),
InputDeviceSelected(AudioDevice),
OutputDeviceSelected(AudioDevice),
EventOccurred(Event),
NavigateToSettings,
NavigateBack,
@@ -68,8 +70,11 @@ pub struct AppState {
ptt_active: bool,
ptt_hotkey: keyboard::Key,
is_setting_hotkey: bool,
input_device: String,
output_device: String,
input_devices: Vec<AudioDevice>,
output_devices: Vec<AudioDevice>,
selected_input: Option<AudioDevice>,
selected_output: Option<AudioDevice>,
config: AppConfig,
peers: HashMap<EndpointId, PeerState>,
peer_volumes: HashMap<EndpointId, f32>,
audio_levels: HashMap<EndpointId, f32>,
@@ -83,6 +88,15 @@ impl Default for AppState {
let controller = Arc::new(CoreController::new(ui_tx));
let _ = UI_RX.set(Mutex::new(Some(ui_rx)));
let config = AppConfig::load();
let all_devices = enumerate_audio_devices();
let input_devices: Vec<_> = all_devices.iter().filter(|d| d.is_input).cloned().collect();
let output_devices: Vec<_> = all_devices.iter().filter(|d| !d.is_input).cloned().collect();
let selected_input = input_devices.iter().find(|d| d.name == config.input_device).cloned();
let selected_output = output_devices.iter().find(|d| d.name == config.output_device).cloned();
Self {
name: "Peer".to_string(),
ticket_input: "".to_string(),
@@ -95,8 +109,11 @@ impl Default for AppState {
ptt_active: false,
ptt_hotkey: keyboard::Key::Named(keyboard::key::Named::Space),
is_setting_hotkey: false,
input_device: "".to_string(),
output_device: "".to_string(),
input_devices,
output_devices,
selected_input,
selected_output,
config,
peers: HashMap::new(),
peer_volumes: HashMap::new(),
audio_levels: HashMap::new(),
@@ -139,21 +156,27 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.ticket_input = val;
}
AppMessage::JoinPressed => {
state.status_message = "Connecting to room...".to_string();
let _ = state.controller.send(CoreCommand::Join {
name: state.name.clone(),
ticket: state.ticket_input.clone(),
input_device: if state.input_device.is_empty() { None } else { Some(state.input_device.clone()) },
output_device: if state.output_device.is_empty() { None } else { Some(state.output_device.clone()) },
});
let input_device = state.selected_input.as_ref().map(|d| d.name.clone());
let output_device = state.selected_output.as_ref().map(|d| d.name.clone());
if !state.ticket_input.is_empty() {
state.status_message = "Joining room...".to_string();
let _ = state.controller.send(CoreCommand::Join {
name: state.name.clone(),
ticket: state.ticket_input.clone(),
input_device,
output_device,
});
}
}
AppMessage::CreatePressed => {
let input_device = state.selected_input.as_ref().map(|d| d.name.clone());
let output_device = state.selected_output.as_ref().map(|d| d.name.clone());
state.status_message = "Creating room...".to_string();
let _ = state.controller.send(CoreCommand::Join {
name: state.name.clone(),
ticket: "".to_string(),
input_device: if state.input_device.is_empty() { None } else { Some(state.input_device.clone()) },
output_device: if state.output_device.is_empty() { None } else { Some(state.output_device.clone()) },
ticket: "create".to_string(),
input_device,
output_device,
});
}
AppMessage::LeavePressed => {
@@ -218,11 +241,15 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.peer_volumes.insert(id, vol);
let _ = state.controller.send(CoreCommand::SetPeerVolume(id, vol));
}
AppMessage::InputDeviceChanged(val) => {
state.input_device = val;
AppMessage::InputDeviceSelected(dev) => {
state.config.input_device = dev.name.clone();
state.config.save();
state.selected_input = Some(dev);
}
AppMessage::OutputDeviceChanged(val) => {
state.output_device = val;
AppMessage::OutputDeviceSelected(dev) => {
state.config.output_device = dev.name.clone();
state.config.save();
state.selected_output = Some(dev);
}
AppMessage::EventOccurred(Event::Keyboard(keyboard::Event::KeyPressed { key, .. })) => {
if state.is_setting_hotkey {
@@ -337,11 +364,25 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
column![
text("Settings").size(24).color(color_blue),
vertical_space(20.0),
text("Device Settings (Optional Node Target IDs)").size(14).color(color_subtext),
text("Device Settings").size(14).color(color_subtext),
row![
text_input("Input Target", &state.input_device).on_input(AppMessage::InputDeviceChanged).style(t_style.clone()).padding(8),
column![
text("Input Target").size(12).color(color_subtext),
pick_list(
&state.input_devices[..],
state.selected_input.clone(),
AppMessage::InputDeviceSelected,
).width(iced::Length::Fixed(160.0))
].spacing(4),
horizontal_space(),
text_input("Output Target", &state.output_device).on_input(AppMessage::OutputDeviceChanged).style(t_style.clone()).padding(8),
column![
text("Output Target").size(12).color(color_subtext),
pick_list(
&state.output_devices[..],
state.selected_output.clone(),
AppMessage::OutputDeviceSelected,
).width(iced::Length::Fixed(160.0))
].spacing(4),
].spacing(10),
vertical_space(30.0),
button(
+1
View File
@@ -29,3 +29,4 @@ pub trait AudioBackend: Send + Sync {
}
pub mod pipewire_impl;
pub mod pw_cli;
+65
View File
@@ -0,0 +1,65 @@
use std::process::Command;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AudioDevice {
pub name: String,
pub description: String,
pub is_input: bool,
}
impl std::fmt::Display for AudioDevice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.description)
}
}
pub fn enumerate_audio_devices() -> Vec<AudioDevice> {
let mut devices = Vec::new();
let output = Command::new("pw-cli")
.arg("list-objects")
.arg("Node")
.output();
if let Ok(out) = output {
let text = String::from_utf8_lossy(&out.stdout);
let mut current_name = String::new();
let mut current_desc = String::new();
let mut current_class = String::new();
for line in text.lines() {
let line = line.trim();
if line.starts_with("id ") {
// Save previous if valid
if !current_name.is_empty() && current_class.starts_with("Audio/") {
devices.push(AudioDevice {
name: current_name.clone(),
description: if current_desc.is_empty() { current_name.clone() } else { current_desc.clone() },
is_input: current_class == "Audio/Source",
});
}
current_name.clear();
current_desc.clear();
current_class.clear();
} else if let Some(val) = line.strip_prefix("node.name = \"") {
current_name = val.trim_end_matches('"').to_string();
} else if let Some(val) = line.strip_prefix("node.description = \"") {
current_desc = val.trim_end_matches('"').to_string();
} else if let Some(val) = line.strip_prefix("media.class = \"") {
current_class = val.trim_end_matches('"').to_string();
}
}
// Final one
if !current_name.is_empty() && current_class.starts_with("Audio/") {
devices.push(AudioDevice {
name: current_name.clone(),
description: if current_desc.is_empty() { current_name } else { current_desc },
is_input: current_class == "Audio/Source",
});
}
}
// Sort devices for consistent display
devices.sort_by(|a, b| a.description.cmp(&b.description));
devices
}
+41
View File
@@ -0,0 +1,41 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AppConfig {
pub input_device: String,
pub output_device: String,
}
impl AppConfig {
fn config_path() -> Option<PathBuf> {
dirs::config_dir().map(|mut p| {
p.push("peerspeak");
p.push("config.json");
p
})
}
pub fn load() -> Self {
if let Some(path) = Self::config_path() {
if let Ok(contents) = fs::read_to_string(&path) {
if let Ok(config) = serde_json::from_str(&contents) {
return config;
}
}
}
Self::default()
}
pub fn save(&self) {
if let Some(path) = Self::config_path() {
if let Some(dir) = path.parent() {
let _ = fs::create_dir_all(dir);
}
if let Ok(json) = serde_json::to_string_pretty(self) {
let _ = fs::write(path, json);
}
}
}
}
+1
View File
@@ -3,6 +3,7 @@ pub mod codec;
pub mod network;
pub mod core;
pub mod app;
pub mod config;
pub fn log_msg(msg: &str) {
if let Ok(mut file) = std::fs::OpenOptions::new()