Compare commits

..

4 Commits
main ... dev

24 changed files with 1928 additions and 95 deletions

View File

@ -4,8 +4,14 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
dirs = "6.0.0"
flume = "0.11.1"
gettext-rs = { version = "0.7", features = ["gettext-system"] } gettext-rs = { version = "0.7", features = ["gettext-system"] }
gtk = { version = "0.9", package = "gtk4", features = ["gnome_47"] } gtk = { version = "0.9", package = "gtk4", features = ["gnome_47"] }
lazy_static = "1.5.0"
libspa = "0.8.0"
pipewire = "0.8.0"
sled = "0.34.7"
[dependencies.adw] [dependencies.adw]
package = "libadwaita" package = "libadwaita"

97
UI Drafts.txt Normal file
View File

@ -0,0 +1,97 @@
Pipewire Device Config Selection Screen
----------------------
| Select Config |
| ______________ |
| | search | |
| ¯¯¯¯¯¯¯¯¯¯¯¯¯¯ |
| Device Config 1 |
| Device Config 2 |
| Device Config 3 |
| ... |
| |
| Select |
| Duplicate |
| Create New |
| Cancel |
| |
----------------------
Duplicate Device Config Screen
-------------------------
| Duplicate Config Name |
| _________________ |
| | name | |
| ¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯ |
| |
| Create |
| Cancel |
| |
-------------------------
New Device Config Selection Screen
-----------------------------------
| New Config |
| |
| Use Pipewire Device as Template |
| Create From Scratch |
| Cancel |
| |
-----------------------------------
New Device Config fom Template Selection
__________________________
| Select Pipewire Device |
| __________________ |
| | search | |
| ¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯ |
| Pipewire Device 1 |
| Pipewire Device 2 |
| Pipewire Device 3 |
| ... |
| |
| Select |
| Cancel |
| |
¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯
Device Configuration Page
_______________________________________________________
| Devices | < Device Connfiguration 1 |Edit Symbol| |
| | __________________________ |
| Device1 | Profile | Profile 1 ⌄ | |
| | ¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯ |
| | |
| | Properties |
| | Which properties should be used to |
| | identify the pipewire device |
| | |
| | Name Value + |
| | _______________________________________ |
| | | property.name1 Property Value | |
| | | property.name2 Property Value | |
| | | ... ... ... | |
| | ¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯ |
| | |
¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯
Device Property Chooser
__________________________
| Select Device Property |
| __________________ |
| | search | |
| ¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯ |
| property.name1 |
| property.name1 |
| ... |
| |
| Select |
| Create Custom |
| Cancel |
| |
¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯

View File

@ -4,7 +4,8 @@
"runtime-version" : "master", "runtime-version" : "master",
"sdk" : "org.gnome.Sdk", "sdk" : "org.gnome.Sdk",
"sdk-extensions" : [ "sdk-extensions" : [
"org.freedesktop.Sdk.Extension.rust-stable" "org.freedesktop.Sdk.Extension.rust-stable",
"org.freedesktop.Sdk.Extension.llvm20"
], ],
"command" : "audio-device-manager", "command" : "audio-device-manager",
"finish-args" : [ "finish-args" : [
@ -16,12 +17,13 @@
], ],
"build-options" : { "build-options" : {
"append-path" : "/usr/lib/sdk/rust-stable/bin", "append-path" : "/usr/lib/sdk/rust-stable/bin",
"prepend-ld-library-path" : "/usr/lib/sdk/llvm20/lib",
"build-args" : [ "build-args" : [
"--share=network" "--share=network"
], ],
"env" : { "env" : {
"RUST_BACKTRACE" : "1", "RUST_BACKTRACE" : "1",
"RUST_LOG" : "audio-device-manager=debug" "RUST_LOG" : "audio-device-manager=debug",
} }
}, },
"cleanup" : [ "cleanup" : [
@ -44,8 +46,11 @@
{ {
"type" : "git", "type" : "git",
"url" : "https://gitea.ada-baumann.de/ada/AudioDeviceManager", "url" : "https://gitea.ada-baumann.de/ada/AudioDeviceManager",
"branch" : "main" "branch" : "dev"
} }
],
"config-opts" : [
"--libdir=lib"
] ]
} }
] ]

View File

@ -0,0 +1,54 @@
{
"id" : "de.AdaLouBaumann.AudioDeviceManager",
"runtime" : "org.freedesktop.Platform",
"runtime-version" : "21.08",
"sdk" : "org.gnome.Sdk",
"sdk-extensions" : [
"org.freedesktop.Sdk.Extension.rust-stable",
"org.freedesktop.Sdk.Extension.llvm13"
],
"command" : "audio-device-manager",
"finish-args" : [
"--share=network",
"--share=ipc",
"--socket=fallback-x11",
"--device=dri",
"--socket=wayland"
],
"build-options" : {
"append-path" : "/usr/lib/sdk/rust-stable/bin:/usr/lib/sdk/llvm13/bin",
"prepend-ld-library-path": "/usr/lib/sdk/llvm13/lib",
"build-args" : [
"--share=network"
],
"env" : {
"RUST_BACKTRACE" : "1",
"RUST_LOG" : "audio-device-manager=debug"
}
},
"cleanup" : [
"/include",
"/lib/pkgconfig",
"/man",
"/share/doc",
"/share/gtk-doc",
"/share/man",
"/share/pkgconfig",
"*.la",
"*.a"
],
"modules" : [
{
"name" : "audio-device-manager",
"builddir" : true,
"buildsystem" : "meson",
"sources" : [
{
"type" : "git",
"url" : "https://gitea.ada-baumann.de/ada/AudioDeviceManager",
"branch" : "dev"
}
]
}
]
}

View File

@ -4,6 +4,8 @@ project('audio-device-manager', 'rust',
default_options: [ 'warning_level=2', 'werror=false', ], default_options: [ 'warning_level=2', 'werror=false', ],
) )
#inc_dir = include_directories('/usr/lib/sdk/llvm20/lib/clang/20/include')
i18n = import('i18n') i18n = import('i18n')
gnome = import('gnome') gnome = import('gnome')

17
run.sh
View File

@ -3,8 +3,15 @@
SRC_DIR="$(dirname "$0")" SRC_DIR="$(dirname "$0")"
cd "$SRC_DIR" || exit cd "$SRC_DIR" || exit
git add . #export DESTDIR=~/.local
git commit -m "dev-$(date -Iseconds)" #export PKGDATADIR=~/.local/share
git push
flatpak-builder --force-clean --user --install --ccache --install-deps-from=gnome-nightly --install-deps-from=flathub --install-deps-from=flathub-beta build de.AdaLouBaumann.AudioDeviceManager.json ~/.local/bin/meson build || exit
flatpak run de.AdaLouBaumann.AudioDeviceManager sudo ~/.local/bin/meson install -C build || exit
RUST_BACKTRACE=1 audio-device-manager || exit
#git add .
#git commit -m "dev-$(date -Iseconds)"
#git push
#flatpak-builder --force-clean --user --install --ccache --install-deps-from=gnome-nightly --install-deps-from=flathub --install-deps-from=flathub-beta build de.AdaLouBaumann.AudioDeviceManager.json
#flatpak run de.AdaLouBaumann.AudioDeviceManager

2
src/components.rs Normal file
View File

@ -0,0 +1,2 @@
use adw::gdk::pango;
use gtk::ListBoxRow;

View File

@ -1,4 +1,4 @@
pub static VERSION: &str = "0.1.0"; pub static VERSION: &str = "0.1.0";
pub static GETTEXT_PACKAGE: &str = "audio-device-manager"; pub static GETTEXT_PACKAGE: &str = "audio-device-manager";
pub static LOCALEDIR: &str = "/app/share/locale"; pub static LOCALEDIR: &str = "/usr/local/share/locale";
pub static PKGDATADIR: &str = "/app/share/audio-device-manager"; pub static PKGDATADIR: &str = "/usr/local/share/audio-device-manager";

View File

@ -22,6 +22,10 @@
mod application; mod application;
mod config; mod config;
mod window; mod window;
mod components;
mod pw_manager;
mod utils;
mod state_manager;
use self::application::AudioDeviceManagerApplication; use self::application::AudioDeviceManagerApplication;
use self::window::AudioDeviceManagerWindow; use self::window::AudioDeviceManagerWindow;

410
src/pw_manager.rs Normal file
View File

@ -0,0 +1,410 @@
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::fmt::{Debug, Formatter};
use std::rc::Rc;
use std::thread;
use std::thread::{sleep, JoinHandle};
use std::time::Duration;
use gtk::glib::ExitCode;
use pipewire::{context::Context, main_loop::MainLoop};
use pipewire::properties::Properties;
use pipewire::registry::{GlobalObject, Registry};
use pipewire::spa::param::ParamType;
use crate::pw_manager::deserialize::deserialize;
#[derive(Debug)]
enum Command {
GetDeviceNames,
GetDeviceProperties(usize),
}
enum Data {
DeviceNames(HashMap<usize, String>),
DeviceProperties(usize, HashMap<String, String>),
}
pub(crate) struct PipewireManager {
handle: JoinHandle<ExitCode>,
tx: pipewire::channel::Sender<Command>,
rx: flume::Receiver<Data>,
}
impl Default for PipewireManager {
fn default() -> Self {
let (handle, tx, rx) = spawn_pipewire_thread();
Self {
handle,
tx,
rx
}
}
}
impl PipewireManager {
pub(crate) fn get_device_names(&self) -> HashMap<usize, String> {
self.tx.send(Command::GetDeviceNames).unwrap();
match self.rx.recv().unwrap() {
Data::DeviceNames(names) => names,
_ => panic!("Invalid data")
}
}
pub(crate) fn get_device_properties(&self, id: usize) -> HashMap<String, String> {
self.tx.send(Command::GetDeviceProperties(id)).unwrap();
match self.rx.recv().unwrap() {
Data::DeviceProperties(_, properties) => properties,
_ => panic!("Invalid data")
}
}
}
enum DeviceAvailability {
Available,
Unavailable,
Unknown
}
struct DeviceProfile {
id: usize,
name: String,
description: String,
priority: usize,
available: DeviceAvailability,
}
// Devices don't have to be connected to current system
// Relevant device data has to be stored
// User is supposed to be able to pick out which device data is relevant and identifying
// default is device.bus-path or device.product.id
// could also be device.product.id + device.product.name + device.vendor.name
// Interface: List of all device properties and next to them a checkbox for "relevant identifier"
// Maybe also allow regex
// also maybe allow custom script
struct Device {
active_profile: Rc<RefCell<Option<usize>>>,
profiles: Rc<RefCell<HashMap<usize, DeviceProfile>>>,
name: Rc<RefCell<String>>,
id: usize,
properties: Rc<RefCell<HashMap<String, String>>>,
pipewire_device: pipewire::device::Device,
device_listener: pipewire::device::DeviceListener,
}
impl Device {
fn new(pipewire_device: pipewire::device::Device, id: usize) -> Self {
let active_profile = Rc::new(RefCell::new(None));
let profiles = Rc::new(RefCell::new(HashMap::new()));
let name = Rc::new(RefCell::new(String::new()));
let properties = Rc::new(RefCell::new(HashMap::new()));
let mut listener_builder = pipewire_device.add_listener_local();
listener_builder= Self::listen_for_params(listener_builder, &active_profile, &profiles);
listener_builder= Self::listen_for_info(listener_builder, &name, &properties);
let device_listener = listener_builder.register();
pipewire_device.subscribe_params(&[
ParamType::Profile,
ParamType::EnumProfile
]);
Self {
active_profile,
profiles,
name,
id,
properties,
pipewire_device,
device_listener
}
}
fn listen_for_params<'a>(
listener_builder: pipewire::device::DeviceListenerLocalBuilder<'a>,
active_profile: &Rc<RefCell<Option<usize>>>,
profiles: &Rc<RefCell<HashMap<usize, DeviceProfile>>>,
) -> pipewire::device::DeviceListenerLocalBuilder<'a> {
listener_builder
.param({
let active_profile_weak = Rc::downgrade(active_profile);
let profiles_weak = Rc::downgrade(profiles);
move |seq, param_type, index, next, param| {
let Some(active_profile) = active_profile_weak.upgrade() else { return };
let Some(profiles) = profiles_weak.upgrade() else { return };
let Some(param) = deserialize(param) else { return };
Self::on_profile_discovered(
param,
param_type,
active_profile,
profiles
)
}
})
}
fn listen_for_info<'a>(
listener_builder: pipewire::device::DeviceListenerLocalBuilder<'a>,
name: &Rc<RefCell<String>>,
properties: &Rc<RefCell<HashMap<String, String>>>,
) -> pipewire::device::DeviceListenerLocalBuilder<'a> {
listener_builder
.info({
let name_weak = Rc::downgrade(name);
let properties_weak = Rc::downgrade(properties);
move |info| {
let Some(name) = name_weak.upgrade() else { return };
let Some(properties) = properties_weak.upgrade() else { return };
Self::on_info_discovered(
info,
name,
properties
)
}
})
}
fn on_profile_discovered(
param: libspa::pod::Object,
param_type: ParamType,
active_profile: Rc<RefCell<Option<usize>>>,
profiles: Rc<RefCell<HashMap<usize, DeviceProfile>>>,
) {
let mut profile_id = None;
let mut profile_name = None;
let mut profile_description = None;
let mut profile_priority = None;
let mut profile_availibility = None;
for property in param.properties {
match (property.key, property.value) {
(1, libspa::pod::Value::Int(id)) => { profile_id = Some(id as usize); },
(2, libspa::pod::Value::String(name)) => { profile_name = Some(name); },
(3, libspa::pod::Value::String(description)) => { profile_description = Some(description); },
(4, libspa::pod::Value::Int(priority)) => { profile_priority = Some(priority as usize); },
(5, libspa::pod::Value::Id(libspa::utils::Id(0))) => { profile_availibility = Some(DeviceAvailability::Unknown); },
(5, libspa::pod::Value::Id(libspa::utils::Id(1))) => { profile_availibility = Some(DeviceAvailability::Unavailable); },
(5, libspa::pod::Value::Id(libspa::utils::Id(2))) => { profile_availibility = Some(DeviceAvailability::Available); },
_ => {}
}
}
// TODO: Proper error handling
match param_type {
ParamType::Profile => {
active_profile.borrow_mut().replace(profile_id.unwrap());
},
ParamType::EnumProfile => {
let device_profile = DeviceProfile {
id: profile_id.unwrap(),
name: profile_name.unwrap(),
description: profile_description.unwrap(),
priority: profile_priority.unwrap(),
available: profile_availibility.unwrap(),
};
profiles.borrow_mut().insert(profile_id.unwrap(), device_profile);
},
_ => return
}
}
fn on_info_discovered(
info: &pipewire::device::DeviceInfoRef,
name: Rc<RefCell<String>>,
properties: Rc<RefCell<HashMap<String, String>>>,
) {
info.props().unwrap().iter()
.for_each(|(k, v)| {
if k == "device.description" {
*name.borrow_mut() = v.to_string();
}
properties.borrow_mut().insert(k.to_string(), v.to_string());
});
}
}
struct GlobalsManager {
globals: Vec<GlobalObject<Properties>>,
devices: HashMap<usize, Device>,
}
impl GlobalsManager {
fn new() -> Self {
Self {
globals: Vec::new(),
devices: HashMap::new(),
}
}
fn add_global(&mut self, global: GlobalObject<Properties>, registry: &Registry) {
self.globals.push(global);
let global_ref = self.globals.last().unwrap();
let props = match &global_ref.props {
Some(props) => props,
None => return
};
match props.get("media.class") {
Some("Audio/Device") => {}
_ => return
}
let device_id = global_ref.id as usize;
let device = Device::new(registry.bind(global_ref).unwrap(), device_id);
self.devices.insert(device_id, device);
println!("ADDED DEVICE");
}
fn get_device_names(&self) -> HashMap<usize, String> {
self.devices.iter()
.map(|(id, d)| (*id, d.name.borrow().clone()))
.collect()
}
fn get_device_properties(&self, id: usize) -> HashMap<String, String> {
self.devices.get(&id)
.and_then(|d| Some(d.properties.borrow().clone()))
.unwrap()
}
}
pub(crate) fn spawn_pipewire_thread() -> (JoinHandle<ExitCode>, pipewire::channel::Sender<Command>, flume::Receiver<Data>) {
let (tx, remote_rx) = flume::unbounded();
let (remote_tx, rx) = pipewire::channel::channel();
let pw_thread = thread::spawn(|| {
// Initialize PipeWire and run the main loop
// ...
let mainloop = MainLoop::new(None).expect("failed to get mail loop");
let context = Context::new(&mainloop).expect("failed to get context");
let core = context.connect(None).expect("failed to get core");
let registry = Rc::new(core.get_registry().expect("failed to get registry"));
let globals_manager = Rc::new(RefCell::new(GlobalsManager::new()));
let _listener = registry
.add_listener_local()
.global({
let registry_weak = Rc::downgrade(&registry);
let globals_manager_weak = Rc::downgrade(&globals_manager);
move |global|
{
let Some(registry) = registry_weak.upgrade() else {
return;
};
let Some(globals_manager) = globals_manager_weak.upgrade() else {
return;
};
let owned_global = global.to_owned();
globals_manager.borrow_mut().add_global(owned_global, &registry);
/*
if global.type_ == ObjectType::Port {
let props = global.props.as_ref().unwrap();
let port_name = props.get("port.name");
let port_alias = props.get("port.alias");
let object_path = props.get("object.path");
let format_dsp = props.get("format.dsp");
let audio_channel = props.get("audio.channel");
let port_id = props.get("port.id");
let port_direction = props.get("port.direction");
println!("Port: Name: {:?} Alias: {:?} Id: {:?} Direction: {:?} AudioChannel: {:?} Object Path: {:?} FormatDsp: {:?}",
port_name,
port_alias,
port_id,port_direction,audio_channel,object_path,format_dsp
);
} else if global.type_ == ObjectType::Device {
let props = global.props.as_ref().unwrap();
let device_name = props.get("device.name");
let device_nick = props.get("device.nick");
let device_description = props.get("device.description");
let device_api = props.get("device.api");
let media_class = props.get("media.class");
println!("Device: Name: {:?} Nick: {:?} Desc: {:?} Api: {:?} MediaClass: {:?}",
device_name, device_nick, device_description, device_api, media_class);
}
*/
}
}
)
.register();
let _receiver = rx.attach(mainloop.loop_(), {
let mainloop = mainloop.clone();
{
let globals_manager_weak = Rc::downgrade(&globals_manager);
move |command| {
let Some(globals_manager) = globals_manager_weak.upgrade() else {
return;
};
match command {
Command::GetDeviceNames => {
let device_names = globals_manager.borrow().get_device_names();
tx.send(Data::DeviceNames(device_names)).unwrap();
}
Command::GetDeviceProperties(id) => {
let device_properties = globals_manager.borrow().get_device_properties(id);
tx.send(Data::DeviceProperties(id, device_properties)).unwrap();
}
}
}
}
});
// Calling the `destroy_global` method on the registry will destroy the object with the specified id on the remote.
// We don't have a specific object to destroy now, so this is commented out.
// registry.destroy_global(313).into_result()?;
mainloop.run();
return ExitCode::FAILURE; // not sure
});
(pw_thread, remote_tx, remote_rx)
}
mod deserialize {
/// Taken from wiremix: https://github.com/tsowell/wiremix/blob/main/src/wirehose/deserialize.rs#L6
use libspa::pod::{deserialize::PodDeserializer, Object, Pod, Value};
pub fn deserialize(param: Option<&Pod>) -> Option<Object> {
param
.and_then(|pod| {
PodDeserializer::deserialize_any_from(pod.as_bytes()).ok()
})
.and_then(|(_, value)| match value {
Value::Object(obj) => Some(obj),
_ => None,
})
}
}

377
src/state_manager.rs Normal file
View File

@ -0,0 +1,377 @@
use std::cell::{Ref, RefCell, RefMut};
use std::collections::HashMap;
use std::path::PathBuf;
use std::rc::Rc;
use adw::gdk::Device;
use crate::state_manager::simple_serde::SimpleSerde;
use crate::state_manager::structs::DeviceConfig;
trait StateBackend {
fn new(path: PathBuf) -> Self;
fn insert_option<T: SimpleSerde>(&mut self, key: &str, value: &Option<T>, namespace: &Option<String>) -> bool {
match value {
Some(v) => {
self.insert(key, v, namespace);
true
}
None => {
self.remove(key, namespace);
false
},
}
}
fn insert<T: SimpleSerde>(&mut self, key: &str, value: &T, namespace: &Option<String>);
fn remove(&mut self, key: &str, namespace: &Option<String>);
fn get<T: SimpleSerde>(&self, key: &str, namespace: &Option<String>) -> Option<T>;
}
pub(crate) mod backends {
use std::fmt::Debug;
use std::path::PathBuf;
use crate::state_manager::StateBackend;
use crate::state_manager::simple_serde::SimpleSerde;
pub(crate) struct SledBackend {
db: sled::Db,
db_path: PathBuf,
}
impl SledBackend {
fn resolve_key(key: &str, namespace: &Option<String>) -> String {
match namespace {
Some(ns) => format!("/{}/{}", ns, key),
None => format!("_{}", key),
}
}
}
impl StateBackend for SledBackend {
fn new(path: PathBuf) -> Self {
Self {
db: sled::open(path.clone()).unwrap(),
db_path: path,
}
}
fn insert<T: SimpleSerde>(&mut self, key: &str, value: &T, namespace: &Option<String>) {
println!("insert {}", Self::resolve_key(key, namespace));
self.db.insert(Self::resolve_key(key, namespace), value.serialize()).unwrap();
}
fn remove(&mut self, key: &str, namespace: &Option<String>) {
println!("remove {}", Self::resolve_key(key, namespace));
self.db.remove(Self::resolve_key(key, namespace)).unwrap();
}
fn get<T: SimpleSerde>(&self, key: &str, namespace: &Option<String>) -> Option<T> {
println!("get {}", Self::resolve_key(key, namespace));
self.db.get(Self::resolve_key(key, namespace))
.expect(format!("Error accessing db {}", self.db_path.display()).as_str())
.and_then(
|v: sled::IVec|
Some(SimpleSerde::deserialize(
Vec::from(v.as_ref())
))
)
}
}
}
pub(crate) mod structs {
use std::collections::HashMap;
use crate::state_manager::StateBackend;
pub(crate) struct DeviceConfig {
name: String,
identifying_properties: HashMap<String, String>,
available_profiles: Vec<String>,
selected_profile: Option<String>,
}
impl DeviceConfig {
pub(super) fn new<Backend: StateBackend>(
name: String,
identifying_properties: HashMap<String, String>,
available_profiles: Vec<String>,
selected_profile: Option<String>,
backend: &mut Backend,
namespace: &Option<String>,
) -> Self {
let instance = Self {
name,
identifying_properties,
available_profiles,
selected_profile
};
instance.save(backend, namespace).unwrap();
instance
}
pub(super) fn load<Backend: StateBackend>(name: String, backend: &mut Backend, namespace: &Option<String>) -> Self {
let device_namespace_string = match namespace {
Some(ns) => ns.clone() + "/" + &name,
None => name.clone()
};
let properties_namespace = Some(device_namespace_string.clone() + "/" + "identifying_properties");
let device_namespace = Some(device_namespace_string);
let properties = backend.get("properties", &device_namespace).unwrap_or(Vec::new());
let identifying_properties = properties.into_iter()
.flat_map(|name: String| {
backend.get(&name, &properties_namespace)
.and_then(|v| Some((name, v)))
})
.collect();
Self {
name,
available_profiles: backend.get("available_profiles", &device_namespace).expect("available_profiles not found"),
selected_profile: backend.get("selected_profile", &device_namespace),
identifying_properties
}
}
pub(super) fn save<Backend: StateBackend>(&self, backend: &mut Backend, namespace: &Option<String>) -> Result<(), String> {
let mut devices: Vec<String> = backend.get("devices", &namespace)
.unwrap_or(Vec::new());
if devices.contains(&self.name) {
return Err("Two devices with the same name cannot exist".to_string())
}
devices.push(self.name.clone());
backend.insert("devices", &devices, &namespace);
let device_namespace_string = match namespace {
Some(ns) => ns.clone() + "/" + &self.name,
None => self.name.clone()
};
let properties_namespace = Some(device_namespace_string.clone() + "/" + "identifying_properties");
let device_namespace = Some(device_namespace_string);
backend.insert("available_profiles", &self.available_profiles, &device_namespace);
backend.insert_option("selected_profile", &self.selected_profile, &device_namespace);
let property_names: Vec<String> = self.identifying_properties.iter()
.map(|(k, v)| k.clone())
.collect();
backend.insert("properties", &property_names, &device_namespace);
for (name, value) in &self.identifying_properties {
backend.insert(name, value, &properties_namespace);
}
Ok(())
}
pub(crate) fn get_name(&self) -> &str {
&self.name
}
pub(crate) fn get_identifying_properties(&self) -> &HashMap<String, String> {
&self.identifying_properties
}
pub(crate) fn get_available_profiles(&self) -> &Vec<String> {
&self.available_profiles
}
pub(crate) fn get_selected_profile(&self) -> &Option<String> {
&self.selected_profile
}
}
}
pub(crate) mod simple_serde {
use std::fmt::Debug;
pub(crate) trait SimpleSerde {
fn serialize(&self) -> Vec<u8>;
fn deserialize(bytes: Vec<u8>) -> Self;
}
impl SimpleSerde for u64 {
fn serialize(&self) -> Vec<u8> {
let mut res = [0u8; 8];
let mut num = *self;
for i in 0..8 {
res[i] = (num % 256) as u8;
num = num >> 8;
};
res.to_vec()
}
fn deserialize(bytes: Vec<u8>) -> Self {
let mut num: Self = 0;
for i in (0..8).rev() {
num = num << 8;
num = num + (bytes[i] as u64);
}
num
}
}
impl SimpleSerde for String {
fn serialize(&self) -> Vec<u8> {
self.clone().into_bytes()
}
fn deserialize(bytes: Vec<u8>) -> Self {
String::from_utf8(bytes)
.expect("Couldn't deserialize string")
}
}
impl<T> SimpleSerde for Vec<T>
where
T: SimpleSerde + Debug
{
fn serialize(&self) -> Vec<u8> {
let mut res = Vec::<u8>::new();
for el in self {
let mut el_ser = el.serialize();
let el_len = el_ser.len() as u64;
let mut len_ser = SimpleSerde::serialize(&el_len);
res.append(&mut len_ser);
res.append(&mut el_ser);
};
res
}
fn deserialize(bytes: Vec<u8>) -> Self {
let mut res = Vec::new();
let mut i = 0;
while i < bytes.len() {
let el_len = <u64 as SimpleSerde>::deserialize(bytes[i..i+8].to_vec()) as usize;
i = i + 8;
res.push(
SimpleSerde::deserialize(bytes[i..i + el_len].to_vec())
);
i = i + el_len;
}
res
}
}
}
enum RootNamespaces {
None,
DeviceConfigs,
}
impl From<RootNamespaces> for Option<String> {
fn from(value: RootNamespaces) -> Self {
match value {
RootNamespaces::None => None,
RootNamespaces::DeviceConfigs => Some("DeviceConfigs".to_string()),
}
}
}
pub(crate) struct StateManager<Backend: StateBackend> {
db: Backend,
device_configs: HashMap<String, DeviceConfig>
}
impl<Backend: StateBackend> StateManager<Backend> {
pub(crate) fn new() -> Self {
let mut instance = Self {
db: Backend::new(crate::utils::CONFIG_DIR.clone().join("db")),
device_configs: HashMap::new(),
};
instance.load();
instance
}
pub(crate) fn load(&mut self) {
let device_config_names: Vec<String> = self.db.get("devices", &RootNamespaces::DeviceConfigs.into())
.unwrap_or(Vec::new());
println!("device_config_names: {:?}", device_config_names);
for device_config_name in device_config_names {
let device_config = DeviceConfig::load(device_config_name.clone(), &mut self.db, &RootNamespaces::DeviceConfigs.into());
self.device_configs.insert(device_config_name, device_config);
}
}
pub(crate) fn get_device_config_names(&mut self) -> Vec<String> {
self.device_configs.iter()
.map(|(name, _)| name.clone())
.collect()
}
pub(crate) fn get_device_config_properties(&mut self, name: &str) -> &HashMap<String, String> {
self.device_configs.get(name)
.unwrap()
.get_identifying_properties()
}
pub(crate) fn get_device_config_profiles(&mut self, name: &str) -> &Vec<String> {
self.device_configs.get(name)
.unwrap()
.get_available_profiles()
}
pub(crate) fn get_device_config_selected_profile(&mut self, name: &str) -> &Option<String> {
self.device_configs.get(name)
.unwrap()
.get_selected_profile()
}
pub(crate) fn create_new_device_config(
&mut self,
name: String,
identifying_properties: HashMap<String, String>,
profiles: Vec<String>,
active_profile: Option<String>
) {
let config = DeviceConfig::new(
name.clone(),
identifying_properties,
profiles,
active_profile,
&mut self.db,
&RootNamespaces::DeviceConfigs.into()
);
self.device_configs.insert(name, config);
}
}
pub(crate) struct RcStateManager<Backend: StateBackend> {
state_manager: Rc<RefCell<StateManager<Backend>>>
}
impl<Backend: StateBackend> Default for RcStateManager<Backend> {
fn default() -> Self {
Self {
state_manager: Rc::new(RefCell::new(StateManager::new()))
}
}
}
impl<Backend: StateBackend> RcStateManager<Backend> {
pub(crate) fn borrow_mut(&self) -> RefMut<'_, StateManager<Backend>> {
self.state_manager.borrow_mut()
}
pub(crate) fn borrow(&self) -> Ref<'_, StateManager<Backend>> {
self.state_manager.borrow()
}
}

10
src/utils.rs Normal file
View File

@ -0,0 +1,10 @@
use std::path::PathBuf;
use dirs::config_dir;
use lazy_static::lazy_static;
lazy_static! {
pub static ref CONFIG_DIR: PathBuf = {
let user_config_dir = config_dir().expect("Could not find user config dir");
user_config_dir.join("audio-device-manager")
};
}

View File

@ -6,15 +6,24 @@ template $AudioDeviceManagerWindow: Adw.ApplicationWindow {
default-width: 800; default-width: 800;
default-height: 600; default-height: 600;
content: Adw.NavigationSplitView { Adw.Breakpoint {
condition ("max-width: 500sp")
setters {
split_view.collapsed: true;
}
}
content: Adw.NavigationSplitView split_view {
min-sidebar-width: 200;
sidebar: Adw.NavigationPage { sidebar: Adw.NavigationPage {
title: _("Sidebar"); title: _("Devices");
tag: "sidebar"; tag: "devices";
child: Adw.ToolbarView { child: Adw.ToolbarView {
[top] [top]
Adw.HeaderBar { Adw.HeaderBar {
show-title: false; show-title: true;
[start] [start]
Gtk.ToggleButton { Gtk.ToggleButton {
icon-name: "list-add-symbolic"; icon-name: "list-add-symbolic";
@ -33,14 +42,14 @@ template $AudioDeviceManagerWindow: Adw.ApplicationWindow {
}; };
}; };
content: Adw.NavigationPage { content: Adw.NavigationPage device_navigation_page {
title: _("Content"); title: _("");
tag: "content"; tag: "device navigation page";
child: Adw.ToolbarView { child: Adw.ToolbarView {
[top] [top]
Adw.HeaderBar { Adw.HeaderBar {
show-title: false; show-title: true;
[end] [end]
MenuButton { MenuButton {
primary: true; primary: true;
@ -50,13 +59,11 @@ template $AudioDeviceManagerWindow: Adw.ApplicationWindow {
} }
} }
content: Adw.StatusPage { content: Gtk.ScrolledWindow {
title: _("Content"); child: Adw.Clamp device_page_clamp {
maximum-size: 640;
LinkButton { };
label: _("API Reference");
uri: "https://ada-baumann.de";
}
}; };
}; };
}; };

View File

@ -1,70 +0,0 @@
/* window.rs
*
* Copyright 2025 Ada
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*
* SPDX-License-Identifier: GPL-2.0-or-later
*/
use gtk::prelude::*;
use adw::subclass::prelude::*;
use gtk::{gio, glib};
mod imp {
use super::*;
#[derive(Debug, Default, gtk::CompositeTemplate)]
#[template(resource = "/de/AdaLouBaumann/AudioDeviceManager/window.ui")]
pub struct AudioDeviceManagerWindow {
// Template widgets
#[template_child]
pub devices_list: TemplateChild<gtk::ListBox>,
}
#[glib::object_subclass]
impl ObjectSubclass for AudioDeviceManagerWindow {
const NAME: &'static str = "AudioDeviceManagerWindow";
type Type = super::AudioDeviceManagerWindow;
type ParentType = adw::ApplicationWindow;
fn class_init(klass: &mut Self::Class) {
klass.bind_template();
}
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
obj.init_template();
}
}
impl ObjectImpl for AudioDeviceManagerWindow {}
impl WidgetImpl for AudioDeviceManagerWindow {}
impl WindowImpl for AudioDeviceManagerWindow {}
impl ApplicationWindowImpl for AudioDeviceManagerWindow {}
impl AdwApplicationWindowImpl for AudioDeviceManagerWindow {}
}
glib::wrapper! {
pub struct AudioDeviceManagerWindow(ObjectSubclass<imp::AudioDeviceManagerWindow>)
@extends gtk::Widget, gtk::Window, gtk::ApplicationWindow, adw::ApplicationWindow, @implements gio::ActionGroup, gio::ActionMap;
}
impl AudioDeviceManagerWindow {
pub fn new<P: IsA<gtk::Application>>(application: &P) -> Self {
glib::Object::builder()
.property("application", application)
.build()
}
}

248
src/window/mod.rs Normal file
View File

@ -0,0 +1,248 @@
/* window.rs
*
* Copyright 2025 Ada
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*
* SPDX-License-Identifier: GPL-2.0-or-later
*/
mod popups;
mod subpages;
use std::cell::{Ref, RefCell, RefMut};
use std::cmp::min;
use std::rc::Rc;
use adw::glib::clone;
use adw::prelude::{AlertDialogExt, AlertDialogExtManual, NavigationPageExt};
use adw::{PreferencesGroup, ResponseAppearance};
use gtk::prelude::*;
use adw::prelude::*;
use adw::subclass::prelude::*;
use gtk::{gio, glib};
use subpages::device_config;
#[derive(Default)]
struct ProceduralChildManager {
device_configs_preferences_group: adw::PreferencesGroup
}
struct RcProceduralChildManager {
procedural_child_manager: Rc<RefCell<ProceduralChildManager>>,
}
impl RcProceduralChildManager {
fn borrow_mut(&self) -> RefMut<'_, ProceduralChildManager> {
self.procedural_child_manager.borrow_mut()
}
fn borrow(&self) -> Ref<'_, ProceduralChildManager> {
self.procedural_child_manager.borrow()
}
}
impl Default for RcProceduralChildManager {
fn default() -> Self {
Self {
procedural_child_manager: Rc::new(RefCell::new(ProceduralChildManager::default())),
}
}
}
mod imp {
use crate::pw_manager::PipewireManager;
use crate::state_manager::RcStateManager;
use crate::state_manager::backends::SledBackend;
use super::*;
#[derive(Default, gtk::CompositeTemplate)]
#[template(resource = "/de/AdaLouBaumann/AudioDeviceManager/window.ui")]
pub struct AudioDeviceManagerWindow {
pub pipewire_manager: PipewireManager,
pub state_manager: RcStateManager<SledBackend>,
pub procedural_child_manager: RcProceduralChildManager,
// Template widgets
#[template_child]
pub devices_list: TemplateChild<gtk::ListBox>,
#[template_child]
pub device_page_clamp: TemplateChild<adw::Clamp>,
#[template_child]
pub device_navigation_page: TemplateChild<adw::NavigationPage>,
#[template_child]
pub split_view: TemplateChild<adw::NavigationSplitView>,
}
#[glib::object_subclass]
impl ObjectSubclass for AudioDeviceManagerWindow {
const NAME: &'static str = "AudioDeviceManagerWindow";
type Type = super::AudioDeviceManagerWindow;
type ParentType = adw::ApplicationWindow;
fn class_init(klass: &mut Self::Class) {
klass.bind_template();
// Create async action to create new device and add to action group "win"
klass.install_action_async(
"win.new-device",
None,
|window, _, _| async move {
popups::new_device::new_device(&window).await;
}
);
klass.install_action_async(
"win.select-device-config",
None,
|window, _, _| async move {
popups::select_device_config::select_device_config(&window).await;
}
);
}
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
obj.init_template();
}
}
impl ObjectImpl for AudioDeviceManagerWindow {}
impl WidgetImpl for AudioDeviceManagerWindow {}
impl WindowImpl for AudioDeviceManagerWindow {}
impl ApplicationWindowImpl for AudioDeviceManagerWindow {}
impl AdwApplicationWindowImpl for AudioDeviceManagerWindow {}
}
glib::wrapper! {
pub struct AudioDeviceManagerWindow(ObjectSubclass<imp::AudioDeviceManagerWindow>)
@extends gtk::Widget, gtk::Window, gtk::ApplicationWindow, adw::ApplicationWindow,
@implements gio::ActionGroup, gio::ActionMap;
}
impl AudioDeviceManagerWindow {
pub fn new<P: IsA<gtk::Application>>(application: &P) -> Self {
let instance: AudioDeviceManagerWindow = glib::Object::builder()
.property("application", application)
.build();
instance.bind_signals();
instance
}
fn bind_signals(&self) {
self.imp().devices_list.connect_row_activated(clone!(
#[weak(rename_to = window)]
self,
move |_, row| {
let label: gtk::Label = row
.child()
.and_downcast()
.expect("No Label in Row");
window.select_device(label.text().to_string());
}
));
}
fn select_device(&self, name: String) {
self.imp().device_navigation_page.set_title(&name);
let device_page = self.build_device_page(name);
self.imp().device_page_clamp.set_child(Some(&device_page));
}
fn build_device_page(&self, name: String) -> adw::PreferencesPage {
let device_page = adw::PreferencesPage::builder()
.margin_start(12)
.margin_end(12)
.build();
self.init_device_configs_group();
device_page.add(&self.imp().procedural_child_manager.borrow().device_configs_preferences_group);
device_page
}
fn init_device_configs_group(&self) {
let add_profile_button = gtk::Button::builder()
.valign(gtk::Align::Center)
.css_classes(["flat"])
.icon_name("list-add-symbolic")
.build();
add_profile_button.set_action_name(Some("win.select-device-config"));
let preferences_group = &mut self.imp().procedural_child_manager.borrow_mut().device_configs_preferences_group;
*preferences_group = adw::PreferencesGroup::builder()
.title("Pipewire Device Configuration")
.description("Select audio profiles for specific pipewire devices")
.header_suffix(&add_profile_button)
.build();
/*
self.imp().devices_list.connect_row_activated(clone!(
#[weak(rename_to = window)]
self,
move |_, row| {
println!("Row selected {}, {:?}", row.index(), row);
let label: gtk::Label = row
.child()
.and_downcast()
.expect("No Label in Row");
window.select_device(label.text().to_string());
}
));
*/
/*
TODO: Implement State management for Device page
for config_name in self.imp().state_manager.borrow_mut().get_device_config_names() {
let row = self.build_device_config_row(config_name);
preferences_group.add(&row);
}
*/
}
fn add_device_config(&self, config_name: &str) {
self.imp().procedural_child_manager.borrow_mut().device_configs_preferences_group.add(
&self.build_device_config_row(config_name.to_string())
);
}
fn build_device_config_row(&self, config_name: String) -> adw::ActionRow {
let row = adw::ActionRow::builder()
.title(config_name)
.activatable(true)
.build();
row.connect_activated(
clone!(
#[weak(rename_to = window)]
self,
move |row| {
println!("Row selected {:?}", row);
}
)
);
row
}
}

12
src/window/popups/mod.rs Normal file
View File

@ -0,0 +1,12 @@
use adw::glib::clone;
use adw::prelude::*;
use gtk::prelude::*;
use adw::subclass::prelude::*;
use gtk::{glib, gio, ListBoxRow};
use crate::window::AudioDeviceManagerWindow;
pub(super) mod new_device;
pub(super) mod select_device_config;
mod new_device_config;
mod select_pipewire_device;
mod new_device_config_name;

View File

@ -0,0 +1,74 @@
use super::*;
use adw::gdk::pango;
use adw::ResponseAppearance;
pub(in crate::window) async fn new_device(main_window: &AudioDeviceManagerWindow) {
let entry = gtk::Entry::builder()
.placeholder_text("Name")
.activates_default(true)
.build();
let cancel_response = "cancel";
let create_response = "create";
// Create new dialog
let dialog = adw::AlertDialog::builder()
.heading("New Device")
.close_response(cancel_response)
.default_response(create_response)
.extra_child(&entry)
.build();
dialog.add_responses(&[(cancel_response, "Cancel"), (create_response, "Create")]);
// Make the dialog button insensitive initially
dialog.set_response_enabled(create_response, false);
dialog.set_response_appearance(create_response, ResponseAppearance::Suggested);
// Set entry's css class to "error", when there is no text in it
entry.connect_changed(clone!(
#[weak]
dialog,
move |entry| {
let text = entry.text();
let empty = text.is_empty();
dialog.set_response_enabled(create_response, !empty);
if empty {
entry.add_css_class("error");
} else {
entry.remove_css_class("error");
}
}
));
let response = dialog.choose_future(main_window).await;
// Return if the user chose 'cancel_response'
if response == cancel_response {
println!("Cancel");
return;
};
if response == create_response {
let device = new_device_row(entry.text().to_string());
main_window.imp().devices_list.append(&device);
device.activate();
return;
}
}
fn new_device_row(name: String) -> ListBoxRow {
let device_label = gtk::Label::builder()
.ellipsize(pango::EllipsizeMode::End)
.xalign(0.0)
.label(&name)
.build();
ListBoxRow::builder().child(&device_label).build()
}

View File

@ -0,0 +1,51 @@
use std::collections::HashMap;
use super::*;
use adw::ResponseAppearance;
use crate::window::popups;
pub(in crate::window) async fn new_device_config(main_window: &AudioDeviceManagerWindow) {
let cancel_response = "cancel";
let create_from_template_response = "create-from-template";
let create_new_response = "create-new";
// Create new dialog
let dialog = adw::AlertDialog::builder()
.heading("New Configuration")
.close_response(cancel_response)
.default_response(create_from_template_response)
.build();
dialog.add_responses(&[
(cancel_response, "Cancel"),
(create_new_response, "Create From Scratch"),
(create_from_template_response, "Use Pipewire Device as Template"),
]);
// Make the dialog button insensitive initially
dialog.set_response_appearance(create_from_template_response, ResponseAppearance::Suggested);
dialog.set_response_appearance(cancel_response, ResponseAppearance::Destructive);
let response = dialog.choose_future(main_window).await;
// Return if the user chose 'cancel_response'
if response == cancel_response {
println!("Cancel");
return;
};
if response == create_from_template_response {
popups::select_pipewire_device::select_pipewire_device(main_window).await;
return;
};
if response == create_new_response {
popups::new_device_config_name::new_device_config_name(
main_window,
None,
HashMap::new(),
Vec::new(),
None
).await;
return;
}
}

View File

@ -0,0 +1,93 @@
use std::collections::HashMap;
use adw::gdk::pango;
use adw::ResponseAppearance;
use crate::state_manager::structs::DeviceConfig;
use crate::window::subpages;
use super::*;
pub(in crate::window) async fn new_device_config_name(
main_window: &AudioDeviceManagerWindow,
name_suggestion: Option<String>,
properties: HashMap<String, String>,
profiles: Vec<String>,
selected_profile: Option<String>,
) {
let entry = gtk::Entry::builder()
.placeholder_text("Name")
.activates_default(true)
.build();
let cancel_response = "cancel";
let create_response = "create";
// Create new dialog
let dialog = adw::AlertDialog::builder()
.heading("Device Configuration Name")
.close_response(cancel_response)
.default_response(create_response)
.extra_child(&entry)
.build();
dialog.add_responses(&[(cancel_response, "Cancel"), (create_response, "Create")]);
// Make the dialog button insensitive initially
dialog.set_response_enabled(create_response, false);
dialog.set_response_appearance(create_response, ResponseAppearance::Suggested);
let config_names = main_window.imp().state_manager.borrow_mut().get_device_config_names();
if let Some(init_name) = name_suggestion {
entry.set_text(&init_name);
if init_name.is_empty() || config_names.contains(&init_name) {
entry.add_css_class("error")
} else {
dialog.set_response_enabled(create_response, true)
}
}
// Set entry's css class to "error", when there is no text in it
entry.connect_changed(clone!(
#[weak]
dialog,
move |entry| {
let text = entry.text();
let empty = text.is_empty();
let exists = config_names.contains(&String::from(text));
let err = empty || exists;
dialog.set_response_enabled(create_response, !err);
if err {
entry.add_css_class("error");
} else {
entry.remove_css_class("error");
}
}
));
let response = dialog.choose_future(main_window).await;
// Return if the user chose 'cancel_response'
if response == cancel_response {
println!("Cancel");
return;
};
if response == create_response {
let device_config_name = entry.text().to_string();
main_window.imp().state_manager.borrow_mut().create_new_device_config(
device_config_name.clone(),
properties,
profiles,
selected_profile
);
main_window.add_device_config(&device_config_name);
subpages::device_config::switch_to_device_config_window(main_window, device_config_name);
return;
};
}

View File

@ -0,0 +1,178 @@
use std::cell::RefCell;
use std::cmp::min;
use std::collections::HashMap;
use std::rc::Rc;
use super::*;
use crate::window::popups;
use adw::ResponseAppearance;
pub(in crate::window) async fn select_device_config(main_window: &AudioDeviceManagerWindow) {
let device_config_names = main_window.imp().state_manager.borrow_mut().get_device_config_names();
if device_config_names.is_empty() {
popups::new_device_config::new_device_config(main_window).await;
return
}
let cancel_response = "cancel";
let create_new_response = "create-new";
let duplicate_response = "duplicate";
let select_response = "select";
let dialog_body = gtk::Box::builder()
.spacing(12)
.orientation(gtk::Orientation::Vertical)
.build();
let entry = gtk::Entry::builder()
.placeholder_text("search")
.activates_default(true)
.hexpand(true)
.build();
let device_configs_list = gtk::ListBox::builder()
.css_classes(["boxed-list"])
.build();
let selected_config_name = Rc::new(RefCell::new(String::new()));
let mut device_configs_list_len = 0;
for device_config_row in build_device_config_rows(main_window, &selected_config_name) {
device_configs_list_len += 1;
device_configs_list.append(&device_config_row);
}
let dialog_body_clamp = adw::Clamp::builder()
.maximum_size(400)
.tightening_threshold(300)
.child(&dialog_body)
.build();
let device_configs_list_scrollable = gtk::ScrolledWindow::builder()
.child(&device_configs_list)
.vexpand(true)
.build();
println!("{}", device_configs_list.height());
dialog_body.append(&entry);
dialog_body.append(&device_configs_list_scrollable);
// Create new dialog
let dialog = adw::AlertDialog::builder()
.heading("Select Configuration")
.close_response(cancel_response)
.default_response(select_response)
.extra_child(&dialog_body_clamp)
.height_request(
min(
min(809, main_window.height()),
383 + 55 * device_configs_list_len
)
)
.width_request(min(500, main_window.width()))
.build();
dialog.add_responses(&[(cancel_response, "Cancel"), (create_new_response, "Create New"), (duplicate_response, "Duplicate"), (select_response, "Select")]);
// Make the dialog button insensitive initially
dialog.set_response_enabled(select_response, false);
dialog.set_response_appearance(select_response, ResponseAppearance::Suggested);
dialog.set_response_enabled(duplicate_response, false);
dialog.set_response_appearance(cancel_response, ResponseAppearance::Destructive);
device_configs_list.connect_row_selected(clone!(
#[weak]
dialog,
move |list_box, row_option| {
match row_option {
Some(row) => {
dialog.set_response_enabled(select_response, true);
dialog.set_response_enabled(duplicate_response, true);
row.activate();
}
None => {
dialog.set_response_enabled(select_response, false);
dialog.set_response_enabled(duplicate_response, false);
}
};
}
));
let response = dialog.choose_future(main_window).await;
// Return if the user chose 'cancel_response'
if response == cancel_response {
println!("Cancel");
return;
}
if response == create_new_response {
popups::new_device_config::new_device_config(main_window).await;
return;
}
let selected_row = device_configs_list.selected_row().unwrap();
let config_name = selected_config_name.borrow().clone();
if response == duplicate_response {
let mut state_manager = main_window.imp().state_manager.borrow_mut();
let properties = state_manager.get_device_config_properties(&config_name).clone();
let profiles = state_manager.get_device_config_profiles(&config_name).clone();
let selected_profile = state_manager.get_device_config_selected_profile(&config_name).clone();
drop(state_manager);
popups::new_device_config_name::new_device_config_name(
main_window,
Some(selected_config_name.borrow().clone()),
properties,
profiles,
selected_profile
).await;
return;
}
if response == select_response {
main_window.add_device_config(&config_name);
return;
}
}
fn build_device_config_rows(
main_window: &AudioDeviceManagerWindow,
selected_config_name: &Rc<RefCell<String>>,
) -> Vec<adw::ActionRow> {
let mut row_vec = Vec::new();
let device_names = main_window.imp().state_manager.borrow_mut().get_device_config_names();
for config_name in device_names {
let row = adw::ActionRow::builder()
.title(config_name.clone())
.build();
row.connect_activate(clone!(
#[weak]
selected_config_name,
move |row| {
*selected_config_name.borrow_mut() = config_name.clone();
}
)
);
row_vec.push(
row
);
}
row_vec
}

View File

@ -0,0 +1,166 @@
use std::cell::RefCell;
use super::*;
use std::cmp::min;
use std::collections::HashMap;
use std::rc::Rc;
use adw::ResponseAppearance;
use crate::window::popups;
use crate::window::subpages::device_config;
pub(in crate::window) async fn select_pipewire_device(main_window: &AudioDeviceManagerWindow) {
let cancel_response = "cancel";
let select_response = "select";
let dialog_body = gtk::Box::builder()
.spacing(12)
.orientation(gtk::Orientation::Vertical)
.build();
let entry = gtk::Entry::builder()
.placeholder_text("search")
.activates_default(true)
.hexpand(true)
.build();
let device_list = gtk::ListBox::builder()
.css_classes(["boxed-list"])
.build();
let selected_device_id = Rc::new(RefCell::new(usize::MAX));
let mut device_list_len = 0;
for device_row in build_pipewire_device_rows(main_window, &selected_device_id) {
device_list_len += 1;
device_list.append(&device_row);
}
let dialog_body_clamp = adw::Clamp::builder()
.maximum_size(400)
.tightening_threshold(300)
.child(&dialog_body)
.build();
let device_list_scrollable = gtk::ScrolledWindow::builder()
.child(&device_list)
.vexpand(true)
.build();
println!("{}", device_list.height());
dialog_body.append(&entry);
dialog_body.append(&device_list_scrollable);
// Create new dialog
let dialog = adw::AlertDialog::builder()
.heading("Select Pipewire Device")
.close_response(cancel_response)
.default_response(select_response)
.extra_child(&dialog_body_clamp)
.height_request(
min(
min(809, main_window.height()),
215 + 55 * device_list_len
)
)
.width_request(min(500, main_window.width()))
.build();
dialog.add_responses(&[(cancel_response, "Cancel"), (select_response, "Select")]);
// Make the dialog button insensitive initially
dialog.set_response_enabled(select_response, false);
dialog.set_response_appearance(select_response, ResponseAppearance::Suggested);
dialog.set_response_appearance(cancel_response, ResponseAppearance::Destructive);
device_list.connect_row_selected(clone!(
#[weak]
dialog,
move |list_box, row_option| {
match row_option {
Some(row) => {
dialog.set_response_enabled(select_response, true);
row.activate();
}
None => {
dialog.set_response_enabled(select_response, false);
}
};
}
));
// Set entry's css class to "error", when there is no text in it
entry.connect_changed(clone!(
#[weak]
dialog,
move |entry| {
let text = entry.text();
let empty = text.is_empty();
}
));
let response = dialog.choose_future(main_window).await;
// Return if the user chose 'cancel_response'
if response == cancel_response {
println!("Cancel");
return;
}
if response == select_response {
let device_properties = main_window.imp().pipewire_manager
.get_device_properties(
selected_device_id.borrow().clone(),
);
let mut identifying_properties = HashMap::new();
identifying_properties.insert(
"device.name".to_string(),
device_properties.get("device.name").unwrap().clone()
);
popups::new_device_config_name::new_device_config_name(
main_window,
Some(device_properties.get("device.description").unwrap().clone()),
identifying_properties,
Vec::new(),
None
).await;
return;
}
}
fn build_pipewire_device_rows(
main_window: &AudioDeviceManagerWindow,
selected_device_id: &Rc<RefCell<usize>>
) -> Vec<adw::ActionRow> {
let mut row_vec = Vec::new();
let device_names = main_window.imp().pipewire_manager.get_device_names();
println!("{:?}", device_names);
for (device_id, device_name) in device_names {
let row = adw::ActionRow::builder()
.title(device_name)
.build();
row.connect_activate(clone!(
#[weak]
selected_device_id,
move |row| {
*selected_device_id.borrow_mut() = device_id;
}
)
);
row_vec.push(
row
);
}
row_vec
}

View File

@ -0,0 +1,99 @@
use adw::gdk::pango::EllipsizeMode;
use crate::window::AudioDeviceManagerWindow;
use adw::prelude::*;
use gtk::prelude::*;
use adw::subclass::prelude::*;
use adw::glib::clone;
use gtk::{glib, gio};
use crate::state_manager::structs::DeviceConfig;
pub(in crate::window) fn switch_to_device_config_window(main_window: &AudioDeviceManagerWindow, device_config_name: String) {
let device_config_window = build_device_config_window(main_window, device_config_name);
main_window.imp().split_view.set_content(Some(&device_config_window));
}
fn build_device_config_window(main_window: &AudioDeviceManagerWindow, device_config_name: String) -> adw::NavigationPage {
let (navigation_page, clamp) = build_page_base(main_window, device_config_name);
navigation_page
}
/*
[top]
Adw.HeaderBar {
show-title: true;
[end]
MenuButton {
primary: true;
icon-name: "open-menu-symbolic";
tooltip-text: _("Main Menu");
menu-model: primary_menu;
}
}
content: Gtk.ScrolledWindow {
child: Adw.Clamp device_page_clamp {
maximum-size: 640;
};
};
*/
fn build_page_base(main_window: &AudioDeviceManagerWindow, device_name: String) -> (adw::NavigationPage, adw::Clamp) {
let back_button= gtk::Button::builder()
.icon_name("go-previous")
.build();
back_button.connect_clicked(clone!(
#[weak]
main_window,
move |button| {
main_window.imp().split_view.set_content(
Some(
&main_window.imp().device_navigation_page.get()
)
)
}
));
let clamp = adw::Clamp::builder()
.maximum_size(640)
.build();
let scrolled_window = gtk::ScrolledWindow::builder()
.child(&clamp)
.build();
let header_bar = gtk::HeaderBar::builder()
.build();
header_bar.pack_start(&back_button);
let title_label = gtk::Label::builder()
.label(&device_name)
.single_line_mode(true)
.ellipsize(EllipsizeMode::End)
.width_chars(5)
.css_classes(["title"])
.build();
header_bar.set_title_widget(Some(&title_label));
let toolbar_view = adw::ToolbarView::builder()
.content(&scrolled_window)
.build();
toolbar_view.add_top_bar(&header_bar);
let navigation_page = adw::NavigationPage::builder()
.child(&toolbar_view)
.build();
println!("device name: {}", device_name);
(navigation_page, clamp)
}

View File

@ -0,0 +1 @@
pub(super) mod device_config;