linux-rust: add battery to window and add option for text in tray

This commit is contained in:
Kavish Devar
2025-11-20 18:56:17 +05:30
parent 093554da07
commit 4737cbfc2c
7 changed files with 214 additions and 54 deletions

Binary file not shown.

View File

@@ -10,7 +10,6 @@ command: librepods
finish-args:
- --socket=wayland
- --socket=fallback-x11
- --share=ipc
- --socket=pulseaudio
- --system-talk-name=org.bluez
- --allow=bluetooth

View File

@@ -1,6 +1,7 @@
use std::fmt::Display;
use iced::widget::{combo_box, ComboBox};
use iced::widget::combo_box;
use serde::{Deserialize, Serialize};
use crate::bluetooth::aacp::BatteryInfo;
use crate::devices::airpods::AirPodsInformation;
use crate::devices::nothing::NothingInformation;
@@ -58,7 +59,8 @@ pub struct AirPodsState {
pub noise_control_state: combo_box::State<AirPodsNoiseControlMode>,
pub conversation_awareness_enabled: bool,
pub personalized_volume_enabled: bool,
pub allow_off_mode: bool
pub allow_off_mode: bool,
pub battery: Vec<BatteryInfo>
}
#[derive(Clone, Debug)]

View File

@@ -28,19 +28,33 @@ use crate::utils::get_devices_path;
#[derive(Parser)]
struct Args {
#[arg(long)]
#[arg(long, short='d', help="Enable debug logging")]
debug: bool,
#[arg(long)]
#[arg(long, help="Disable system tray, useful if your environment doesn't support AppIndicator or StatusNotifier")]
no_tray: bool,
#[arg(long)]
#[arg(long, help="Start the application minimized to tray")]
start_minimized: bool,
#[arg(long, help="Enable Bluetooth LE debug logging. Only use when absolutely necessary; this produces a lot of logs.")]
le_debug: bool,
#[arg(long, short='v', help="Show application version and exit")]
version: bool
}
fn main() -> iced::Result {
let args = Args::parse();
if args.version {
println!("You are running LibrePods version {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
let log_level = if args.debug { "debug" } else { "info" };
let wayland_display = env::var("WAYLAND_DISPLAY").is_ok();
if env::var("RUST_LOG").is_err() {
unsafe { env::set_var("RUST_LOG", log_level.to_owned() + ",winit=warn,tracing=warn,,iced_wgpu=warn,wgpu_hal=warn,wgpu_core=warn,librepods_rust::bluetooth::le=warn,cosmic_text=warn,naga=warn,iced_winit=warn") };
if wayland_display {
unsafe { env::set_var("WGPU_BACKEND", "gl") };
}
unsafe { env::set_var("RUST_LOG", log_level.to_owned() + &format!(",winit=warn,tracing=warn,iced_wgpu=warn,wgpu_hal=warn,wgpu_core=warn,cosmic_text=warn,naga=warn,iced_winit=warn,librepods_rust::bluetooth::le={}", if args.le_debug { "debug" } else { "warn" })) };
}
env_logger::init();

View File

@@ -24,6 +24,7 @@ pub fn airpods_view<'a>(
) -> iced::widget::Container<'a, Message> {
let mac = mac.to_string();
// order: name, noise control, press and hold config, call controls (not sure if why it might be needed, adding it just in case), audio (personalized volume, conversational awareness, adaptive audio slider), connection settings, microphone, head gestures (not adding this), off listening mode, device information
let aacp_manager_for_rename = aacp_manager.clone();
let rename_input = container(
row![
@@ -221,14 +222,13 @@ pub fn airpods_view<'a>(
column![
text("Personalized Volume").size(16),
text("Adjusts the volume in response to your environment.").size(12).style(
|theme: &Theme| {
let mut style = text::Style::default();
style.color = Some(theme.palette().text.scale_alpha(0.7));
style
}
)
],
Space::with_width(Length::Fill),
|theme: &Theme| {
let mut style = text::Style::default();
style.color = Some(theme.palette().text.scale_alpha(0.7));
style
}
).width(Length::Fill),
].width(Length::Fill),
toggler(state.personalized_volume_enabled)
.on_toggle(
{
@@ -255,6 +255,7 @@ pub fn airpods_view<'a>(
.size(20)
]
.align_y(Center)
.spacing(8)
},
Rule::horizontal(8).style(
|theme: &Theme| {
@@ -272,14 +273,13 @@ pub fn airpods_view<'a>(
column![
text("Conversation Awareness").size(16),
text("Lowers the volume of your audio when it detects that you are speaking.").size(12).style(
|theme: &Theme| {
let mut style = text::Style::default();
style.color = Some(theme.palette().text.scale_alpha(0.7));
style
}
)
],
Space::with_width(Length::Fill),
|theme: &Theme| {
let mut style = text::Style::default();
style.color = Some(theme.palette().text.scale_alpha(0.7));
style
}
).width(Length::Fill),
].width(Length::Fill),
toggler(state.conversation_awareness_enabled)
.on_toggle(move |is_enabled| {
let aacp_manager = aacp_manager_conv_detect.clone();
@@ -299,6 +299,7 @@ pub fn airpods_view<'a>(
.size(20)
]
.align_y(Center)
.spacing(8)
}
]
.spacing(4)
@@ -328,15 +329,14 @@ pub fn airpods_view<'a>(
container(row![
column![
text("Off Listening Mode").size(16),
text("When this is on, AIrPods listening modes will include an Off option. Loud sound levels are not reduced when listening mode is set to Off.").size(12).style(
text("When this is on, AirPods listening modes will include an Off option. Loud sound levels are not reduced when listening mode is set to Off.").size(12).style(
|theme: &Theme| {
let mut style = text::Style::default();
style.color = Some(theme.palette().text.scale_alpha(0.7));
style
}
)
],
Space::with_width(Length::Fill),
).width(Length::Fill)
].width(Length::Fill),
toggler(state.allow_off_mode)
.on_toggle(move |is_enabled| {
let aacp_manager = aacp_manager_olm.clone();
@@ -356,6 +356,7 @@ pub fn airpods_view<'a>(
.size(20)
]
.align_y(Center)
.spacing(8)
)
.padding(Padding{
top: 5.0,

View File

@@ -6,6 +6,7 @@ use tokio::sync::mpsc::UnboundedSender;
use crate::bluetooth::aacp::ControlCommandIdentifiers;
use crate::ui::messages::BluetoothUIMessage;
use crate::utils::get_app_settings_path;
#[derive(Debug)]
pub(crate) struct MyTray {
@@ -57,7 +58,15 @@ impl ksni::Tray for MyTray {
};
let any_bud_charging = matches!(self.battery_l_status, Some(crate::bluetooth::aacp::BatteryStatus::Charging))
|| matches!(self.battery_r_status, Some(crate::bluetooth::aacp::BatteryStatus::Charging));
let icon = generate_icon(&text, false, any_bud_charging);
let app_settings_path = get_app_settings_path();
let settings = std::fs::read_to_string(&app_settings_path)
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok());
let text_mode = settings.clone()
.and_then(|v| v.get("tray_text_mode").cloned())
.and_then(|ttm| serde_json::from_value(ttm).ok())
.unwrap_or(false);
let icon = generate_icon(&text, text_mode, any_bud_charging);
vec![icon]
}
fn tool_tip(&self) -> ToolTip {

View File

@@ -1,7 +1,7 @@
use std::collections::HashMap;
use iced::widget::button::Style;
use iced::widget::{button, column, container, pane_grid, text, Space, combo_box, row, text_input, scrollable, vertical_rule, rule};
use iced::{daemon, window, Background, Border, Center, Color, Element, Length, Size, Subscription, Task, Theme};
use iced::widget::{button, column, container, pane_grid, text, Space, combo_box, row, text_input, scrollable, vertical_rule, rule, toggler};
use iced::{daemon, window, Background, Border, Center, Color, Element, Font, Length, Padding, Size, Subscription, Task, Theme};
use std::sync::Arc;
use bluer::{Address, Session};
use iced::border::Radius;
@@ -10,7 +10,7 @@ use iced::widget::rule::FillMode;
use log::{debug, error};
use tokio::sync::mpsc::UnboundedReceiver;
use tokio::sync::{Mutex, RwLock};
use crate::bluetooth::aacp::{AACPEvent, ControlCommandIdentifiers};
use crate::bluetooth::aacp::{AACPEvent, ControlCommandIdentifiers, BatteryComponent, BatteryStatus};
use crate::bluetooth::managers::DeviceManagers;
use crate::devices::enums::{AirPodsNoiseControlMode, AirPodsState, DeviceData, DeviceState, DeviceType, NothingAncMode, NothingState};
use crate::ui::messages::BluetoothUIMessage;
@@ -26,6 +26,8 @@ pub fn start_ui(
daemon(App::title, App::update, App::view)
.subscription(App::subscription)
.theme(App::theme)
.font(include_bytes!("../../assets/font/sf_pro.otf").as_slice())
.default_font(Font::with_name("SF Pro Text"))
.run_with(move || App::new(ui_rx, start_minimized, device_managers))
}
@@ -43,6 +45,7 @@ pub struct App {
pending_add_device: Option<(String, Address)>,
device_type_state: combo_box::State<DeviceType>,
selected_device_type: Option<DeviceType>,
tray_text_mode: bool
}
pub struct BluetoothState {
@@ -73,6 +76,7 @@ pub enum Message {
ConfirmAddDevice,
CancelAddDevice,
StateChanged(String, DeviceState),
TrayTextModeChanged(bool) // yes, I know I should add all settings to a struct, but I'm lazy
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
@@ -116,12 +120,17 @@ impl App {
};
let app_settings_path = get_app_settings_path();
let selected_theme = std::fs::read_to_string(&app_settings_path)
let settings = std::fs::read_to_string(&app_settings_path)
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok());
let selected_theme = settings.clone()
.and_then(|v| v.get("theme").cloned())
.and_then(|t| serde_json::from_value(t).ok())
.unwrap_or(MyTheme::Dark);
let tray_text_mode = settings.clone()
.and_then(|v| v.get("tray_text_mode").cloned())
.and_then(|ttm| serde_json::from_value(ttm).ok())
.unwrap_or(false);
let bluetooth_state = BluetoothState::new();
@@ -132,6 +141,7 @@ impl App {
// ("28:2D:7F:C2:05:5B".to_string(), dummy_device_state),
// ]);
let device_states = HashMap::new();
(
Self {
@@ -172,7 +182,8 @@ impl App {
DeviceType::Nothing
]),
selected_device_type: None,
device_managers
device_managers,
tray_text_mode
},
Task::batch(vec![open_task, wait_task])
)
@@ -205,7 +216,7 @@ impl App {
Message::ThemeSelected(theme) => {
self.selected_theme = theme;
let app_settings_path = get_app_settings_path();
let settings = serde_json::json!({"theme": self.selected_theme});
let settings = serde_json::json!({"theme": self.selected_theme, "tray_text_mode": self.tray_text_mode});
debug!("Writing settings to {}: {}", app_settings_path.to_str().unwrap() , settings);
std::fs::write(app_settings_path, settings.to_string()).ok();
Task::none()
@@ -301,6 +312,7 @@ impl App {
};
self.device_states.insert(mac.clone(), DeviceState::AirPods(AirPodsState {
device_name,
battery: state.battery_info.clone(),
noise_control_mode: state.control_command_status_list.iter().find_map(|status| {
if status.identifier == ControlCommandIdentifiers::ListeningMode {
status.value.get(0).map(|b| AirPodsNoiseControlMode::from_byte(b))
@@ -444,6 +456,12 @@ impl App {
}
}
}
AACPEvent::BatteryInfo(battery_info) => {
if let Some(DeviceState::AirPods(state)) = self.device_states.get_mut(&mac) {
state.battery = battery_info;
debug!("Updated battery info for {}: {:?}", mac, state.battery);
}
}
_ => {}
}
Task::batch(vec![
@@ -554,6 +572,14 @@ impl App {
}
Task::none()
}
Message::TrayTextModeChanged(is_enabled) => {
self.tray_text_mode = is_enabled;
let app_settings_path = get_app_settings_path();
let settings = serde_json::json!({"theme": self.selected_theme, "tray_text_mode": self.tray_text_mode});
debug!("Writing settings to {}: {}", app_settings_path.to_str().unwrap() , settings);
std::fs::write(app_settings_path, settings.to_string()).ok();
Task::none()
}
}
}
@@ -569,18 +595,44 @@ impl App {
let pane_grid = pane_grid::PaneGrid::new(&self.panes, |_pane_id, pane, _is_maximized| {
match pane {
Pane::Sidebar => {
let create_tab_button = |tab: Tab, label: &str, description: &str, connected: bool| -> Element<'_, Message> {
let label = label.to_string();
let create_tab_button = |tab: Tab, label: &str, mac_addr: &str, connected: bool| -> Element<'_, Message> {
let label = label.to_string() + if connected { " 􀉣" } else { "" };
let is_selected = self.selected_tab == tab;
let col = column![
text(label).size(16),
text(
text({
if connected {
format!("Connected - {}", description)
let mac = match tab {
Tab::Device(ref mac) => mac.as_str(),
_ => "",
};
match self.device_states.get(mac) {
Some(DeviceState::AirPods(state)) => {
let b = &state.battery;
let left = b.iter().find(|x| x.component == BatteryComponent::Left)
.map(|x| x.level).unwrap_or_default();
let right = b.iter().find(|x| x.component == BatteryComponent::Right)
.map(|x| x.level).unwrap_or_default();
let case = b.iter().find(|x| x.component == BatteryComponent::Case)
.map(|x| x.level).unwrap_or_default();
let left_charging = b.iter().find(|x| x.component == BatteryComponent::Left)
.map(|x| x.status == BatteryStatus::Charging).unwrap_or(false);
let right_charging = b.iter().find(|x| x.component == BatteryComponent::Right)
.map(|x| x.status == BatteryStatus::Charging).unwrap_or(false);
let case_charging = b.iter().find(|x| x.component == BatteryComponent::Case)
.map(|x| x.status == BatteryStatus::Charging).unwrap_or(false);
format!(
"\u{1018E5} {}%{} \u{1018E8} {}%{} \u{100E6C} {}%{}",
left, if left_charging {"\u{1002E6}"} else {""}, right, if right_charging {"\u{1002E6}"} else {""}, case, if case_charging {"\u{1002E6}"} else {""}
)
}
_ => "Connected".to_string(),
}
} else {
format!("{}", description)
mac_addr.to_string()
}
).size(12)
}).size(12)
];
let content = container(col)
.padding(8);
@@ -791,12 +843,65 @@ impl App {
}
}
Tab::Settings => {
container(
column![
text("Settings").size(40),
Space::with_height(Length::from(20)),
let tray_text_mode_toggle = container(
row![
column![
text("Use text in tray").size(16),
text("Use text for battery status in tray instead of a progress bar.").size(12).style(
|theme: &Theme| {
let mut style = text::Style::default();
style.color = Some(theme.palette().text.scale_alpha(0.7));
style
}
).width(Length::Fill)
].width(Length::Fill),
toggler(self.tray_text_mode)
.on_toggle(move |is_enabled| {
Message::TrayTextModeChanged(is_enabled)
})
.spacing(0)
.size(20)
]
.align_y(Center)
.spacing(12)
)
.padding(Padding{
top: 5.0,
bottom: 5.0,
left: 18.0,
right: 18.0,
})
.style(
|theme: &Theme| {
let mut style = container::Style::default();
style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1)));
let mut border = Border::default();
border.color = theme.palette().primary.scale_alpha(0.5);
style.border = border.rounded(16);
style
}
)
.align_y(Center);
let appearance_settings_col = column![
container(
text("Appearance").size(20).style(
|theme: &Theme| {
let mut style = text::Style::default();
style.color = Some(theme.palette().primary);
style
}
)
)
.padding(Padding{
top: 0.0,
bottom: 0.0,
left: 18.0,
right: 18.0,
}),
container(
row![
text("Theme:")
text("Theme")
.size(16),
Space::with_width(Length::Fill),
combo_box(
@@ -808,23 +913,23 @@ impl App {
.input_style(
|theme: &Theme, _status| {
text_input::Style {
background: Background::Color(Color::TRANSPARENT),
background: Background::Color(theme.palette().primary.scale_alpha(0.2)),
border: Border {
width: 1.0,
color: theme.palette().text,
radius: Radius::from(8.0),
color: theme.palette().text.scale_alpha(0.3),
radius: Radius::from(4.0)
},
icon: Default::default(),
placeholder: theme.palette().text.scale_alpha(0.5),
placeholder: theme.palette().text,
value: theme.palette().text,
selection: theme.palette().primary
selection: Default::default(),
}
}
)
.menu_style(
|theme: &Theme| {
menu::Style {
background: Background::Color(Color::TRANSPARENT),
background: Background::Color(theme.palette().background),
border: Border {
width: 1.0,
color: theme.palette().text,
@@ -836,9 +941,40 @@ impl App {
}
}
)
.width(Length::from(350))
.padding(Padding{
top: 5.0,
bottom: 5.0,
left: 10.0,
right: 10.0,
})
.width(Length::from(200))
]
.align_y(Center)
)
.padding(Padding{
top: 5.0,
bottom: 5.0,
left: 18.0,
right: 18.0,
})
.style(
|theme: &Theme| {
let mut style = container::Style::default();
style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1)));
let mut border = Border::default();
border.color = theme.palette().primary.scale_alpha(0.5);
style.border = border.rounded(16);
style
}
)
]
.spacing(12);
container(
column![
appearance_settings_col,
Space::with_height(Length::from(20)),
tray_text_mode_toggle
]
)
.padding(20)
@@ -862,7 +998,6 @@ impl App {
].into(),
Space::with_width(Length::Fill).into(),
];
// Only show "Add" button if this device is not the pending one
if !matches!(&self.pending_add_device, Some((_, addr)) if addr == &device.1) {
row_elements.push(
button(