diff --git a/Cargo.toml b/Cargo.toml index db65efb..c7e8e81 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,9 +4,14 @@ version = "0.1.0" edition = "2021" [dependencies] +dirs = "6.0.0" +flume = "0.11.1" gettext-rs = { version = "0.7", features = ["gettext-system"] } 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] package = "libadwaita" diff --git a/UI Drafts.txt b/UI Drafts.txt new file mode 100644 index 0000000..6601e6f --- /dev/null +++ b/UI Drafts.txt @@ -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 | +| | +¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯ diff --git a/de.AdaLouBaumann.AudioDeviceManager.json b/de.AdaLouBaumann.AudioDeviceManager.json index a33a713..db24dcc 100644 --- a/de.AdaLouBaumann.AudioDeviceManager.json +++ b/de.AdaLouBaumann.AudioDeviceManager.json @@ -1,11 +1,11 @@ { "id" : "de.AdaLouBaumann.AudioDeviceManager", "runtime" : "org.gnome.Platform", - "runtime-version" : "22.08", + "runtime-version" : "master", "sdk" : "org.gnome.Sdk", "sdk-extensions" : [ "org.freedesktop.Sdk.Extension.rust-stable", - "org.freedesktop.Sdk.Extension.llvm14" + "org.freedesktop.Sdk.Extension.llvm20" ], "command" : "audio-device-manager", "finish-args" : [ @@ -17,13 +17,13 @@ ], "build-options" : { "append-path" : "/usr/lib/sdk/rust-stable/bin", + "prepend-ld-library-path" : "/usr/lib/sdk/llvm20/lib", "build-args" : [ "--share=network" ], "env" : { "RUST_BACKTRACE" : "1", "RUST_LOG" : "audio-device-manager=debug", - "INCLUDE" : "/lib/clang/14.0.6/include" } }, "cleanup" : [ @@ -48,6 +48,9 @@ "url" : "https://gitea.ada-baumann.de/ada/AudioDeviceManager", "branch" : "dev" } + ], + "config-opts" : [ + "--libdir=lib" ] } ] diff --git a/de.AdaLouBaumann.AudioDeviceManager.json~ b/de.AdaLouBaumann.AudioDeviceManager.json~ new file mode 100644 index 0000000..64eae96 --- /dev/null +++ b/de.AdaLouBaumann.AudioDeviceManager.json~ @@ -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" + } + ] + } + ] +} diff --git a/meson.build b/meson.build index 040d6fc..1f4c7d7 100644 --- a/meson.build +++ b/meson.build @@ -4,6 +4,8 @@ project('audio-device-manager', 'rust', default_options: [ 'warning_level=2', 'werror=false', ], ) +#inc_dir = include_directories('/usr/lib/sdk/llvm20/lib/clang/20/include') + i18n = import('i18n') gnome = import('gnome') diff --git a/run.sh b/run.sh index 32f3d84..5c8c01f 100755 --- a/run.sh +++ b/run.sh @@ -3,8 +3,15 @@ SRC_DIR="$(dirname "$0")" cd "$SRC_DIR" || 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 \ No newline at end of file +#export DESTDIR=~/.local +#export PKGDATADIR=~/.local/share + +~/.local/bin/meson build || exit +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 \ No newline at end of file diff --git a/src/components.rs b/src/components.rs index ff2548a..3cb620c 100644 --- a/src/components.rs +++ b/src/components.rs @@ -1,12 +1,2 @@ use adw::gdk::pango; use gtk::ListBoxRow; - -pub(crate) fn new_device(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() -} \ No newline at end of file diff --git a/src/config.rs b/src/config.rs index 9eaa117..5584e53 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,4 @@ pub static VERSION: &str = "0.1.0"; pub static GETTEXT_PACKAGE: &str = "audio-device-manager"; -pub static LOCALEDIR: &str = "/app/share/locale"; -pub static PKGDATADIR: &str = "/app/share/audio-device-manager"; +pub static LOCALEDIR: &str = "/usr/local/share/locale"; +pub static PKGDATADIR: &str = "/usr/local/share/audio-device-manager"; diff --git a/src/main.rs b/src/main.rs index 77284e3..3d8a38a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,7 +23,9 @@ mod application; mod config; mod window; mod components; -mod pipewire; +mod pw_manager; +mod utils; +mod state_manager; use self::application::AudioDeviceManagerApplication; use self::window::AudioDeviceManagerWindow; diff --git a/src/meson.build b/src/meson.build index 8fa8ef0..8430c8f 100644 --- a/src/meson.build +++ b/src/meson.build @@ -43,7 +43,7 @@ run_command( cargo_bin = find_program('cargo') cargo_opt = [ '--manifest-path', meson.project_source_root() / 'Cargo.toml' ] cargo_opt += [ '--target-dir', meson.project_build_root() / 'src' ] -cargo_env = [ 'CARGO_HOME=' + meson.project_build_root() / 'cargo-home' ] +cargo_env = ['CARGO_HOME=' + meson.project_build_root() / 'cargo-home'] if get_option('buildtype') == 'release' cargo_opt += [ '--release' ] diff --git a/src/pipewire.rs b/src/pipewire.rs deleted file mode 100644 index 5695d80..0000000 --- a/src/pipewire.rs +++ /dev/null @@ -1,57 +0,0 @@ -use std::thread; -use std::thread::JoinHandle; -use gtk::glib::ExitCode; -use pipewire::{context::Context, main_loop::MainLoop}; -use pipewire::types::ObjectType; - -pub fn spawn_pipewire_thread() -> JoinHandle { - 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 = core.get_registry().expect("failed to get registry"); - - let _listener = registry - .add_listener_local() - .global(|global| - { - 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(); - - // 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 - }); - return pw_thread; -} \ No newline at end of file diff --git a/src/pw_manager.rs b/src/pw_manager.rs new file mode 100644 index 0000000..a70b0ea --- /dev/null +++ b/src/pw_manager.rs @@ -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), + DeviceProperties(usize, HashMap), +} + +pub(crate) struct PipewireManager { + handle: JoinHandle, + tx: pipewire::channel::Sender, + rx: flume::Receiver, +} + +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 { + 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 { + 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>>, + profiles: Rc>>, + name: Rc>, + id: usize, + properties: Rc>>, + 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>>, + profiles: &Rc>>, + ) -> 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>, + properties: &Rc>>, + ) -> 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>>, + profiles: Rc>>, + ) { + 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>, + properties: Rc>>, + ) { + 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>, + devices: HashMap, +} + +impl GlobalsManager { + fn new() -> Self { + Self { + globals: Vec::new(), + devices: HashMap::new(), + } + } + + fn add_global(&mut self, global: GlobalObject, 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 { + self.devices.iter() + .map(|(id, d)| (*id, d.name.borrow().clone())) + .collect() + } + + fn get_device_properties(&self, id: usize) -> HashMap { + self.devices.get(&id) + .and_then(|d| Some(d.properties.borrow().clone())) + .unwrap() + } +} + +pub(crate) fn spawn_pipewire_thread() -> (JoinHandle, pipewire::channel::Sender, flume::Receiver) { + + 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(®istry); + 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, ®istry); + + /* + 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 { + param + .and_then(|pod| { + PodDeserializer::deserialize_any_from(pod.as_bytes()).ok() + }) + .and_then(|(_, value)| match value { + Value::Object(obj) => Some(obj), + _ => None, + }) + } +} \ No newline at end of file diff --git a/src/state_manager.rs b/src/state_manager.rs new file mode 100644 index 0000000..9cbf7e7 --- /dev/null +++ b/src/state_manager.rs @@ -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(&mut self, key: &str, value: &Option, namespace: &Option) -> bool { + match value { + Some(v) => { + self.insert(key, v, namespace); + true + } + None => { + self.remove(key, namespace); + false + }, + } + } + fn insert(&mut self, key: &str, value: &T, namespace: &Option); + fn remove(&mut self, key: &str, namespace: &Option); + fn get(&self, key: &str, namespace: &Option) -> Option; +} + +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 { + 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(&mut self, key: &str, value: &T, namespace: &Option) { + 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) { + println!("remove {}", Self::resolve_key(key, namespace)); + self.db.remove(Self::resolve_key(key, namespace)).unwrap(); + } + + fn get(&self, key: &str, namespace: &Option) -> Option { + 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, + available_profiles: Vec, + selected_profile: Option, + } + + impl DeviceConfig { + pub(super) fn new( + name: String, + identifying_properties: HashMap, + available_profiles: Vec, + selected_profile: Option, + backend: &mut Backend, + namespace: &Option, + ) -> Self { + let instance = Self { + name, + identifying_properties, + available_profiles, + selected_profile + }; + + instance.save(backend, namespace).unwrap(); + + instance + } + pub(super) fn load(name: String, backend: &mut Backend, namespace: &Option) -> 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(&self, backend: &mut Backend, namespace: &Option) -> Result<(), String> { + let mut devices: Vec = 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 = 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 { + &self.identifying_properties + } + + pub(crate) fn get_available_profiles(&self) -> &Vec { + &self.available_profiles + } + + pub(crate) fn get_selected_profile(&self) -> &Option { + &self.selected_profile + } + } +} + +pub(crate) mod simple_serde { + use std::fmt::Debug; + + pub(crate) trait SimpleSerde { + fn serialize(&self) -> Vec; + fn deserialize(bytes: Vec) -> Self; + } + + impl SimpleSerde for u64 { + fn serialize(&self) -> Vec { + 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) -> 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 { + self.clone().into_bytes() + } + + fn deserialize(bytes: Vec) -> Self { + String::from_utf8(bytes) + .expect("Couldn't deserialize string") + } + } + + impl SimpleSerde for Vec + where + T: SimpleSerde + Debug + { + fn serialize(&self) -> Vec { + let mut res = Vec::::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) -> Self { + let mut res = Vec::new(); + + let mut i = 0; + + while i < bytes.len() { + let el_len = ::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 for Option { + fn from(value: RootNamespaces) -> Self { + match value { + RootNamespaces::None => None, + RootNamespaces::DeviceConfigs => Some("DeviceConfigs".to_string()), + } + } +} + +pub(crate) struct StateManager { + db: Backend, + device_configs: HashMap +} + +impl StateManager { + 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 = 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 { + self.device_configs.iter() + .map(|(name, _)| name.clone()) + .collect() + } + + pub(crate) fn get_device_config_properties(&mut self, name: &str) -> &HashMap { + self.device_configs.get(name) + .unwrap() + .get_identifying_properties() + } + + pub(crate) fn get_device_config_profiles(&mut self, name: &str) -> &Vec { + self.device_configs.get(name) + .unwrap() + .get_available_profiles() + } + + pub(crate) fn get_device_config_selected_profile(&mut self, name: &str) -> &Option { + self.device_configs.get(name) + .unwrap() + .get_selected_profile() + } + + pub(crate) fn create_new_device_config( + &mut self, + name: String, + identifying_properties: HashMap, + profiles: Vec, + active_profile: Option + ) { + 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 { + state_manager: Rc>> +} + +impl Default for RcStateManager { + fn default() -> Self { + Self { + state_manager: Rc::new(RefCell::new(StateManager::new())) + } + } +} + +impl RcStateManager { + pub(crate) fn borrow_mut(&self) -> RefMut<'_, StateManager> { + self.state_manager.borrow_mut() + } + + pub(crate) fn borrow(&self) -> Ref<'_, StateManager> { + self.state_manager.borrow() + } +} \ No newline at end of file diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..f57ad81 --- /dev/null +++ b/src/utils.rs @@ -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") + }; +} \ No newline at end of file diff --git a/src/window.blp b/src/window.blp index 45740ca..fedc15b 100644 --- a/src/window.blp +++ b/src/window.blp @@ -63,10 +63,6 @@ template $AudioDeviceManagerWindow: Adw.ApplicationWindow { child: Adw.Clamp device_page_clamp { maximum-size: 640; - Box { - orientation: vertical; - spacing: 24; - } }; }; }; diff --git a/src/window.rs b/src/window.rs deleted file mode 100644 index c6ebbd4..0000000 --- a/src/window.rs +++ /dev/null @@ -1,188 +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 adw::glib::{clone, closure_local}; -use adw::prelude::{AlertDialogExt, AlertDialogExtManual, NavigationPageExt}; -use adw::ResponseAppearance; -use gtk::prelude::*; -use adw::subclass::prelude::*; -use gtk::{gio, glib, Button, ListBoxRow}; -use crate::components::new_device; - -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, - #[template_child] - pub device_page_clamp: TemplateChild, - #[template_child] - pub device_navigation_page: TemplateChild, - } - - #[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 { - window.new_device().await; - } - ); - } - - fn instance_init(obj: &glib::subclass::InitializingObject) { - 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) - @extends gtk::Widget, gtk::Window, gtk::ApplicationWindow, adw::ApplicationWindow, - @implements gio::ActionGroup, gio::ActionMap; -} - -impl AudioDeviceManagerWindow { - pub fn new>(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| { - 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()); - } - )); - } - - async fn new_device(&self) { - 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(self).await; - - // Return if the user chose 'cancel_response' - - if response == cancel_response { - println!("Cancel"); - return; - } - - let device = new_device(entry.text().to_string()); - - self.imp().devices_list.append(&device); - } - - 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) -> gtk::Box { - let device_page = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .margin_start(12) - .margin_end(12) - .spacing(12) - .build(); - - let entry = gtk::Entry::builder() - .placeholder_text("Test") - .secondary_icon_name("list-add-symbolic") - .build(); - - device_page.append(&entry); - - device_page - - } -} diff --git a/src/window/mod.rs b/src/window/mod.rs new file mode 100644 index 0000000..ca573cd --- /dev/null +++ b/src/window/mod.rs @@ -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>, +} + +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, + + pub procedural_child_manager: RcProceduralChildManager, + + // Template widgets + #[template_child] + pub devices_list: TemplateChild, + #[template_child] + pub device_page_clamp: TemplateChild, + #[template_child] + pub device_navigation_page: TemplateChild, + #[template_child] + pub split_view: TemplateChild, + } + + #[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) { + 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) + @extends gtk::Widget, gtk::Window, gtk::ApplicationWindow, adw::ApplicationWindow, + @implements gio::ActionGroup, gio::ActionMap; +} + +impl AudioDeviceManagerWindow { + pub fn new>(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 + } + + + +} + + diff --git a/src/window/popups/mod.rs b/src/window/popups/mod.rs new file mode 100644 index 0000000..9b28c01 --- /dev/null +++ b/src/window/popups/mod.rs @@ -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; \ No newline at end of file diff --git a/src/window/popups/new_device.rs b/src/window/popups/new_device.rs new file mode 100644 index 0000000..0f9f317 --- /dev/null +++ b/src/window/popups/new_device.rs @@ -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() +} diff --git a/src/window/popups/new_device_config.rs b/src/window/popups/new_device_config.rs new file mode 100644 index 0000000..820942d --- /dev/null +++ b/src/window/popups/new_device_config.rs @@ -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; + } +} diff --git a/src/window/popups/new_device_config_name.rs b/src/window/popups/new_device_config_name.rs new file mode 100644 index 0000000..2cc784c --- /dev/null +++ b/src/window/popups/new_device_config_name.rs @@ -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, + properties: HashMap, + profiles: Vec, + selected_profile: Option, +) { + 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; + }; + +} \ No newline at end of file diff --git a/src/window/popups/select_device_config.rs b/src/window/popups/select_device_config.rs new file mode 100644 index 0000000..0938f73 --- /dev/null +++ b/src/window/popups/select_device_config.rs @@ -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>, +) -> Vec { + 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 +} diff --git a/src/window/popups/select_pipewire_device.rs b/src/window/popups/select_pipewire_device.rs new file mode 100644 index 0000000..76c608a --- /dev/null +++ b/src/window/popups/select_pipewire_device.rs @@ -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> +) -> Vec { + 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 +} diff --git a/src/window/subpages/device_config.rs b/src/window/subpages/device_config.rs new file mode 100644 index 0000000..2ec1aaa --- /dev/null +++ b/src/window/subpages/device_config.rs @@ -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) +} diff --git a/src/window/subpages/mod.rs b/src/window/subpages/mod.rs new file mode 100644 index 0000000..64a8943 --- /dev/null +++ b/src/window/subpages/mod.rs @@ -0,0 +1 @@ +pub(super) mod device_config; \ No newline at end of file