Compare commits

..

No commits in common. "dev" and "main" have entirely different histories.
dev ... main

30 changed files with 96 additions and 2942 deletions

View File

@ -1,16 +0,0 @@
Full control is taken over everything that is configured
and added to Devices.
On initial creation existing pipewire settings might
be copied, but existing configs will not be changed
by external interfaces.
If, for example, a device is configured to use Pro Audio,
and you change the device Profile from pavucontrol,
it will automatically be changed back.
This program is supposed to be opinionated with the goal
of getting audio devices working as intended. It will
not compromise to external software and will force its
configurations onto the system. It will provide
reproducible endpoints that other programs can attach to.

View File

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

View File

@ -1,190 +0,0 @@
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 |
| |
¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯
Device Page
_______________________________________________________
| Devices | Device1 |
| | |
| Device1 | Pipewire Device Configuration |
| | Select audio profiles for specific |
| | pipewire devices + |
| | _______________________________________ |
| | | Device Config 1 | |
| | | Device Config 2 | |
| | | ... | |
| | ¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯ |
| | |
| | Inputs |
| | Selectable inputs that are mapped |
| | to a device + |
| | _______________________________________ |
| | | Input 1 | |
| | | Input 2 | |
| | | ... | |
| | ¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯ |
| | |
| | Outputs |
| | Selectable outputs that are mapped |
| | to a device + |
| | _______________________________________ |
| | | Output 1 | |
| | | Output 2 | |
| | | ... | |
| | ¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯ |
| | |
¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯
Inputs General or Device Bound?
Device Bound:
- It's the input of the device,
unlike the device config which is
something hardware bound
- easier workflow
- Could use Simple names instead of
sifting through a bunch of inputs
from other devices
- Since each device might have multiple
inputs, the selection screen would
get confusing quickly
Input Page
Loopback reference: https://docs.pipewire.org/page_module_loopback.html
maybe use commandline tool for config: https://docs.pipewire.org/page_man_pw-loopback_1.html
possible profiles:
- Mono
[ MONO ]
- Stereo
[ FL FR ]
- Downmix
[ FL FR ]
- etc.?
- Custom?
Example (design changes based on selected profile):
Stereo, mapped from pro audio
_______________________________________________________
| Devices | < Input 1 |Edit Symbol| |
| | __________________________ |
| Device1 | Profile | Stereo ⌄ | |
| | ¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯ |
| | |
| | Input Map |
| | Map inputs from a device config |
| | to inputs in this profile |
| | ________________________ |
| | Front Left | DeviceConfig1-Aux0 ⌄ | |
| | ¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯ |
| | ________________________ |
| | Front Right | DeviceConfig1-Aux1 ⌄ | |
| | ¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯ |
| | |
¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯¯
Output Page
possible profiles:
- Mono
[
- Stereo
- Upmix (with param configuration)
- etc.?
- Custom?

View File

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

View File

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

View File

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

17
run.sh
View File

@ -3,15 +3,8 @@
SRC_DIR="$(dirname "$0")" SRC_DIR="$(dirname "$0")"
cd "$SRC_DIR" || exit cd "$SRC_DIR" || exit
#export DESTDIR=~/.local git add .
#export PKGDATADIR=~/.local/share git commit -m "dev-$(date -Iseconds)"
git push
meson build || exit 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
sudo meson install -C build || exit flatpak run de.AdaLouBaumann.AudioDeviceManager
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

View File

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

View File

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

View File

@ -18,16 +18,10 @@
* *
* SPDX-License-Identifier: GPL-2.0-or-later * SPDX-License-Identifier: GPL-2.0-or-later
*/ */
extern crate core;
mod application; mod application;
mod config; mod config;
mod window; mod window;
mod components;
mod pipewire_manager;
mod utils;
mod state_manager;
mod spa_structs;
use self::application::AudioDeviceManagerApplication; use self::application::AudioDeviceManagerApplication;
use self::window::AudioDeviceManagerWindow; use self::window::AudioDeviceManagerWindow;

View File

@ -1,751 +0,0 @@
use std::cell::{Cell, RefCell};
use std::cmp::Ordering;
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 adw::glib;
use adw::glib::{clone, GString, ParamFlags, Value};
use adw::prelude::*;
use gtk::prelude::*;
use gtk::glib::ExitCode;
use libspa::pod::Pod;
use pipewire::{context::Context, main_loop::MainLoop};
use pipewire::properties::{properties, Properties};
use pipewire::registry::{GlobalObject, Registry};
use pipewire::spa::param::ParamType;
use crate::pipewire_manager::deserialize_pod::deserialize_pod;
type DeviceId = usize;
enum DeviceMessage {
ProfileDiscovered(DeviceProfile),
ActiveProfileSet(ProfileId),
PropertyDiscovered(String, String),
}
enum Data {
DeviceMessage(DeviceId, DeviceMessage)
}
#[derive(Debug)]
enum DeviceCommand {
SetProfile(ProfileId)
}
#[derive(Debug)]
enum Command {
DeviceCommand(DeviceId, DeviceCommand)
}
struct RegisteredObjects {
profile_combo_rows: HashMap<uuid::Uuid, (usize, adw::ComboRow, adw::gio::ListStore)>
}
impl RegisteredObjects {
fn new() -> Self {
Self {
profile_combo_rows: HashMap::new()
}
}
}
struct Device {
properties: HashMap<String, String>,
profiles: HashMap<usize, DeviceProfile>,
active_profile: Option<usize>,
}
impl Default for Device {
fn default() -> Self {
Self {
properties: HashMap::new(),
profiles: HashMap::new(),
active_profile: None,
}
}
}
pub(crate) struct PipewireManager {
handle: JoinHandle<ExitCode>,
devices: Rc<RefCell<HashMap<usize, Device>>>,
registered_objects: Rc<RefCell<RegisteredObjects>>,
tx: pipewire::channel::Sender<Command>,
rx: flume::Receiver<Data>
}
impl Default for PipewireManager {
fn default() -> Self {
let (handle, tx, rx) = spawn_pipewire_thread();
let devices = Rc::new(RefCell::new(HashMap::new()));
let registered_objects = Rc::new(RefCell::new(RegisteredObjects::new()));
let rx_clone = rx.clone();
glib::spawn_future_local(clone!(
#[weak]
devices,
#[weak]
registered_objects,
async move {
handle_registered_objects(rx_clone, devices, registered_objects).await
}
));
let instance = Self {
handle,
devices,
registered_objects,
tx,
rx
};
instance
}
}
async fn handle_registered_objects(
rx: flume::Receiver<Data>,
devices: Rc<RefCell<HashMap<usize, Device>>>,
registered_objects: Rc<RefCell<RegisteredObjects>>
) {
let mut active_profile_set = None;
while let Ok(data) = rx.recv_async().await {
match data {
Data::DeviceMessage(device_id, message) => {
let mut devices_mut = devices.borrow_mut();
let device = devices_mut
.entry(device_id)
.or_insert(Device::default());
match message {
DeviceMessage::ProfileDiscovered(profile) => {
let profile_name = profile.description.clone();
device.profiles.insert(profile.id, profile);
handle_profile_discovered(device_id, registered_objects.clone(), profile_name);
}
DeviceMessage::ActiveProfileSet(id) => {
println!("Active profile set: {}", id);
active_profile_set = Some(id);
}
DeviceMessage::PropertyDiscovered(k, v) => {
device.properties.insert(k, v);
}
}
if let Some(id) = active_profile_set {
if let Some(profile) = device.profiles.get(&id) {
device.active_profile = Some(id);
let profile_name = profile.description.clone();
handle_active_profile_set(device_id, registered_objects.clone(), profile_name);
active_profile_set = None;
}
}
}
}
}
}
fn handle_profile_discovered(device_id: usize, registered_objects: Rc<RefCell<RegisteredObjects>>, profile_name: String) {
for (
_id,
(
combo_row_device_id,
_combo_row,
model
)
) in &mut registered_objects.borrow_mut().profile_combo_rows {
if *combo_row_device_id != device_id {
continue;
}
let mut i = 0;
while let Some(p) = model.item(i) {
let label = p.downcast::<gtk::Label>().unwrap().label().to_string();
match label.cmp(&profile_name) {
Ordering::Less => {}
Ordering::Equal => return,
Ordering::Greater => {break;}
}
i += 1
}
let profile_label = gtk::Label::new(Some(&profile_name));
model.splice(i, 0, &[profile_label]);
}
}
fn handle_active_profile_set(device_id: usize, registered_objects: Rc<RefCell<RegisteredObjects>>, profile_name: String) {
for (
_id,
(
combo_row_device_id,
combo_row,
model
)
) in &mut registered_objects.borrow_mut().profile_combo_rows {
if *combo_row_device_id != device_id {
continue;
}
let mut i = 0;
while let Some(p) = model.item(i) {
let label = p.downcast::<gtk::Label>().unwrap().label().to_string();
if label == profile_name {
break;
}
i += 1;
}
combo_row.set_selected(i);
}
}
impl PipewireManager {
pub(crate) fn get_device_names(&self) -> HashMap<usize, String> {
self.devices.borrow()
.iter()
.map(|(id, d)| {
(
*id,
d.properties
.get("device.description")
.unwrap_or(&"Error loading device name".to_owned())
.to_owned()
)
})
.collect()
}
pub(crate) fn get_device_properties(&self, id: usize) -> HashMap<String, String> {
self.devices.borrow()[&id]
.properties
.clone()
}
pub(crate) fn get_device_ids_from_properties(&self, properties: HashMap<String, String>) -> Vec<usize> {
self.devices.borrow()
.iter()
.filter(
|(id, d)| {
properties
.iter()
.all(|(k, v)| {
let Some(v2) = d.properties.get(k) else { return false; };
v == v2
})
}
)
.map(|(id, d)| *id)
.collect()
}
pub(crate) fn register_profile_combo_row(&self, device_id: usize, combo_row: adw::ComboRow) -> uuid::Uuid {
let mut profile_names: Vec<_> = self.devices.borrow()[&device_id].profiles
.iter()
.map(|(id, p)| {
p.description.clone()
})
.collect();
profile_names.sort();
let active_profile = self.devices.borrow()[&device_id].active_profile
.clone() // make sure no lockups happen
.and_then(|id| {
Some((id, self.devices.borrow()[&device_id].profiles[&id].description.clone()))
});
let factory = gtk::SignalListItemFactory::new();
factory.connect_setup(move |factory, item| {
let label = gtk::Label::new(None);
item.downcast_ref::<gtk::ListItem>()
.unwrap()
.set_child(Some(&label));
});
factory.connect_bind(move |factory, item| {
let list_item = item.downcast_ref::<gtk::ListItem>().unwrap();
let label = list_item.child().unwrap().downcast::<gtk::Label>().unwrap();
// using list_item.set_child( Some(&>label<) )
// instead of label.set_label( >label<.label() )
// results in weird broken behavior
label.set_label(&list_item.item().unwrap().downcast::<gtk::Label>().unwrap().label())
});
combo_row.set_factory(Some(&factory));
let model = adw::gio::ListStore::new::<gtk::Label>();
//let model = gtk::StringList::new(&[]);
for name in &profile_names {
let label = gtk::Label::new(Some(name));
label.set_tooltip_text(Some(name));
model.append(&label);
}
combo_row.set_model(Some(&model));
combo_row.connect_selected_notify(clone!(
#[weak]
model,
#[weak(rename_to=devices)]
self.devices,
#[strong(rename_to=tx)]
self.tx,
move |c| {
let model_entry = c.selected();
let selected_profile_name = model
.item(model_entry)
.unwrap()
.downcast::<gtk::Label>()
.unwrap()
.label();
let mut selected_profile_id = None;
for (profile_id, profile) in &devices.borrow()[&device_id].profiles {
if selected_profile_name == profile.description {
selected_profile_id = Some(*profile_id);
};
};
let selected_profile_id = selected_profile_id.unwrap();
tx.send(
Command::DeviceCommand(device_id, DeviceCommand::SetProfile(selected_profile_id))
).unwrap();
println!("{}", &selected_profile_name);
}
));
/*
// works, TODO: figure out preferred style
combo_row.set_use_subtitle(true);
combo_row.connect_subtitle_notify(clone!(
#[weak]
model,
move |c| {
let model_entry = c.selected();
let selected_profile_name = model
.item(model_entry)
.unwrap()
.downcast::<gtk::Label>()
.unwrap()
.label();
c.set_subtitle(&selected_profile_name);
}
));
*/
if let Some((_, name)) = active_profile {
if let Ok(position) = profile_names.binary_search(&&name) {
combo_row.set_selected(position as u32)
}
}
let combo_row_id = uuid::Uuid::new_v4();
self.registered_objects.borrow_mut().profile_combo_rows.insert(combo_row_id, (device_id, combo_row, model));
combo_row_id
}
}
enum DeviceAvailability {
Available,
Unavailable,
Unknown
}
struct DeviceProfile {
id: usize,
name: String,
description: String,
priority: usize,
available: DeviceAvailability,
}
type ProfileId = usize;
type PodBytes = Vec<u8>;
// 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 DeviceMessageHandler {
pipewire_device: Rc<RefCell<pipewire::device::Device>>,
device_listener: pipewire::device::DeviceListener,
profiles: Rc<RefCell<HashMap<ProfileId, PodBytes>>>,
}
impl DeviceMessageHandler {
fn new(pipewire_device: pipewire::device::Device, id: usize, tx: flume::Sender<Data>) -> Self {
let pipewire_device_rc = Rc::new(RefCell::new(pipewire_device));
let profiles = Rc::new(RefCell::new(HashMap::new()));
let pipewire_device_ref = pipewire_device_rc.borrow();
let mut listener_builder = pipewire_device_ref.add_listener_local();
listener_builder = Self::listen_for_params(id, listener_builder, tx.clone(), profiles.clone());
listener_builder = Self::listen_for_info(id, listener_builder, tx, pipewire_device_rc.clone());
let device_listener = listener_builder.register();
pipewire_device_ref.subscribe_params(&[
ParamType::Profile,
ParamType::EnumProfile,
]);
drop(pipewire_device_ref);
Self {
pipewire_device: pipewire_device_rc,
device_listener,
profiles
}
}
fn listen_for_params(
device_id: usize,
listener_builder: pipewire::device::DeviceListenerLocalBuilder,
tx: flume::Sender<Data>,
profiles: Rc<RefCell<HashMap<ProfileId, PodBytes>>>
) -> pipewire::device::DeviceListenerLocalBuilder {
listener_builder
.param(clone!(
#[weak]
profiles,
move |seq, param_type, index, next, param_pod| {
let Some(pod) = param_pod else { return };
let Some(param) = deserialize_pod(param_pod) else { return };
Self::on_profile_discovered(
device_id,
param,
param_type,
tx.clone(),
profiles,
pod
)
}
))
}
fn listen_for_info(
device_id: usize,
listener_builder: pipewire::device::DeviceListenerLocalBuilder,
tx: flume::Sender<Data>,
pipewire_device: Rc<RefCell<pipewire::device::Device>>
) -> pipewire::device::DeviceListenerLocalBuilder {
listener_builder
.info(clone!(
#[weak]
pipewire_device,
move |info| {
Self::on_info_discovered(
device_id,
info,
tx.clone(),
pipewire_device
)
}
))
}
fn on_profile_discovered(
device_id: usize,
param: libspa::pod::Object,
param_type: ParamType,
tx: flume::Sender<Data>,
profiles: Rc<RefCell<HashMap<ProfileId, PodBytes>>>,
pod: &Pod
) {
let mut profile_id = None;
let mut profile_name = None;
let mut profile_description = None;
let mut profile_priority = None;
let mut profile_availability = 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_availability = Some(DeviceAvailability::Unknown); },
(5, libspa::pod::Value::Id(libspa::utils::Id(1))) => { profile_availability = Some(DeviceAvailability::Unavailable); },
(5, libspa::pod::Value::Id(libspa::utils::Id(2))) => { profile_availability = Some(DeviceAvailability::Available); },
_ => {}
}
}
// TODO: Proper error handling
match param_type {
ParamType::Profile => {
let id = profile_id.unwrap();
tx.send(
Data::DeviceMessage(
device_id,
DeviceMessage::ActiveProfileSet(id)
)
)
.unwrap();
},
ParamType::EnumProfile => {
let id = profile_id.unwrap();
let pod_bytes = pod.as_bytes().to_vec();
profiles.borrow_mut().insert(id, pod_bytes);
let device_profile = DeviceProfile {
id,
name: profile_name.unwrap(),
description: profile_description.unwrap(),
priority: profile_priority.unwrap(),
available: profile_availability.unwrap(),
};
tx.send(
Data::DeviceMessage(
device_id,
DeviceMessage::ProfileDiscovered(device_profile)
)
)
.unwrap();
},
_ => return
}
}
fn on_info_discovered(
device_id: usize,
info: &pipewire::device::DeviceInfoRef,
tx: flume::Sender<Data>,
pipewire_device: Rc<RefCell<pipewire::device::Device>>
) {
// a bit jank, but works for now
// every time an info update is given also give a params update
// because subscribed params don't work somehow
pipewire_device.borrow().enum_params(1, Some(ParamType::Profile), 0, 1);
info.props().unwrap().iter()
.for_each(|(k, v)| {
tx.send(Data::DeviceMessage(
device_id,
DeviceMessage::PropertyDiscovered(k.to_owned(), v.to_owned())
)).unwrap();
});
}
fn set_profile(&self, profile_id: ProfileId) {
let profile_pod_bytes = match self.profiles.borrow().get(&profile_id) {
Some(pod_bytes) => pod_bytes.clone(),
None => {
println!("No profile with id {} found", profile_id);
return
}
};
let profile_pod = Pod::from_bytes(&profile_pod_bytes).unwrap();
self.pipewire_device.borrow().set_param(ParamType::Profile, ParamFlags::empty().bits(), profile_pod)
}
}
struct GlobalsManager {
globals: Vec<GlobalObject<Properties>>,
devices: HashMap<usize, DeviceMessageHandler>,
tx: flume::Sender<Data>,
}
impl GlobalsManager {
fn new(tx: flume::Sender<Data>) -> Self {
Self {
globals: Vec::new(),
devices: HashMap::new(),
tx
}
}
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 = DeviceMessageHandler::new(registry.bind(global_ref).unwrap(), device_id, self.tx.clone());
self.devices.insert(device_id, device);
println!("ADDED DEVICE");
}
fn get_device(&self, device_id: usize) -> Option<&DeviceMessageHandler> {
self.devices.get(&device_id)
}
}
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(move || {
// 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(tx.clone())));
let _listener = registry
.add_listener_local()
.global(clone!(
#[strong]
registry,
#[strong]
globals_manager,
move |global|
{
let owned_global = global.to_owned();
globals_manager.borrow_mut().add_global(owned_global, &registry);
/*
if global.type_ == ObjectType::Port {
let props = global.props.as_ref().unwrap();
let port_name = props.get("port.name");
let port_alias = props.get("port.alias");
let object_path = props.get("object.path");
let format_dsp = props.get("format.dsp");
let audio_channel = props.get("audio.channel");
let port_id = props.get("port.id");
let port_direction = props.get("port.direction");
println!("Port: Name: {:?} Alias: {:?} Id: {:?} Direction: {:?} AudioChannel: {:?} Object Path: {:?} FormatDsp: {:?}",
port_name,
port_alias,
port_id,port_direction,audio_channel,object_path,format_dsp
);
} else if global.type_ == ObjectType::Device {
let props = global.props.as_ref().unwrap();
let device_name = props.get("device.name");
let device_nick = props.get("device.nick");
let device_description = props.get("device.description");
let device_api = props.get("device.api");
let media_class = props.get("media.class");
println!("Device: Name: {:?} Nick: {:?} Desc: {:?} Api: {:?} MediaClass: {:?}",
device_name, device_nick, device_description, device_api, media_class);
}
*/
}
)
)
.register();
let _receiver = rx.attach(mainloop.loop_(), {
let mainloop = mainloop.clone();
{
let globals_manager_weak = Rc::downgrade(&globals_manager);
move |command| {
let Some(globals_manager) = globals_manager_weak.upgrade() else {
return;
};
match command {
Command::DeviceCommand(device_id, device_command) => {
let globals_manager_ref = globals_manager.borrow();
let Some(device) = globals_manager_ref.get_device(device_id) else {
println!("No device with id {} found", device_id);
return
};
match device_command {
DeviceCommand::SetProfile(profile_id) => {
device.set_profile(profile_id);
}
}
}
}
}
}
});
// 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_pod {
/// 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_pod(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,
})
}
}

View File

@ -1,151 +0,0 @@
use libspa_sys::{SPA_AUDIO_CHANNEL_START_Aux, SPA_AUDIO_CHANNEL_START_Custom, SPA_AUDIO_CHANNEL_BC, SPA_AUDIO_CHANNEL_BLC, SPA_AUDIO_CHANNEL_BRC, SPA_AUDIO_CHANNEL_FC, SPA_AUDIO_CHANNEL_FCH, SPA_AUDIO_CHANNEL_FL, SPA_AUDIO_CHANNEL_FLC, SPA_AUDIO_CHANNEL_FLH, SPA_AUDIO_CHANNEL_FLW, SPA_AUDIO_CHANNEL_FR, SPA_AUDIO_CHANNEL_FRC, SPA_AUDIO_CHANNEL_FRH, SPA_AUDIO_CHANNEL_FRW, SPA_AUDIO_CHANNEL_LFE, SPA_AUDIO_CHANNEL_LFE2, SPA_AUDIO_CHANNEL_LLFE, SPA_AUDIO_CHANNEL_MONO, SPA_AUDIO_CHANNEL_NA, SPA_AUDIO_CHANNEL_RC, SPA_AUDIO_CHANNEL_RL, SPA_AUDIO_CHANNEL_RLC, SPA_AUDIO_CHANNEL_RLFE, SPA_AUDIO_CHANNEL_RR, SPA_AUDIO_CHANNEL_RRC, SPA_AUDIO_CHANNEL_SL, SPA_AUDIO_CHANNEL_SR, SPA_AUDIO_CHANNEL_TC, SPA_AUDIO_CHANNEL_TFC, SPA_AUDIO_CHANNEL_TFL, SPA_AUDIO_CHANNEL_TFLC, SPA_AUDIO_CHANNEL_TFR, SPA_AUDIO_CHANNEL_TFRC, SPA_AUDIO_CHANNEL_TRC, SPA_AUDIO_CHANNEL_TRL, SPA_AUDIO_CHANNEL_TRR, SPA_AUDIO_CHANNEL_TSL, SPA_AUDIO_CHANNEL_TSR, SPA_AUDIO_CHANNEL_UNKNOWN};
// Modified from https://gitlab.freedesktop.org/pipewire/pipewire/-/blob/master/spa/include/spa/param/audio/raw.h
pub(crate) enum Channel {
UNKNOWN, /**< unspecified */
NA, /**< N/A, silent */
MONO, /**< mono stream */
FL, /**< front left */
FR, /**< front right */
FC, /**< front center */
LFE, /**< LFE */
SL, /**< side left */
SR, /**< side right */
FLC, /**< front left center */
FRC, /**< front right center */
RC, /**< rear center */
RL, /**< rear left */
RR, /**< rear right */
TC, /**< top center */
TFL, /**< top front left */
TFC, /**< top front center */
TFR, /**< top front right */
TRL, /**< top rear left */
TRC, /**< top rear center */
TRR, /**< top rear right */
RLC, /**< rear left center */
RRC, /**< rear right center */
FLW, /**< front left wide */
FRW, /**< front right wide */
LFE2, /**< LFE 2 */
FLH, /**< front left high */
FCH, /**< front center high */
FRH, /**< front right high */
TFLC, /**< top front left center */
TFRC, /**< top front right center */
TSL, /**< top side left */
TSR, /**< top side right */
LLFE, /**< left LFE */
RLFE, /**< right LFE */
BC, /**< bottom center */
BLC, /**< bottom left center */
BRC, /**< bottom right center */
AUX(u32), // 0 - 4095
CUSTOM(u32),
}
impl From<Channel> for libspa_sys::spa_audio_channel {
fn from(value: Channel) -> Self {
match value {
Channel::UNKNOWN => SPA_AUDIO_CHANNEL_UNKNOWN,
Channel::NA => SPA_AUDIO_CHANNEL_NA,
Channel::MONO => SPA_AUDIO_CHANNEL_MONO,
Channel::FL => SPA_AUDIO_CHANNEL_FL,
Channel::FR => SPA_AUDIO_CHANNEL_FR,
Channel::FC => SPA_AUDIO_CHANNEL_FC,
Channel::LFE => SPA_AUDIO_CHANNEL_LFE,
Channel::SL => SPA_AUDIO_CHANNEL_SL,
Channel::SR => SPA_AUDIO_CHANNEL_SR,
Channel::FLC => SPA_AUDIO_CHANNEL_FLC,
Channel::FRC => SPA_AUDIO_CHANNEL_FRC,
Channel::RC => SPA_AUDIO_CHANNEL_RC,
Channel::RL => SPA_AUDIO_CHANNEL_RL,
Channel::RR => SPA_AUDIO_CHANNEL_RR,
Channel::TC => SPA_AUDIO_CHANNEL_TC,
Channel::TFL => SPA_AUDIO_CHANNEL_TFL,
Channel::TFC => SPA_AUDIO_CHANNEL_TFC,
Channel::TFR => SPA_AUDIO_CHANNEL_TFR,
Channel::TRL => SPA_AUDIO_CHANNEL_TRL,
Channel::TRC => SPA_AUDIO_CHANNEL_TRC,
Channel::TRR => SPA_AUDIO_CHANNEL_TRR,
Channel::RLC => SPA_AUDIO_CHANNEL_RLC,
Channel::RRC => SPA_AUDIO_CHANNEL_RRC,
Channel::FLW => SPA_AUDIO_CHANNEL_FLW,
Channel::FRW => SPA_AUDIO_CHANNEL_FRW,
Channel::LFE2 => SPA_AUDIO_CHANNEL_LFE2,
Channel::FLH => SPA_AUDIO_CHANNEL_FLH,
Channel::FCH => SPA_AUDIO_CHANNEL_FCH,
Channel::FRH => SPA_AUDIO_CHANNEL_FRH,
Channel::TFLC => SPA_AUDIO_CHANNEL_TFLC,
Channel::TFRC => SPA_AUDIO_CHANNEL_TFRC,
Channel::TSL => SPA_AUDIO_CHANNEL_TSL,
Channel::TSR => SPA_AUDIO_CHANNEL_TSR,
Channel::LLFE => SPA_AUDIO_CHANNEL_LLFE,
Channel::RLFE => SPA_AUDIO_CHANNEL_RLFE,
Channel::BC => SPA_AUDIO_CHANNEL_BC,
Channel::BLC => SPA_AUDIO_CHANNEL_BLC,
Channel::BRC => SPA_AUDIO_CHANNEL_BRC,
Channel::AUX(aux) => SPA_AUDIO_CHANNEL_START_Aux + aux,
Channel::CUSTOM(custom) => SPA_AUDIO_CHANNEL_START_Custom + custom,
}
}
}
// https://gitlab.freedesktop.org/pipewire/pipewire/-/blob/master/spa/include/spa/param/audio/raw-types.h
impl From<Channel> for String {
fn from(value: Channel) -> String {
match value {
Channel::UNKNOWN => "UNK".to_owned(),
Channel::NA => "NA".to_owned(),
Channel::MONO => "MONO".to_owned(),
Channel::FL => "FL".to_owned(),
Channel::FR => "FR".to_owned(),
Channel::FC => "FC".to_owned(),
Channel::LFE => "LFE".to_owned(),
Channel::SL => "SL".to_owned(),
Channel::SR => "SR".to_owned(),
Channel::FLC => "FLC".to_owned(),
Channel::FRC => "FRC".to_owned(),
Channel::RC => "RC".to_owned(),
Channel::RL => "RL".to_owned(),
Channel::RR => "RR".to_owned(),
Channel::TC => "TC".to_owned(),
Channel::TFL => "TFL".to_owned(),
Channel::TFC => "TFC".to_owned(),
Channel::TFR => "TFR".to_owned(),
Channel::TRL => "TRL".to_owned(),
Channel::TRC => "TRC".to_owned(),
Channel::TRR => "TRR".to_owned(),
Channel::RLC => "RLC".to_owned(),
Channel::RRC => "RRC".to_owned(),
Channel::FLW => "FLW".to_owned(),
Channel::FRW => "FRW".to_owned(),
Channel::LFE2 => "LFE2".to_owned(),
Channel::FLH => "FLH".to_owned(),
Channel::FCH => "FCH".to_owned(),
Channel::FRH => "FRH".to_owned(),
Channel::TFLC => "TFLC".to_owned(),
Channel::TFRC => "TFRC".to_owned(),
Channel::TSL => "TSL".to_owned(),
Channel::TSR => "TSR".to_owned(),
Channel::LLFE => "LLFE".to_owned(),
Channel::RLFE => "RLFE".to_owned(),
Channel::BC => "BC".to_owned(),
Channel::BLC => "BLC".to_owned(),
Channel::BRC => "BRC".to_owned(),
Channel::AUX(aux) => {
// > 63 is not official spec
if aux > 4095 {
panic!("AUX Channel has to be in range 0 - 4095");
}
format!("AUX{}", aux)
},
Channel::CUSTOM(custom) => {
// not official spec
format!("CUSTOM{}", custom)
},
}
}
}

View File

@ -1,585 +0,0 @@
use adw::prelude::*;
use std::cell::{Ref, RefCell, RefMut};
use std::collections::HashMap;
use std::path::PathBuf;
use std::rc::Rc;
use adw::gdk::Device;
use uuid::Uuid;
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, HashSet};
use uuid::{Timestamp, Uuid};
use crate::spa_structs::Channel;
use crate::state_manager::StateBackend;
pub(crate) struct DeviceConfig {
id: Uuid,
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 id = Uuid::now_v7();
let instance = Self {
id,
name,
identifying_properties,
available_profiles,
selected_profile
};
instance.save(backend, namespace).unwrap();
instance
}
fn get_namespaces(id: Uuid, namespace: &Option<String>) -> (Option<String>, Option<String>) {
let device_namespace_string = match namespace {
Some(ns) => ns.clone() + "/" + &id.to_string(),
None => id.to_string()
};
let properties_namespace = Some(device_namespace_string.clone() + "/" + "identifying_properties");
let device_namespace = Some(device_namespace_string);
(
device_namespace,
properties_namespace,
)
}
pub(super) fn load<Backend: StateBackend>(id: Uuid, backend: &mut Backend, namespace: &Option<String>) -> Self {
let (device_namespace, properties_namespace) = Self::get_namespaces(id, namespace);
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 {
id,
name: backend.get("name", &device_namespace).expect("name not found"),
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<Uuid> = backend.get("devices", &namespace)
.unwrap_or(Vec::new());
if devices.contains(&self.id) {
Self::remove_by_id(self.id, backend, namespace);
} else {
devices.push(self.id);
}
backend.insert("devices", &devices, &namespace);
let (device_namespace, properties_namespace) = Self::get_namespaces(self.id, namespace);
backend.insert("name", &self.name, &device_namespace);
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(super) fn remove_by_id<Backend: StateBackend>(id: Uuid, backend: &mut Backend, namespace: &Option<String>) {
let mut devices: Vec<Uuid> = backend.get("devices", &namespace)
.unwrap_or(Vec::new());
devices = devices.into_iter()
.filter(|device_id| *device_id != id)
.collect();
backend.insert("devices", &devices, &namespace);
let (device_namespace, properties_namespace) = Self::get_namespaces(id, namespace);
backend.remove("name", &device_namespace);
backend.remove("available_profiles", &device_namespace);
backend.remove("selected_profile", &device_namespace);
let properties: Vec<String> = backend.get("properties", &device_namespace).unwrap_or(Vec::new());
backend.remove("properties", &device_namespace);
for name in properties {
backend.remove(&name, &properties_namespace);
}
}
pub(super) fn remove<Backend: StateBackend>(&self, backend: &mut Backend, namespace: &Option<String>) {
Self::remove_by_id(self.id, backend, namespace);
}
pub(super) fn change_name<Backend: StateBackend>(&mut self, new_name: String, backend: &mut Backend, namespace: &Option<String>) {
let (device_namespace, _) = Self::get_namespaces(self.id, namespace);
backend.insert("name", &new_name, &device_namespace);
self.name = new_name;
}
pub(crate) fn get_name(&self) -> &str {
&self.name
}
pub(crate) fn get_id(&self) -> Uuid { self.id }
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) fn get_available_channels(&self) -> HashSet<Channel> {
let channels = HashSet::new();
channels
}
}
pub(crate) struct InputProfile {
id: Uuid,
}
pub(crate) struct OutputProfile {
id: Uuid,
}
enum LoopbackProfile {
Input(Uuid),
Output(Uuid),
}
enum LoopbackSource {
DeviceConfig(Uuid),
Loopback(Uuid)
}
pub(crate) struct Loopback {
id: Uuid,
name: String,
profile: LoopbackProfile,
virtual_only: bool,
// has to be checked for validity everytime it's loaded
// If Source changes profile, available channels also change
// Perhaps backreference, so that warning can be given?
mappings: HashMap<Channel, (LoopbackSource, String)>
}
pub(crate) struct Output {
name: String,
output_profile: Uuid,
}
pub(crate) struct Device {
id: Uuid,
name: String,
device_configs: Vec<Uuid>,
inputs: Vec<Loopback>,
outputs: Vec<Loopback>,
}
impl Device {
}
}
pub(crate) mod simple_serde {
use std::fmt::Debug;
use uuid::Uuid;
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
}
}
impl SimpleSerde for Uuid {
fn serialize(&self) -> Vec<u8> {
self.as_bytes().to_vec()
}
fn deserialize(bytes: Vec<u8>) -> Self {
let uuid_bytes = uuid::Bytes::try_from(bytes).unwrap();
Uuid::from_bytes(uuid_bytes)
}
}
}
enum RootNamespaces {
None,
Devices,
DeviceConfigs,
}
impl From<RootNamespaces> for Option<String> {
fn from(value: RootNamespaces) -> Self {
match value {
RootNamespaces::None => None,
RootNamespaces::Devices => Some("Devices".to_owned()),
RootNamespaces::DeviceConfigs => Some("DeviceConfigs".to_owned()),
}
}
}
pub(crate) struct StateManager<Backend: StateBackend> {
db: Backend,
selected_device: String,
device_configs: HashMap<Uuid, DeviceConfig>,
device_config_action_rows: HashMap<String, HashMap<Uuid, Vec<adw::ActionRow>>>
}
impl<Backend: StateBackend> StateManager<Backend> {
pub(crate) fn new() -> Self {
let mut instance = Self {
db: Backend::new(crate::utils::CONFIG_DIR.clone().join("db")),
selected_device: String::new(),
device_configs: HashMap::new(),
device_config_action_rows: HashMap::new()
};
instance.load();
instance
}
pub(crate) fn load(&mut self) {
self.selected_device = self.db.get("selected_device", &RootNamespaces::Devices.into())
.unwrap_or("".to_owned());
let device_config_ids: Vec<Uuid> = self.db.get("devices", &RootNamespaces::DeviceConfigs.into())
.unwrap_or(Vec::new());
println!("device_config_ids: {:?}", device_config_ids);
for id in device_config_ids {
let device_config = DeviceConfig::load(id, &mut self.db, &RootNamespaces::DeviceConfigs.into());
self.device_configs.insert(id, device_config);
}
}
pub(crate) fn get_device_config_ids(&self) -> Vec<Uuid> {
self.device_configs.iter()
.map(|(id, _)| *id)
.collect()
}
pub(crate) fn get_device_config_name(&self, device_config_id: &Uuid) -> String {
self.device_configs.get(device_config_id).unwrap()
.get_name()
.to_owned()
}
pub(crate) fn get_device_config_names(&self) -> HashMap<Uuid, String> {
self.device_configs.iter()
.map(|(id, config)| (*id, config.get_name().to_owned()))
.collect()
}
pub(crate) fn get_device_config_properties(&self, device_config_id: &Uuid) -> &HashMap<String, String> {
self.device_configs.get(device_config_id)
.unwrap()
.get_identifying_properties()
}
pub(crate) fn get_device_config_profiles(&self, device_config_id: &Uuid) -> &Vec<String> {
self.device_configs.get(device_config_id)
.unwrap()
.get_available_profiles()
}
pub(crate) fn get_device_config_selected_profile(&self, device_config_id: &Uuid) -> &Option<String> {
self.device_configs.get(device_config_id)
.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>
) -> Uuid {
let config = DeviceConfig::new(
name.clone(),
identifying_properties,
profiles,
active_profile,
&mut self.db,
&RootNamespaces::DeviceConfigs.into()
);
let config_id = config.get_id();
self.device_configs.insert(config_id, config);
config_id
}
// Register action_rows, so that if the device config name is changed
// the action row text is changed too
pub(crate) fn register_device_config_action_row(
&mut self,
device_name: String,
device_config_id: Uuid,
row: adw::ActionRow
) {
row.set_title(
&self.get_device_config_name(&device_config_id)
);
self.device_config_action_rows
.entry(device_name).or_insert(HashMap::new())
.entry(device_config_id).or_insert(vec![])
.push(row);
}
pub(crate) fn change_device_config_name(
&mut self,
device_config_id: &Uuid,
new_name: String,
) {
let config = self.device_configs.get_mut(device_config_id).unwrap();
config
.change_name(
new_name.clone(),
&mut self.db,
&RootNamespaces::DeviceConfigs.into()
);
for (_, rows_map) in self.device_config_action_rows.iter_mut() {
rows_map.get(device_config_id)
.map(|rows| {
for row in rows {
row.set_title(&new_name);
};
rows
});
}
}
// Unregister device, so that after use all widgets registered with it
// can be freed up again
pub(crate) fn unregister_device(
&mut self,
device_name: &str,
) {
self.device_config_action_rows.remove(device_name);
}
pub(crate) fn set_current_device(
&mut self,
device_name: String,
) {
self.db.insert("selected_device", &device_name, &RootNamespaces::Devices.into());
self.selected_device = device_name;
}
pub(crate) fn get_current_device(&self) -> &String {
&self.selected_device
}
}
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()
}
}

View File

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

View File

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

70
src/window.rs Normal file
View File

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

View File

@ -1,5 +0,0 @@
use crate::window::AudioDeviceManagerWindow;
async fn message_handler(main_window: &AudioDeviceManagerWindow) {
}

View File

@ -1,256 +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
*/
mod popups;
mod subpages;
mod message_handler;
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 uuid::Uuid;
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::pipewire_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_owned());
}
));
*/
/*
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_id: Uuid) {
self.imp().procedural_child_manager.borrow_mut().device_configs_preferences_group.add(
&self.build_device_config_row(config_id)
);
}
fn build_device_config_row(&self, config_id: Uuid) -> adw::ActionRow {
let row = adw::ActionRow::builder()
.activatable(true)
.build();
self.imp().state_manager.borrow_mut().register_device_config_action_row(
String::from(self.imp().device_navigation_page.title()),
config_id,
row.clone()
);
row.connect_activated(
clone!(
#[weak(rename_to = window)]
self,
move |row| {
let device_name = String::from(row.title());
device_config::switch_to_device_config_window(&window, config_id);
}
)
);
row
}
}

View File

@ -1,89 +0,0 @@
use super::*;
use adw::ResponseAppearance;
use uuid::Uuid;
pub(in crate::window) async fn change_device_config_name(
main_window: &AudioDeviceManagerWindow,
device_config_id: Uuid,
) -> Option<String> {
let old_name = main_window.imp().state_manager.borrow().get_device_config_name(&device_config_id);
let entry = gtk::Entry::builder()
.placeholder_text("Name")
.text(old_name.clone())
.activates_default(true)
.build();
let cancel_response = "cancel";
let change_response = "change";
// Create new dialog
let dialog = adw::AlertDialog::builder()
.heading("Change Configuration Name")
.close_response(cancel_response)
.default_response(change_response)
.extra_child(&entry)
.build();
dialog.add_responses(&[(cancel_response, "Cancel"), (change_response, "Change")]);
dialog.set_response_appearance(change_response, ResponseAppearance::Suggested);
let config_names: Vec<String> = main_window.imp().state_manager.borrow_mut()
.get_device_config_names()
.into_values()
.collect();
// Set entry's css class to "error", when there is no text in it
entry.connect_changed(clone!(
#[weak]
dialog,
#[strong]
old_name,
move |entry| {
let text = entry.text();
let empty = text.is_empty();
let exists = config_names.contains(&String::from(text.clone()));
let mut err = empty || exists;
if text == old_name {
err = false;
}
dialog.set_response_enabled(change_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 None;
};
if response == change_response {
let new_name = entry.text().to_string();
if old_name == new_name {
return None;
}
main_window.imp().state_manager.borrow_mut().change_device_config_name(
&device_config_id,
new_name.clone()
);
return Some(new_name);
};
unreachable!()
}

View File

@ -1,14 +0,0 @@
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;
pub(super) mod new_device_config;
pub mod change_device_config_name;
mod new_device_config_type;
mod select_pipewire_device;
mod new_device_config_name;

View File

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

View File

@ -1,25 +0,0 @@
use std::collections::HashMap;
use crate::window::popups::new_device_config_name::new_device_config_name;
use crate::window::subpages;
use super::*;
pub(in crate::window) async fn new_device_config(
main_window: &AudioDeviceManagerWindow,
name_suggestion: Option<String>,
properties: HashMap<String, String>,
profiles: Vec<String>,
selected_profile: Option<String>,
) {
let Some(name) = new_device_config_name(main_window, name_suggestion).await else { return; };
let config_id = main_window.imp().state_manager.borrow_mut().create_new_device_config(
name.clone(),
properties,
profiles,
selected_profile
);
main_window.add_device_config(config_id);
subpages::device_config::switch_to_device_config_window(main_window, config_id);
}

View File

@ -1,85 +0,0 @@
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>,
) -> 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: Vec<String> = main_window.imp().state_manager.borrow_mut()
.get_device_config_names()
.into_values()
.collect();
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 None;
};
if response == create_response {
let device_config_name = entry.text().to_string();
return Some(device_config_name);
};
unreachable!()
}

View File

@ -1,51 +0,0 @@
use std::collections::HashMap;
use super::*;
use adw::ResponseAppearance;
use crate::window::popups;
pub(in crate::window) async fn new_device_config_type(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::new_device_config(
main_window,
None,
HashMap::new(),
Vec::new(),
None
).await;
return;
}
}

View File

@ -1,186 +0,0 @@
use std::cell::RefCell;
use std::cmp::min;
use std::rc::Rc;
use super::*;
use crate::window::popups;
use adw::ResponseAppearance;
use uuid::Uuid;
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_type::new_device_config_type(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_id = Rc::new(RefCell::new(None));
let mut device_configs_list_len = 0;
for device_config_row in build_device_config_rows(main_window, &selected_config_id) {
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();
println!("{}", device_configs_list.height());
dialog_body.append(&entry);
if device_configs_list_len > 3 {
let device_configs_list_scrollable = gtk::ScrolledWindow::builder()
.child(&device_configs_list)
.vexpand(true)
.build();
dialog_body.append(&device_configs_list_scrollable);
} else {
dialog_body.append(&device_configs_list);
}
// 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_type::new_device_config_type(main_window).await;
return;
}
let selected_row = device_configs_list.selected_row().unwrap();
let config_id = selected_config_id.borrow().clone().unwrap();
if response == duplicate_response {
let mut state_manager = main_window.imp().state_manager.borrow_mut();
let config_name = state_manager.get_device_config_name(&config_id);
let properties = state_manager.get_device_config_properties(&config_id).clone();
let profiles = state_manager.get_device_config_profiles(&config_id).clone();
let selected_profile = state_manager.get_device_config_selected_profile(&config_id).clone();
drop(state_manager);
popups::new_device_config::new_device_config(
main_window,
Some(config_name),
properties,
profiles,
selected_profile
).await;
return;
}
if response == select_response {
main_window.add_device_config(config_id);
return;
}
}
fn build_device_config_rows(
main_window: &AudioDeviceManagerWindow,
selected_config_id: &Rc<RefCell<Option<Uuid>>>,
) -> Vec<adw::ActionRow> {
let mut row_vec = Vec::new();
let mut device_names: Vec<_> = main_window.imp().state_manager.borrow_mut()
.get_device_config_names()
.into_iter()
.collect();
device_names.sort_by(|(_, v1), (_, v2)| v1.cmp(v2));
for (config_id, config_name) in device_names {
let row = adw::ActionRow::builder()
.title(config_name.clone())
.build();
row.connect_activate(clone!(
#[weak]
selected_config_id,
move |row| {
*selected_config_id.borrow_mut() = Some(config_id.clone());
}
)
);
row_vec.push(
row
);
}
row_vec
}

View File

@ -1,175 +0,0 @@
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;
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();
println!("{}", device_list.height());
dialog_body.append(&entry);
if device_list_len > 3 {
let device_list_scrollable = gtk::ScrolledWindow::builder()
.child(&device_list)
.vexpand(true)
.build();
dialog_body.append(&device_list_scrollable);
} else {
dialog_body.append(&device_list);
}
// 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);
let device_name = main_window.imp().device_navigation_page.title();
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_owned(),
device_properties.get("device.name").unwrap().clone()
);
popups::new_device_config::new_device_config(
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 mut device_names: Vec<_> = main_window.imp().pipewire_manager
.get_device_names()
.into_iter()
.collect();
device_names.sort_by(|(_, v1), (_, v2)| v1.cmp(v2));
for (device_id, device_name) in device_names {
let row = adw::ActionRow::builder()
.title(device_name)
.build();
row.connect_activate(clone!(
#[weak]
selected_device_id,
move |row| {
*selected_device_id.borrow_mut() = device_id;
}
)
);
row_vec.push(
row
);
}
row_vec
}

View File

@ -1,161 +0,0 @@
use adw::gdk::pango::EllipsizeMode;
use crate::window::{popups, AudioDeviceManagerWindow};
use adw::prelude::*;
use gtk::prelude::*;
use adw::subclass::prelude::*;
use adw::glib::clone;
use gtk::{glib, gio};
use uuid::Uuid;
pub(in crate::window) fn switch_to_device_config_window(main_window: &AudioDeviceManagerWindow, device_config_id: Uuid) {
let device_config_window = build_device_config_window(main_window, device_config_id);
main_window.imp().split_view.set_content(Some(&device_config_window));
}
fn build_device_config_window(main_window: &AudioDeviceManagerWindow, device_config_id: Uuid) -> adw::NavigationPage {
let (navigation_page, preferences_page) = page_base::build_page_base(main_window, device_config_id);
preferences_page.add(
&profile_selection::build_profile_selection(main_window, device_config_id),
);
navigation_page
}
mod page_base {
use super::*;
pub(super) fn build_page_base(main_window: &AudioDeviceManagerWindow, device_config_id: Uuid) -> (adw::NavigationPage, adw::PreferencesPage) {
let preferences_page = adw::PreferencesPage::builder()
.build();
let clamp = adw::Clamp::builder()
.child(&preferences_page)
.maximum_size(640)
.build();
let scrolled_window = gtk::ScrolledWindow::builder()
.child(&clamp)
.build();
let toolbar_view = adw::ToolbarView::builder()
.content(&scrolled_window)
.build();
let header_bar = build_header_bar(main_window, device_config_id);
toolbar_view.add_top_bar(&header_bar);
let navigation_page = adw::NavigationPage::builder()
.child(&toolbar_view)
.build();
(navigation_page, preferences_page)
}
fn build_header_bar(main_window: &AudioDeviceManagerWindow, device_config_id: Uuid) -> adw::HeaderBar {
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 header_bar = adw::HeaderBar::builder()
.show_title(true)
.build();
header_bar.pack_start(&back_button);
let device_config_name = main_window.imp().state_manager.borrow().get_device_config_name(&device_config_id);
let title_label = gtk::Label::builder()
.label(&device_config_name)
.single_line_mode(true)
.ellipsize(EllipsizeMode::End)
.width_chars(5)
.css_classes(["title"])
.build();
let title_edit_button = gtk::Button::builder()
.icon_name("edit")
.build();
title_edit_button.connect_clicked(clone!(
#[weak]
title_label,
#[weak]
main_window,
move |_| {
glib::spawn_future_local(clone!(
#[weak]
main_window,
#[weak]
title_label,
async move {
let Some(new_device_name) = popups::change_device_config_name::change_device_config_name(
&main_window,
device_config_id
).await else { return };
title_label.set_label(&new_device_name)
}
));
}
));
let title = gtk::Box::builder()
.build();
title.append(&title_label);
title.append(&title_edit_button);
header_bar.set_title_widget(Some(&title));
header_bar
}
}
mod profile_selection {
use super::*;
pub(super) fn build_profile_selection(main_window: &AudioDeviceManagerWindow, device_config_id: Uuid) -> adw::PreferencesGroup {
let preferences_group = adw::PreferencesGroup::builder()
.build();
let combo_row = adw::ComboRow::builder()
.title("Profile")
.build();
let profile_dropdown = gtk::DropDown::builder()
.build();
let properties = main_window.imp().state_manager.borrow().get_device_config_properties(&device_config_id).clone();
let device_ids = main_window.imp().pipewire_manager.get_device_ids_from_properties(properties);
if device_ids.len() == 1 {
let id = device_ids[0];
main_window.imp().pipewire_manager.register_profile_combo_row(id, combo_row.clone());
}
preferences_group.add(&combo_row);
preferences_group
}
}

View File

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

View File