mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-04-11 13:57:22 +00:00
* feat: add stem press track control and headless mode support - Parse STEM_PRESS packets and emit AACPEvent::StemPress with press type and bud side - Enable double/triple tap detection on init via StemConfig control command (0x06) - Double press → next track, triple press → previous track via MPRIS D-Bus - Add next_track() and previous_track() to MediaController - Add --no-tray flag for headless operation without a GUI - Replace unwrap() on ui_tx.send() calls with graceful warn! logging (vibecoded) * Update main.rs * feat: make stem press track control optional with GUI toggle Add a --no-stem-control CLI flag and a toggle in the Settings tab for environments that handle AirPods AVRCP commands natively (e.g. via BlueZ/PipeWire). The feature remains enabled by default. - Load stem_control from app settings JSON on startup; --no-stem-control overrides it to false regardless of the saved value - Share an Arc<AtomicBool> between the async backend and the GUI thread; AirPodsDevice holds the Arc directly so the event loop reads the live value on every stem press — toggle takes effect immediately without reconnecting - Persist stem_control to settings JSON alongside theme and tray_text_mode - Add a "Controls" section to the Settings tab with a toggler labelled "Stem press track control", with a subtitle explaining the AVRCP conflict scenario - Fix StemConfig bitmask comment to clarify it uses a separate numbering scheme from the StemPressType event enum values (0x05–0x08)
1308 lines
72 KiB
Rust
1308 lines
72 KiB
Rust
use crate::bluetooth::aacp::{
|
|
AACPEvent, BatteryComponent, BatteryStatus, ControlCommandIdentifiers,
|
|
};
|
|
use crate::bluetooth::managers::DeviceManagers;
|
|
use crate::devices::enums::{
|
|
AirPodsNoiseControlMode, AirPodsState, DeviceData, DeviceState, DeviceType, NothingAncMode,
|
|
NothingState,
|
|
};
|
|
use crate::ui::airpods::airpods_view;
|
|
use crate::ui::messages::BluetoothUIMessage;
|
|
use crate::ui::nothing::nothing_view;
|
|
use crate::utils::{MyTheme, get_app_settings_path, get_devices_path};
|
|
use bluer::{Address, Session};
|
|
use iced::border::Radius;
|
|
use iced::overlay::menu;
|
|
use iced::widget::button::Style;
|
|
use iced::widget::rule::FillMode;
|
|
use iced::widget::{
|
|
Space, button, column, combo_box, container, pane_grid, row, rule, scrollable, text,
|
|
text_input, toggler, vertical_rule,
|
|
};
|
|
use iced::{
|
|
Background, Border, Center, Element, Font, Length, Padding, Size, Subscription, Task, Theme,
|
|
daemon, window,
|
|
};
|
|
use log::{debug, error};
|
|
use std::collections::HashMap;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::Arc;
|
|
use tokio::sync::mpsc::UnboundedReceiver;
|
|
use tokio::sync::{Mutex, RwLock};
|
|
|
|
pub fn start_ui(
|
|
ui_rx: UnboundedReceiver<BluetoothUIMessage>,
|
|
start_minimized: bool,
|
|
device_managers: Arc<RwLock<HashMap<String, DeviceManagers>>>,
|
|
stem_control: Arc<AtomicBool>,
|
|
) -> iced::Result {
|
|
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, stem_control))
|
|
}
|
|
|
|
pub struct App {
|
|
window: Option<window::Id>,
|
|
panes: pane_grid::State<Pane>,
|
|
selected_tab: Tab,
|
|
theme_state: combo_box::State<MyTheme>,
|
|
selected_theme: MyTheme,
|
|
ui_rx: Arc<Mutex<UnboundedReceiver<BluetoothUIMessage>>>,
|
|
bluetooth_state: BluetoothState,
|
|
paired_devices: HashMap<String, Address>,
|
|
device_states: HashMap<String, DeviceState>,
|
|
device_managers: Arc<RwLock<HashMap<String, DeviceManagers>>>,
|
|
pending_add_device: Option<(String, Address)>,
|
|
device_type_state: combo_box::State<DeviceType>,
|
|
selected_device_type: Option<DeviceType>,
|
|
tray_text_mode: bool,
|
|
stem_control: Arc<AtomicBool>,
|
|
}
|
|
|
|
pub struct BluetoothState {
|
|
connected_devices: Vec<String>,
|
|
}
|
|
|
|
impl BluetoothState {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
connected_devices: Vec::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum Message {
|
|
WindowOpened(window::Id),
|
|
WindowClosed(window::Id),
|
|
Resized(pane_grid::ResizeEvent),
|
|
SelectTab(Tab),
|
|
ThemeSelected(MyTheme),
|
|
CopyToClipboard(String),
|
|
BluetoothMessage(BluetoothUIMessage),
|
|
ShowNewDialogTab,
|
|
GotPairedDevices(HashMap<String, Address>),
|
|
StartAddDevice(String, Address),
|
|
SelectDeviceType(DeviceType),
|
|
ConfirmAddDevice,
|
|
CancelAddDevice,
|
|
StateChanged(String, DeviceState),
|
|
TrayTextModeChanged(bool), // yes, I know I should add all settings to a struct, but I'm lazy
|
|
StemControlChanged(bool),
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
|
pub enum Tab {
|
|
Device(String),
|
|
Settings,
|
|
AddDevice,
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub enum Pane {
|
|
Sidebar,
|
|
Content,
|
|
}
|
|
|
|
impl App {
|
|
pub fn new(
|
|
ui_rx: UnboundedReceiver<BluetoothUIMessage>,
|
|
start_minimized: bool,
|
|
device_managers: Arc<RwLock<HashMap<String, DeviceManagers>>>,
|
|
stem_control: Arc<AtomicBool>,
|
|
) -> (Self, Task<Message>) {
|
|
let (mut panes, first_pane) = pane_grid::State::new(Pane::Sidebar);
|
|
let split = panes.split(pane_grid::Axis::Vertical, first_pane, Pane::Content);
|
|
panes.resize(split.unwrap().1, 0.2);
|
|
|
|
let ui_rx = Arc::new(Mutex::new(ui_rx));
|
|
|
|
let wait_task = Task::perform(wait_for_message(Arc::clone(&ui_rx)), |msg| msg);
|
|
|
|
let (window, open_task) = if start_minimized {
|
|
(None, Task::none())
|
|
} else {
|
|
let mut settings = window::Settings::default();
|
|
settings.min_size = Some(Size::new(400.0, 300.0));
|
|
settings.icon = window::icon::from_file("../../assets/icon.png").ok();
|
|
let (id, open) = window::open(settings);
|
|
(Some(id), open.map(Message::WindowOpened))
|
|
};
|
|
|
|
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 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();
|
|
|
|
// let dummy_device_state = DeviceState::AirPods(AirPodsState {
|
|
// conversation_awareness_enabled: false,
|
|
// });
|
|
// let device_states = HashMap::from([
|
|
// ("28:2D:7F:C2:05:5B".to_string(), dummy_device_state),
|
|
// ]);
|
|
|
|
let device_states = HashMap::new();
|
|
(
|
|
Self {
|
|
window,
|
|
panes,
|
|
selected_tab: Tab::Device("none".to_string()),
|
|
theme_state: combo_box::State::new(vec![
|
|
MyTheme::Light,
|
|
MyTheme::Dark,
|
|
MyTheme::Dracula,
|
|
MyTheme::Nord,
|
|
MyTheme::SolarizedLight,
|
|
MyTheme::SolarizedDark,
|
|
MyTheme::GruvboxLight,
|
|
MyTheme::GruvboxDark,
|
|
MyTheme::CatppuccinLatte,
|
|
MyTheme::CatppuccinFrappe,
|
|
MyTheme::CatppuccinMacchiato,
|
|
MyTheme::CatppuccinMocha,
|
|
MyTheme::TokyoNight,
|
|
MyTheme::TokyoNightStorm,
|
|
MyTheme::TokyoNightLight,
|
|
MyTheme::KanagawaWave,
|
|
MyTheme::KanagawaDragon,
|
|
MyTheme::KanagawaLotus,
|
|
MyTheme::Moonfly,
|
|
MyTheme::Nightfly,
|
|
MyTheme::Oxocarbon,
|
|
MyTheme::Ferra,
|
|
]),
|
|
selected_theme,
|
|
ui_rx,
|
|
bluetooth_state,
|
|
paired_devices: HashMap::new(),
|
|
device_states,
|
|
pending_add_device: None,
|
|
device_type_state: combo_box::State::new(vec![DeviceType::Nothing]),
|
|
selected_device_type: None,
|
|
device_managers,
|
|
tray_text_mode,
|
|
stem_control,
|
|
},
|
|
Task::batch(vec![open_task, wait_task]),
|
|
)
|
|
}
|
|
|
|
fn title(&self, _id: window::Id) -> String {
|
|
"LibrePods".to_string()
|
|
}
|
|
|
|
fn update(&mut self, message: Message) -> Task<Message> {
|
|
match message {
|
|
Message::WindowOpened(id) => {
|
|
self.window = Some(id);
|
|
Task::none()
|
|
}
|
|
Message::WindowClosed(id) => {
|
|
if self.window == Some(id) {
|
|
self.window = None;
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::Resized(event) => {
|
|
self.panes.resize(event.split, event.ratio);
|
|
Task::none()
|
|
}
|
|
Message::SelectTab(tab) => {
|
|
self.selected_tab = tab;
|
|
Task::none()
|
|
}
|
|
Message::ThemeSelected(theme) => {
|
|
self.selected_theme = theme;
|
|
let app_settings_path = get_app_settings_path();
|
|
let settings = serde_json::json!({
|
|
"theme": self.selected_theme,
|
|
"tray_text_mode": self.tray_text_mode,
|
|
"stem_control": self.stem_control.load(Ordering::Relaxed),
|
|
});
|
|
debug!(
|
|
"Writing settings to {}: {}",
|
|
app_settings_path.to_str().unwrap(),
|
|
settings
|
|
);
|
|
std::fs::write(app_settings_path, settings.to_string()).ok();
|
|
Task::none()
|
|
}
|
|
Message::CopyToClipboard(data) => iced::clipboard::write(data),
|
|
Message::BluetoothMessage(ui_message) => {
|
|
match ui_message {
|
|
BluetoothUIMessage::NoOp => {
|
|
let ui_rx = Arc::clone(&self.ui_rx);
|
|
|
|
Task::perform(wait_for_message(ui_rx), |msg| msg)
|
|
}
|
|
BluetoothUIMessage::OpenWindow => {
|
|
let ui_rx = Arc::clone(&self.ui_rx);
|
|
let wait_task = Task::perform(wait_for_message(ui_rx), |msg| msg);
|
|
debug!("Opening main window...");
|
|
if let Some(window_id) = self.window {
|
|
Task::batch(vec![window::gain_focus(window_id), wait_task])
|
|
} else {
|
|
let mut settings = window::Settings::default();
|
|
settings.min_size = Some(Size::new(400.0, 300.0));
|
|
settings.icon = window::icon::from_file("../../assets/icon.png").ok();
|
|
let (new_window_task, open_task) = window::open(settings);
|
|
self.window = Some(new_window_task);
|
|
Task::batch(vec![open_task.map(Message::WindowOpened), wait_task])
|
|
}
|
|
}
|
|
BluetoothUIMessage::DeviceConnected(mac) => {
|
|
let ui_rx = Arc::clone(&self.ui_rx);
|
|
let wait_task = Task::perform(wait_for_message(ui_rx), |msg| msg);
|
|
debug!(
|
|
"Device connected: {}. Adding to connected devices list",
|
|
mac
|
|
);
|
|
let mut already_connected = false;
|
|
for device in &self.bluetooth_state.connected_devices {
|
|
if device == &mac {
|
|
already_connected = true;
|
|
break;
|
|
}
|
|
}
|
|
if !already_connected {
|
|
self.bluetooth_state.connected_devices.push(mac.clone());
|
|
}
|
|
|
|
// self.device_states.insert(mac.clone(), DeviceState::AirPods(AirPodsState {
|
|
// conversation_awareness_enabled: false,
|
|
// }));
|
|
|
|
let type_ = {
|
|
let devices_json = std::fs::read_to_string(get_devices_path())
|
|
.unwrap_or_else(|e| {
|
|
error!("Failed to read devices file: {}", e);
|
|
"{}".to_string()
|
|
});
|
|
let devices_list: HashMap<String, DeviceData> =
|
|
serde_json::from_str(&devices_json).unwrap_or_else(|e| {
|
|
error!("Deserialization failed: {}", e);
|
|
HashMap::new()
|
|
});
|
|
devices_list.get(&mac).map(|d| d.type_.clone())
|
|
};
|
|
match type_ {
|
|
Some(DeviceType::AirPods) => {
|
|
let device_managers = self.device_managers.blocking_read();
|
|
let device_manager = device_managers.get(&mac).unwrap();
|
|
let aacp_manager = device_manager.get_aacp().unwrap();
|
|
let aacp_manager_state = aacp_manager.state.clone();
|
|
let state = aacp_manager_state.blocking_lock();
|
|
debug!("AACP manager found for AirPods device {}", mac);
|
|
let device_name = {
|
|
let devices_json = std::fs::read_to_string(get_devices_path())
|
|
.unwrap_or_else(|e| {
|
|
error!("Failed to read devices file: {}", e);
|
|
"{}".to_string()
|
|
});
|
|
let devices_list: HashMap<String, DeviceData> =
|
|
serde_json::from_str(&devices_json).unwrap_or_else(|e| {
|
|
error!("Deserialization failed: {}", e);
|
|
HashMap::new()
|
|
});
|
|
devices_list
|
|
.get(&mac)
|
|
.map(|d| d.name.clone())
|
|
.unwrap_or_else(|| "Unknown Device".to_string())
|
|
};
|
|
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.first().map(AirPodsNoiseControlMode::from_byte)
|
|
} else {
|
|
None
|
|
}
|
|
}).unwrap_or(AirPodsNoiseControlMode::Transparency),
|
|
noise_control_state: combo_box::State::new(
|
|
{
|
|
let mut modes = vec![
|
|
AirPodsNoiseControlMode::Transparency,
|
|
AirPodsNoiseControlMode::NoiseCancellation,
|
|
AirPodsNoiseControlMode::Adaptive
|
|
];
|
|
if state.control_command_status_list.iter().any(|status| {
|
|
status.identifier == ControlCommandIdentifiers::AllowOffOption &&
|
|
matches!(status.value.as_slice(), [0x01])
|
|
}) {
|
|
modes.insert(0, AirPodsNoiseControlMode::Off);
|
|
}
|
|
modes
|
|
}
|
|
),
|
|
conversation_awareness_enabled: state.control_command_status_list.iter().any(|status| {
|
|
status.identifier == ControlCommandIdentifiers::ConversationDetectConfig &&
|
|
matches!(status.value.as_slice(), [0x01])
|
|
}),
|
|
personalized_volume_enabled: state.control_command_status_list.iter().any(|status| {
|
|
status.identifier == ControlCommandIdentifiers::AdaptiveVolumeConfig &&
|
|
matches!(status.value.as_slice(), [0x01])
|
|
}),
|
|
allow_off_mode: state.control_command_status_list.iter().any(|status| {
|
|
status.identifier == ControlCommandIdentifiers::AllowOffOption &&
|
|
matches!(status.value.as_slice(), [0x01])
|
|
}),
|
|
}));
|
|
}
|
|
Some(DeviceType::Nothing) => {
|
|
self.device_states.insert(
|
|
mac.clone(),
|
|
DeviceState::Nothing(NothingState {
|
|
anc_mode: NothingAncMode::Off,
|
|
anc_mode_state: combo_box::State::new(vec![
|
|
NothingAncMode::Off,
|
|
NothingAncMode::Transparency,
|
|
NothingAncMode::AdaptiveNoiseCancellation,
|
|
NothingAncMode::LowNoiseCancellation,
|
|
NothingAncMode::MidNoiseCancellation,
|
|
NothingAncMode::HighNoiseCancellation,
|
|
]),
|
|
}),
|
|
);
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
Task::batch(vec![wait_task])
|
|
}
|
|
BluetoothUIMessage::DeviceDisconnected(mac) => {
|
|
let ui_rx = Arc::clone(&self.ui_rx);
|
|
let wait_task = Task::perform(wait_for_message(ui_rx), |msg| msg);
|
|
debug!("Device disconnected: {}", mac);
|
|
|
|
self.device_states.remove(&mac);
|
|
Task::batch(vec![wait_task])
|
|
}
|
|
BluetoothUIMessage::AACPUIEvent(mac, event) => {
|
|
let ui_rx = Arc::clone(&self.ui_rx);
|
|
let wait_task = Task::perform(wait_for_message(ui_rx), |msg| msg);
|
|
debug!("AACP UI Event for {}: {:?}", mac, event);
|
|
match event {
|
|
AACPEvent::ControlCommand(status) => match status.identifier {
|
|
ControlCommandIdentifiers::ListeningMode => {
|
|
let mode = status
|
|
.value
|
|
.first()
|
|
.map(AirPodsNoiseControlMode::from_byte)
|
|
.unwrap_or(AirPodsNoiseControlMode::Transparency);
|
|
if let Some(DeviceState::AirPods(state)) =
|
|
self.device_states.get_mut(&mac)
|
|
{
|
|
state.noise_control_mode = mode;
|
|
}
|
|
}
|
|
ControlCommandIdentifiers::ConversationDetectConfig => {
|
|
let is_enabled = match status.value.as_slice() {
|
|
[0x01] => true,
|
|
[0x02] => false,
|
|
_ => {
|
|
error!(
|
|
"Unknown Conversation Detect Config value: {:?}",
|
|
status.value
|
|
);
|
|
false
|
|
}
|
|
};
|
|
if let Some(DeviceState::AirPods(state)) =
|
|
self.device_states.get_mut(&mac)
|
|
{
|
|
state.conversation_awareness_enabled = is_enabled;
|
|
}
|
|
}
|
|
ControlCommandIdentifiers::AdaptiveVolumeConfig => {
|
|
let is_enabled = match status.value.as_slice() {
|
|
[0x01] => true,
|
|
[0x02] => false,
|
|
_ => {
|
|
error!(
|
|
"Unknown Adaptive Volume Config value: {:?}",
|
|
status.value
|
|
);
|
|
false
|
|
}
|
|
};
|
|
if let Some(DeviceState::AirPods(state)) =
|
|
self.device_states.get_mut(&mac)
|
|
{
|
|
state.personalized_volume_enabled = is_enabled;
|
|
}
|
|
}
|
|
ControlCommandIdentifiers::AllowOffOption => {
|
|
let is_enabled = match status.value.as_slice() {
|
|
[0x01] => true,
|
|
[0x02] => false,
|
|
_ => {
|
|
error!(
|
|
"Unknown Allow Off Option value: {:?}",
|
|
status.value
|
|
);
|
|
false
|
|
}
|
|
};
|
|
if let Some(DeviceState::AirPods(state)) =
|
|
self.device_states.get_mut(&mac)
|
|
{
|
|
state.allow_off_mode = is_enabled;
|
|
state.noise_control_state = combo_box::State::new({
|
|
let mut modes = vec![
|
|
AirPodsNoiseControlMode::Transparency,
|
|
AirPodsNoiseControlMode::NoiseCancellation,
|
|
AirPodsNoiseControlMode::Adaptive,
|
|
];
|
|
if is_enabled {
|
|
modes.insert(0, AirPodsNoiseControlMode::Off);
|
|
}
|
|
modes
|
|
});
|
|
}
|
|
}
|
|
_ => {
|
|
debug!("Unhandled Control Command Status: {:?}", status);
|
|
}
|
|
},
|
|
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![wait_task])
|
|
}
|
|
BluetoothUIMessage::ATTNotification(mac, handle, value) => {
|
|
debug!(
|
|
"ATT Notification for {}: handle=0x{:04X}, value={:?}",
|
|
mac, handle, value
|
|
);
|
|
|
|
// TODO: Handle Nothing's ANC Mode changes here
|
|
|
|
let ui_rx = Arc::clone(&self.ui_rx);
|
|
let wait_task = Task::perform(wait_for_message(ui_rx), |msg| msg);
|
|
Task::batch(vec![wait_task])
|
|
}
|
|
}
|
|
}
|
|
Message::ShowNewDialogTab => {
|
|
debug!("switching to Add Device tab");
|
|
self.selected_tab = Tab::AddDevice;
|
|
Task::perform(load_paired_devices(), Message::GotPairedDevices)
|
|
}
|
|
Message::GotPairedDevices(map) => {
|
|
self.paired_devices = map;
|
|
Task::none()
|
|
}
|
|
Message::StartAddDevice(name, addr) => {
|
|
self.pending_add_device = Some((name, addr));
|
|
self.selected_device_type = None;
|
|
Task::none()
|
|
}
|
|
Message::SelectDeviceType(device_type) => {
|
|
self.selected_device_type = Some(device_type);
|
|
Task::none()
|
|
}
|
|
Message::ConfirmAddDevice => {
|
|
if let Some((name, addr)) = self.pending_add_device.take()
|
|
&& let Some(type_) = self.selected_device_type.take()
|
|
{
|
|
let devices_path = get_devices_path();
|
|
let devices_json = std::fs::read_to_string(&devices_path).unwrap_or_else(|e| {
|
|
error!("Failed to read devices file: {}", e);
|
|
"{}".to_string()
|
|
});
|
|
let mut devices_list: HashMap<String, DeviceData> =
|
|
serde_json::from_str(&devices_json).unwrap_or_else(|e| {
|
|
error!("Deserialization failed: {}", e);
|
|
HashMap::new()
|
|
});
|
|
devices_list.insert(
|
|
addr.to_string(),
|
|
DeviceData {
|
|
name,
|
|
type_: type_.clone(),
|
|
information: None,
|
|
},
|
|
);
|
|
let updated_json = serde_json::to_string(&devices_list).unwrap_or_else(|e| {
|
|
error!("Serialization failed: {}", e);
|
|
"{}".to_string()
|
|
});
|
|
if let Err(e) = std::fs::write(&devices_path, updated_json) {
|
|
error!("Failed to write devices file: {}", e);
|
|
}
|
|
self.selected_tab = Tab::Device(addr.to_string());
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::CancelAddDevice => {
|
|
self.pending_add_device = None;
|
|
self.selected_device_type = None;
|
|
Task::none()
|
|
}
|
|
Message::StateChanged(mac, state) => {
|
|
self.device_states.insert(mac.clone(), state);
|
|
// if airpods, update the noise control state combo box based on allow off mode
|
|
let type_ = {
|
|
let devices_json =
|
|
std::fs::read_to_string(get_devices_path()).unwrap_or_else(|e| {
|
|
error!("Failed to read devices file: {}", e);
|
|
"{}".to_string()
|
|
});
|
|
let devices_list: HashMap<String, DeviceData> =
|
|
serde_json::from_str(&devices_json).unwrap_or_else(|e| {
|
|
error!("Deserialization failed: {}", e);
|
|
HashMap::new()
|
|
});
|
|
devices_list.get(&mac).map(|d| d.type_.clone())
|
|
};
|
|
if let Some(DeviceType::AirPods) = type_
|
|
&& let Some(DeviceState::AirPods(state)) = self.device_states.get_mut(&mac)
|
|
{
|
|
state.noise_control_state = combo_box::State::new({
|
|
let mut modes = vec![
|
|
AirPodsNoiseControlMode::Transparency,
|
|
AirPodsNoiseControlMode::NoiseCancellation,
|
|
AirPodsNoiseControlMode::Adaptive,
|
|
];
|
|
if state.allow_off_mode {
|
|
modes.insert(0, AirPodsNoiseControlMode::Off);
|
|
}
|
|
modes
|
|
});
|
|
}
|
|
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,
|
|
"stem_control": self.stem_control.load(Ordering::Relaxed),
|
|
});
|
|
debug!(
|
|
"Writing settings to {}: {}",
|
|
app_settings_path.to_str().unwrap(),
|
|
settings
|
|
);
|
|
std::fs::write(app_settings_path, settings.to_string()).ok();
|
|
Task::none()
|
|
}
|
|
Message::StemControlChanged(is_enabled) => {
|
|
self.stem_control.store(is_enabled, Ordering::Relaxed);
|
|
let app_settings_path = get_app_settings_path();
|
|
let settings = serde_json::json!({
|
|
"theme": self.selected_theme,
|
|
"tray_text_mode": self.tray_text_mode,
|
|
"stem_control": self.stem_control.load(Ordering::Relaxed),
|
|
});
|
|
debug!(
|
|
"Writing settings to {}: {}",
|
|
app_settings_path.to_str().unwrap(),
|
|
settings
|
|
);
|
|
std::fs::write(app_settings_path, settings.to_string()).ok();
|
|
Task::none()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn view(&self, _id: window::Id) -> Element<'_, Message> {
|
|
let devices_json = std::fs::read_to_string(get_devices_path()).unwrap_or_else(|e| {
|
|
error!("Failed to read devices file: {}", e);
|
|
"{}".to_string()
|
|
});
|
|
let devices_list: HashMap<String, DeviceData> = serde_json::from_str(&devices_json)
|
|
.unwrap_or_else(|e| {
|
|
error!("Deserialization failed: {}", e);
|
|
HashMap::new()
|
|
});
|
|
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, 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({
|
|
if connected {
|
|
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 headphone = b.iter().find(|x| x.component == BatteryComponent::Headphone)
|
|
.map(|x| x.level);
|
|
// if headphones is not None, use only that
|
|
if let Some(level) = headphone {
|
|
let charging = b.iter().find(|x| x.component == BatteryComponent::Headphone)
|
|
.map(|x| x.status == BatteryStatus::Charging).unwrap_or(false);
|
|
format!(
|
|
" {}%{}",
|
|
level, if charging {"\u{1002E6}"} else {""}
|
|
)
|
|
} else {
|
|
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 {
|
|
mac_addr.to_string()
|
|
}
|
|
}).size(12)
|
|
];
|
|
let content = container(col)
|
|
.padding(8);
|
|
let style = move |theme: &Theme, _status| {
|
|
if is_selected {
|
|
let mut style = Style::default()
|
|
.with_background(theme.palette().primary);
|
|
let mut border = Border::default();
|
|
border.color = theme.palette().text;
|
|
style.border = border.rounded(12);
|
|
style
|
|
} else {
|
|
let mut style = Style::default()
|
|
.with_background(theme.palette().primary.scale_alpha(0.1));
|
|
let mut border = Border::default();
|
|
border.color = theme.palette().primary.scale_alpha(0.1);
|
|
style.border = border.rounded(8);
|
|
style.text_color = theme.palette().text;
|
|
style
|
|
}
|
|
};
|
|
button(content)
|
|
.style(style)
|
|
.padding(5)
|
|
.on_press(Message::SelectTab(tab))
|
|
.width(Length::Fill)
|
|
.into()
|
|
};
|
|
|
|
let create_settings_button = || -> Element<'_, Message> {
|
|
let label = "Settings".to_string();
|
|
let is_selected = self.selected_tab == Tab::Settings;
|
|
let col = column![text(label).size(16)];
|
|
let content = container(col)
|
|
.padding(8);
|
|
let style = move |theme: &Theme, _status| {
|
|
if is_selected {
|
|
let mut style = Style::default()
|
|
.with_background(theme.palette().primary);
|
|
let mut border = Border::default();
|
|
border.color = theme.palette().text;
|
|
style.border = border.rounded(12);
|
|
style
|
|
} else {
|
|
let mut style = Style::default()
|
|
.with_background(theme.palette().primary.scale_alpha(0.1));
|
|
let mut border = Border::default();
|
|
border.color = theme.palette().primary.scale_alpha(0.1);
|
|
style.border = border.rounded(8);
|
|
style.text_color = theme.palette().text;
|
|
style
|
|
}
|
|
};
|
|
button(content)
|
|
.style(style)
|
|
.padding(5)
|
|
.on_press(Message::SelectTab(Tab::Settings))
|
|
.width(Length::Fill)
|
|
.into()
|
|
};
|
|
|
|
let mut devices = column!().spacing(4);
|
|
let mut devices_vec: Vec<(String, DeviceData)> = devices_list.clone().into_iter().collect();
|
|
devices_vec.sort_by(|a, b| a.1.name.cmp(&b.1.name));
|
|
for (mac, device) in devices_vec {
|
|
let name = device.name.clone();
|
|
let tab_button = create_tab_button(
|
|
Tab::Device(mac.clone()),
|
|
&name,
|
|
&mac,
|
|
self.bluetooth_state.connected_devices.contains(&mac)
|
|
);
|
|
devices = devices.push(tab_button);
|
|
}
|
|
|
|
let settings = create_settings_button();
|
|
|
|
let content = column![
|
|
row![
|
|
text("Devices").size(18),
|
|
Space::with_width(Length::Fill),
|
|
button(
|
|
container(text("+").size(18)).center_x(Length::Fill).center_y(Length::Fill)
|
|
)
|
|
.style(
|
|
|theme: &Theme, _status| {
|
|
let mut style = Style::default();
|
|
style.text_color = theme.palette().text;
|
|
style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1)));
|
|
style.border = Border {
|
|
width: 1.0,
|
|
color: theme.palette().primary.scale_alpha(0.1),
|
|
radius: Radius::from(8.0),
|
|
};
|
|
style
|
|
}
|
|
)
|
|
.padding(0)
|
|
.width(Length::from(28))
|
|
.height(Length::from(28))
|
|
.on_press(Message::ShowNewDialogTab)
|
|
]
|
|
.align_y(Center)
|
|
.padding(4),
|
|
Space::with_height(Length::from(8)),
|
|
devices,
|
|
Space::with_height(Length::Fill),
|
|
settings
|
|
]
|
|
.padding(12);
|
|
pane_grid::Content::new(
|
|
row![
|
|
content,
|
|
vertical_rule(1).style(
|
|
|theme: &Theme| {
|
|
rule::Style{
|
|
color: theme.palette().primary.scale_alpha(0.2),
|
|
width: 2,
|
|
radius: Radius::from(8.0),
|
|
fill_mode: FillMode::Full
|
|
}
|
|
}
|
|
)
|
|
]
|
|
)
|
|
}
|
|
|
|
Pane::Content => {
|
|
let device_managers = self.device_managers.blocking_read();
|
|
let content = match &self.selected_tab {
|
|
Tab::Device(id) => {
|
|
if id == "none" {
|
|
container(
|
|
text("Select a device".to_string()).size(16)
|
|
)
|
|
.center_x(Length::Fill)
|
|
.center_y(Length::Fill)
|
|
} else {
|
|
let device_type = devices_list.get(id).map(|d| d.type_.clone());
|
|
let device_state = self.device_states.get(id);
|
|
debug!("Rendering device view for {}: type={:?}, state={:?}", id, device_type, device_state);
|
|
match device_type {
|
|
Some(DeviceType::AirPods) => {
|
|
|
|
device_state.as_ref().and_then(|state| {
|
|
match state {
|
|
DeviceState::AirPods(state) => {
|
|
device_managers.get(id).and_then(|managers| {
|
|
managers.get_aacp().map(|aacp_manager| airpods_view(
|
|
id,
|
|
&devices_list,
|
|
state,
|
|
aacp_manager.clone()
|
|
))
|
|
})
|
|
}
|
|
_ => None,
|
|
}
|
|
}).unwrap_or_else(|| {
|
|
container(
|
|
text("Required managers or state not available for this AirPods device").size(16)
|
|
)
|
|
.center_x(Length::Fill)
|
|
.center_y(Length::Fill)
|
|
})
|
|
}
|
|
Some(DeviceType::Nothing) => {
|
|
if let Some(DeviceState::Nothing(state)) = device_state {
|
|
if let Some(device_managers) = device_managers.get(id) {
|
|
if let Some(att_manager) = device_managers.get_att() {
|
|
nothing_view(id, &devices_list, state, att_manager.clone())
|
|
} else {
|
|
error!("No ATT manager found for Nothing device {}", id);
|
|
container(
|
|
text("No valid ATT manager found for this Nothing device").size(16)
|
|
)
|
|
.center_x(Length::Fill)
|
|
.center_y(Length::Fill)
|
|
}
|
|
} else {
|
|
error!("No manager found for Nothing device {}", id);
|
|
container(
|
|
text("No manager found for this Nothing device").size(16)
|
|
)
|
|
.center_x(Length::Fill)
|
|
.center_y(Length::Fill)
|
|
}
|
|
} else {
|
|
container(
|
|
text("No state available for this Nothing device").size(16)
|
|
)
|
|
.center_x(Length::Fill)
|
|
.center_y(Length::Fill)
|
|
}
|
|
}
|
|
_ => {
|
|
container(text("Unsupported device").size(16))
|
|
.center_x(Length::Fill)
|
|
.center_y(Length::Fill)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Tab::Settings => {
|
|
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")
|
|
.size(16),
|
|
Space::with_width(Length::Fill),
|
|
combo_box(
|
|
&self.theme_state,
|
|
"Select theme",
|
|
Some(&self.selected_theme),
|
|
Message::ThemeSelected
|
|
)
|
|
.input_style(
|
|
|theme: &Theme, _status| {
|
|
text_input::Style {
|
|
background: Background::Color(theme.palette().primary.scale_alpha(0.2)),
|
|
border: Border {
|
|
width: 1.0,
|
|
color: theme.palette().text.scale_alpha(0.3),
|
|
radius: Radius::from(4.0)
|
|
},
|
|
icon: Default::default(),
|
|
placeholder: theme.palette().text,
|
|
value: theme.palette().text,
|
|
selection: Default::default(),
|
|
}
|
|
}
|
|
)
|
|
.menu_style(
|
|
|theme: &Theme| {
|
|
menu::Style {
|
|
background: Background::Color(theme.palette().background),
|
|
border: Border {
|
|
width: 1.0,
|
|
color: theme.palette().text,
|
|
radius: Radius::from(4.0)
|
|
},
|
|
text_color: theme.palette().text,
|
|
selected_text_color: theme.palette().text,
|
|
selected_background: Background::Color(theme.palette().primary.scale_alpha(0.3)),
|
|
}
|
|
}
|
|
)
|
|
.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);
|
|
|
|
let stem_control_value = self.stem_control.load(Ordering::Relaxed);
|
|
let stem_control_toggle = container(
|
|
row![
|
|
column![
|
|
text("Stem press track control").size(16),
|
|
text("Double press = next track, triple press = previous track. Disable if your environment handles AirPods AVRCP commands natively.").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(stem_control_value)
|
|
.on_toggle(move |is_enabled| {
|
|
Message::StemControlChanged(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 controls_settings_col = column![
|
|
container(
|
|
text("Controls").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,
|
|
}),
|
|
stem_control_toggle
|
|
]
|
|
.spacing(12);
|
|
|
|
container(
|
|
column![
|
|
appearance_settings_col,
|
|
Space::with_height(Length::from(20)),
|
|
tray_text_mode_toggle,
|
|
Space::with_height(Length::from(20)),
|
|
controls_settings_col,
|
|
]
|
|
)
|
|
.padding(20)
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
},
|
|
Tab::AddDevice => {
|
|
container(
|
|
column![
|
|
text("Pick a paired device to add:").size(18),
|
|
Space::with_height(Length::from(10)),
|
|
{
|
|
let mut list_col = column![].spacing(12);
|
|
for device in self.paired_devices.clone() {
|
|
if !devices_list.contains_key(&device.1.to_string()) {
|
|
let mut item_col = column![].spacing(8);
|
|
let mut row_elements = vec![
|
|
column![
|
|
text(device.0.to_string()).size(16),
|
|
text(device.1.to_string()).size(12)
|
|
].into(),
|
|
Space::with_width(Length::Fill).into(),
|
|
];
|
|
if !matches!(&self.pending_add_device, Some((_, addr)) if addr == &device.1) {
|
|
row_elements.push(
|
|
button(
|
|
text("Add").size(14).width(120).align_y(Center).align_x(Center)
|
|
)
|
|
.style(
|
|
|theme: &Theme, _status| {
|
|
let mut style = Style::default();
|
|
style.text_color = theme.palette().text;
|
|
style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.5)));
|
|
style.border = Border {
|
|
width: 1.0,
|
|
color: theme.palette().primary,
|
|
radius: Radius::from(8.0),
|
|
};
|
|
style
|
|
}
|
|
)
|
|
.padding(8)
|
|
.on_press(Message::StartAddDevice(device.0.clone(), device.1))
|
|
.into()
|
|
);
|
|
}
|
|
item_col = item_col.push(row(row_elements).align_y(Center));
|
|
|
|
if let Some((_, pending_addr)) = &self.pending_add_device
|
|
&& pending_addr == &device.1 {
|
|
item_col = item_col.push(
|
|
row![
|
|
text("Device Type:").size(16),
|
|
Space::with_width(Length::Fill),
|
|
combo_box(
|
|
&self.device_type_state,
|
|
"Select device type",
|
|
self.selected_device_type.as_ref(),
|
|
Message::SelectDeviceType
|
|
)
|
|
.input_style(
|
|
|theme: &Theme, _status| {
|
|
text_input::Style {
|
|
background: Background::Color(theme.palette().background),
|
|
border: Border {
|
|
width: 1.0,
|
|
color: theme.palette().text,
|
|
radius: Radius::from(8.0),
|
|
},
|
|
icon: Default::default(),
|
|
placeholder: theme.palette().text.scale_alpha(0.5),
|
|
value: theme.palette().text,
|
|
selection: theme.palette().primary
|
|
}
|
|
}
|
|
)
|
|
.menu_style(
|
|
|theme: &Theme| {
|
|
menu::Style {
|
|
background: Background::Color(theme.palette().background),
|
|
border: Border {
|
|
width: 1.0,
|
|
color: theme.palette().text,
|
|
radius: Radius::from(8.0)
|
|
},
|
|
text_color: theme.palette().text,
|
|
selected_text_color: theme.palette().text,
|
|
selected_background: Background::Color(theme.palette().primary.scale_alpha(0.3)),
|
|
}
|
|
}
|
|
)
|
|
.width(Length::from(200))
|
|
]
|
|
);
|
|
item_col = item_col.push(
|
|
row![
|
|
Space::with_width(Length::Fill),
|
|
button(text("Cancel").size(16).width(Length::Fill).center())
|
|
.on_press(Message::CancelAddDevice)
|
|
.style(|theme: &Theme, _status| {
|
|
let mut style = Style::default();
|
|
style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1)));
|
|
style.text_color = theme.palette().text;
|
|
style.border = Border::default().rounded(8.0);
|
|
style
|
|
})
|
|
.width(Length::from(120))
|
|
.padding(4),
|
|
Space::with_width(Length::from(20)),
|
|
button(text("Add Device").size(16).width(Length::Fill).center())
|
|
.on_press(Message::ConfirmAddDevice)
|
|
.style(|theme: &Theme, _status| {
|
|
let mut style = Style::default();
|
|
style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.3)));
|
|
style.text_color = theme.palette().text;
|
|
style.border = Border::default().rounded(8.0);
|
|
style
|
|
})
|
|
.width(Length::from(120))
|
|
.padding(4),
|
|
]
|
|
.align_y(Center)
|
|
.width(Length::Fill)
|
|
);
|
|
}
|
|
list_col = list_col.push(
|
|
container(item_col)
|
|
.padding(8)
|
|
.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().text;
|
|
style.border = border.rounded(8);
|
|
style
|
|
}
|
|
)
|
|
);
|
|
}
|
|
}
|
|
if self.paired_devices.iter().all(|device| devices_list.contains_key(&device.1.to_string())) && self.pending_add_device.is_none() {
|
|
list_col = list_col.push(
|
|
container(
|
|
text("No new paired devices found. All paired devices are already added.").size(16)
|
|
)
|
|
.width(Length::Fill)
|
|
);
|
|
}
|
|
scrollable(list_col)
|
|
.height(Length::Fill)
|
|
.width(Length::Fill)
|
|
}
|
|
]
|
|
)
|
|
.padding(20)
|
|
.height(Length::Fill)
|
|
.width(Length::Fill)
|
|
}
|
|
};
|
|
|
|
pane_grid::Content::new(content)
|
|
}
|
|
}
|
|
})
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.on_resize(20, Message::Resized);
|
|
|
|
container(pane_grid).into()
|
|
}
|
|
|
|
fn theme(&self, _id: window::Id) -> Theme {
|
|
self.selected_theme.into()
|
|
}
|
|
|
|
fn subscription(&self) -> Subscription<Message> {
|
|
window::close_events().map(Message::WindowClosed)
|
|
}
|
|
}
|
|
|
|
async fn wait_for_message(ui_rx: Arc<Mutex<UnboundedReceiver<BluetoothUIMessage>>>) -> Message {
|
|
let mut rx = ui_rx.lock().await;
|
|
match rx.recv().await {
|
|
Some(msg) => Message::BluetoothMessage(msg),
|
|
None => {
|
|
error!("UI message channel closed");
|
|
Message::BluetoothMessage(BluetoothUIMessage::NoOp)
|
|
}
|
|
}
|
|
}
|
|
async fn load_paired_devices() -> HashMap<String, Address> {
|
|
let mut devices = HashMap::new();
|
|
|
|
let session = Session::new().await.ok().unwrap();
|
|
let adapter = session.default_adapter().await.ok().unwrap();
|
|
let addresses = adapter.device_addresses().await.ok().unwrap();
|
|
for addr in addresses {
|
|
let device = adapter.device(addr).ok().unwrap();
|
|
let paired = device.is_paired().await.ok().unwrap();
|
|
if paired {
|
|
let name = device
|
|
.name()
|
|
.await
|
|
.ok()
|
|
.flatten()
|
|
.unwrap_or_else(|| "Unknown".to_string());
|
|
devices.insert(name, addr);
|
|
}
|
|
}
|
|
|
|
devices
|
|
}
|