dev-2025-08-21T14:14:34+02:00
This commit is contained in:
parent
4bc439feaf
commit
7df8124933
@ -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"
|
||||
|
97
UI Drafts.txt
Normal file
97
UI Drafts.txt
Normal 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 |
|
||||
| |
|
||||
¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯
|
@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
54
de.AdaLouBaumann.AudioDeviceManager.json~
Normal file
54
de.AdaLouBaumann.AudioDeviceManager.json~
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
@ -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')
|
||||
|
||||
|
17
run.sh
17
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
|
||||
#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
|
@ -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()
|
||||
}
|
@ -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";
|
||||
|
@ -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;
|
||||
|
@ -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' ]
|
||||
|
@ -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<ExitCode> {
|
||||
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;
|
||||
}
|
410
src/pw_manager.rs
Normal file
410
src/pw_manager.rs
Normal 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(®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<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
377
src/state_manager.rs
Normal 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
10
src/utils.rs
Normal 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")
|
||||
};
|
||||
}
|
@ -63,10 +63,6 @@ template $AudioDeviceManagerWindow: Adw.ApplicationWindow {
|
||||
child: Adw.Clamp device_page_clamp {
|
||||
maximum-size: 640;
|
||||
|
||||
Box {
|
||||
orientation: vertical;
|
||||
spacing: 24;
|
||||
}
|
||||
};
|
||||
};
|
||||
};
|
||||
|
188
src/window.rs
188
src/window.rs
@ -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<gtk::ListBox>,
|
||||
#[template_child]
|
||||
pub device_page_clamp: TemplateChild<adw::Clamp>,
|
||||
#[template_child]
|
||||
pub device_navigation_page: TemplateChild<adw::NavigationPage>,
|
||||
}
|
||||
|
||||
#[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<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| {
|
||||
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
|
||||
|
||||
}
|
||||
}
|
248
src/window/mod.rs
Normal file
248
src/window/mod.rs
Normal 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
12
src/window/popups/mod.rs
Normal 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;
|
74
src/window/popups/new_device.rs
Normal file
74
src/window/popups/new_device.rs
Normal 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()
|
||||
}
|
51
src/window/popups/new_device_config.rs
Normal file
51
src/window/popups/new_device_config.rs
Normal 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;
|
||||
}
|
||||
}
|
93
src/window/popups/new_device_config_name.rs
Normal file
93
src/window/popups/new_device_config_name.rs
Normal 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;
|
||||
};
|
||||
|
||||
}
|
178
src/window/popups/select_device_config.rs
Normal file
178
src/window/popups/select_device_config.rs
Normal 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
|
||||
}
|
166
src/window/popups/select_pipewire_device.rs
Normal file
166
src/window/popups/select_pipewire_device.rs
Normal 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
|
||||
}
|
99
src/window/subpages/device_config.rs
Normal file
99
src/window/subpages/device_config.rs
Normal 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)
|
||||
}
|
1
src/window/subpages/mod.rs
Normal file
1
src/window/subpages/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub(super) mod device_config;
|
Loading…
Reference in New Issue
Block a user