Add audio controls and focused hotkeys

This commit is contained in:
2026-06-16 17:23:38 -04:00
parent 22f0eed94d
commit 20643a24de
12 changed files with 1341 additions and 59 deletions
+404 -25
View File
@@ -1,8 +1,10 @@
use crate::core::{CoreController, messages::{CoreCommand, UiEvent}};
use crate::network::PeerState;
use crate::notify::{self, Sound};
use crate::audio::eq::{EqSettings, EQ_GAIN_DB_MAX, EQ_GAIN_DB_MIN};
use crate::audio::pw_cli::{AudioDevice, enumerate_audio_devices};
use crate::config::{AppConfig, NetworkMode, RecordingMode, RoomLayout};
use crate::hotkeys::{format_binding, HotkeyAction, HotkeyContext, KeyBinding};
use crate::presence::PresenceMode;
use crate::theme::{AppTheme, Palette};
@@ -62,6 +64,13 @@ pub enum DividerKind {
ChatDrawer,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EqBand {
Low,
Mid,
High,
}
/// Minimum width of the Participants panel (px).
const PARTICIPANTS_MIN_W: f32 = 200.0;
/// Minimum width reserved for the Controls panel when resizing Participants (px).
@@ -121,8 +130,11 @@ pub enum AppMessage {
/// Copy an arbitrary string to the clipboard (e.g. the full node ID).
CopyText(String),
TogglePtt(bool),
StartSettingHotkey,
StartHotkeyCapture(HotkeyAction),
ClearHotkey(HotkeyAction),
PeerVolumeChanged(EndpointId, f32),
PeerPanChanged(EndpointId, f32),
PeerEqChanged(EndpointId, EqBand, f32),
/// Toggle local mute of a peer (silence them just for us).
TogglePeerMute(EndpointId),
InputDeviceSelected(AudioDevice),
@@ -186,6 +198,9 @@ pub enum AppMessage {
/// Open / close the room-layout picker popup.
OpenLayoutPicker,
CloseLayoutPicker,
/// Open / close the live hotkey reference popup.
OpenHotkeyInfo,
CloseHotkeyInfo,
/// Open / close the "screen sharing needs pixelpass" explainer popup (A11).
OpenPixelpassHelp,
ClosePixelpassHelp,
@@ -235,8 +250,7 @@ pub struct AppState {
is_deafened: bool,
ptt_enabled: bool,
ptt_active: bool,
ptt_hotkey: keyboard::Key,
is_setting_hotkey: bool,
hotkey_capture: Option<HotkeyAction>,
input_devices: Vec<AudioDevice>,
output_devices: Vec<AudioDevice>,
selected_input: Option<AudioDevice>,
@@ -261,6 +275,8 @@ pub struct AppState {
window_size: Size,
/// Whether the room-layout picker popup is open (launch + in-call screens).
layout_picker_open: bool,
/// Whether the hotkey reference popup is open.
hotkey_info_open: bool,
/// Whether the pixelpass screen-share explainer popup is open (A11).
pixelpass_help_open: bool,
/// Whether the Chat drawer is open (drawer layout only).
@@ -353,6 +369,16 @@ impl Default for AppState {
let _ = controller.send(CoreCommand::SetRecordingMode(config.recording_mode));
let _ = controller.send(CoreCommand::SetPixelpassPath(config.pixelpass_path.clone()));
let _ = controller.send(CoreCommand::SetPresenceMode(config.presence_mode));
for (peer, settings) in &config.peer_eq {
if let Ok(id) = peer.parse::<EndpointId>() {
let _ = controller.send(CoreCommand::SetPeerEq(id, *settings));
}
}
for (peer, pan) in &config.peer_pan {
if let Ok(id) = peer.parse::<EndpointId>() {
let _ = controller.send(CoreCommand::SetPeerPan(id, *pan));
}
}
let pixelpass_available =
crate::screenshare::is_available(config.pixelpass_path.as_deref());
let all_devices = enumerate_audio_devices();
@@ -375,8 +401,7 @@ impl Default for AppState {
is_deafened: false,
ptt_enabled: false,
ptt_active: false,
ptt_hotkey: keyboard::Key::Named(keyboard::key::Named::Space),
is_setting_hotkey: false,
hotkey_capture: None,
input_devices,
output_devices,
selected_input,
@@ -393,6 +418,7 @@ impl Default for AppState {
chat_input: String::new(),
window_size: Size::new(ww, wh),
layout_picker_open: false,
hotkey_info_open: false,
pixelpass_help_open: false,
drawer_chat_open: false,
mic_level: 0.0,
@@ -526,6 +552,105 @@ fn reconnected_chime(
was_reconnect.then_some(Sound::Reconnected)
}
fn in_call(state: &AppState) -> bool {
!state.ticket.is_empty()
}
fn toggle_mute(state: &mut AppState) {
if !in_call(state) {
return;
}
let _ = state.controller.send(CoreCommand::ToggleMute);
state.is_muted = !state.is_muted;
notify::play(Sound::MicToggle, state.config.custom_sound_mic_toggle.as_deref());
}
fn toggle_deafen(state: &mut AppState) {
if !in_call(state) {
return;
}
let _ = state.controller.send(CoreCommand::ToggleDeafen);
state.is_deafened = !state.is_deafened;
notify::play(Sound::MicToggle, state.config.custom_sound_mic_toggle.as_deref());
}
fn handle_hotkey_pressed(state: &mut AppState, action: HotkeyAction) {
match action {
HotkeyAction::ToggleMute => toggle_mute(state),
HotkeyAction::ToggleDeafen => toggle_deafen(state),
HotkeyAction::OpenSettings => {
state.current_screen = Screen::Settings;
state.hotkey_info_open = false;
state.layout_picker_open = false;
}
HotkeyAction::PushToTalk => {
if in_call(state) && state.ptt_enabled && !state.ptt_active {
state.ptt_active = true;
let _ = state.controller.send(CoreCommand::SetPttActive(true));
}
}
HotkeyAction::LeaveRoom => {
if in_call(state) {
let _ = state.controller.send(CoreCommand::Leave);
}
}
}
}
fn set_peer_pan_config(config: &mut AppConfig, id: EndpointId, pan: f32) -> f32 {
let pan = pan.clamp(-1.0, 1.0);
let key = id.to_string();
if pan.abs() <= 0.001 {
config.peer_pan.remove(&key);
} else {
config.peer_pan.insert(key, pan);
}
pan
}
fn peer_eq_settings(config: &AppConfig, id: &EndpointId) -> EqSettings {
config
.peer_eq
.get(&id.to_string())
.copied()
.unwrap_or_default()
.clamped()
}
fn set_peer_eq_config(
config: &mut AppConfig,
id: EndpointId,
band: EqBand,
gain_db: f32,
) -> EqSettings {
let key = id.to_string();
let mut settings = config.peer_eq.get(&key).copied().unwrap_or_default();
let gain_db = gain_db.clamp(EQ_GAIN_DB_MIN, EQ_GAIN_DB_MAX);
match band {
EqBand::Low => settings.low_gain_db = gain_db,
EqBand::Mid => settings.mid_gain_db = gain_db,
EqBand::High => settings.high_gain_db = gain_db,
}
settings = settings.clamped();
if settings.is_flat() {
config.peer_eq.remove(&key);
} else {
config.peer_eq.insert(key, settings);
}
settings
}
fn pan_label(pan: f32) -> String {
let pan = pan.clamp(-1.0, 1.0);
if pan.abs() <= 0.01 {
"Center".to_string()
} else if pan < 0.0 {
format!("L {:.0}%", pan.abs() * 100.0)
} else {
format!("R {:.0}%", pan * 100.0)
}
}
fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
match message {
AppMessage::NicknameChanged(val) => {
@@ -593,14 +718,10 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.status_message = "Opening screen share…".to_string();
}
AppMessage::ToggleMutePressed => {
let _ = state.controller.send(CoreCommand::ToggleMute);
state.is_muted = !state.is_muted;
notify::play(Sound::MicToggle, state.config.custom_sound_mic_toggle.as_deref());
toggle_mute(state);
}
AppMessage::ToggleDeafenPressed => {
let _ = state.controller.send(CoreCommand::ToggleDeafen);
state.is_deafened = !state.is_deafened;
notify::play(Sound::MicToggle, state.config.custom_sound_mic_toggle.as_deref());
toggle_deafen(state);
}
AppMessage::UiEventReceived(event) => {
match event {
@@ -761,13 +882,26 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.ptt_enabled = enabled;
let _ = state.controller.send(CoreCommand::SetPttMode(enabled));
}
AppMessage::StartSettingHotkey => {
state.is_setting_hotkey = true;
AppMessage::StartHotkeyCapture(action) => {
state.hotkey_capture = Some(action);
state.hotkey_info_open = false;
}
AppMessage::ClearHotkey(action) => {
state.config.hotkeys.set_binding(action, None);
state.config.save();
}
AppMessage::PeerVolumeChanged(id, vol) => {
state.peer_volumes.insert(id, vol);
let _ = state.controller.send(CoreCommand::SetPeerVolume(id, vol));
}
AppMessage::PeerPanChanged(id, pan) => {
let pan = set_peer_pan_config(&mut state.config, id, pan);
let _ = state.controller.send(CoreCommand::SetPeerPan(id, pan));
}
AppMessage::PeerEqChanged(id, band, gain_db) => {
let settings = set_peer_eq_config(&mut state.config, id, band, gain_db);
let _ = state.controller.send(CoreCommand::SetPeerEq(id, settings));
}
AppMessage::TogglePeerMute(id) => {
let now_muted = if state.locally_muted.contains(&id) {
state.locally_muted.remove(&id);
@@ -1021,10 +1155,18 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
}
AppMessage::OpenLayoutPicker => {
state.layout_picker_open = true;
state.hotkey_info_open = false;
}
AppMessage::CloseLayoutPicker => {
state.layout_picker_open = false;
}
AppMessage::OpenHotkeyInfo => {
state.hotkey_info_open = true;
state.layout_picker_open = false;
}
AppMessage::CloseHotkeyInfo => {
state.hotkey_info_open = false;
}
AppMessage::OpenPixelpassHelp => {
state.pixelpass_help_open = true;
}
@@ -1119,16 +1261,30 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
.send(CoreCommand::SetMicMonitor { enabled, input_device });
}
AppMessage::EventOccurred(Event::Keyboard(keyboard::Event::KeyPressed { key, .. })) => {
if state.is_setting_hotkey {
state.ptt_hotkey = key.clone();
state.is_setting_hotkey = false;
} else if state.ptt_enabled && key == state.ptt_hotkey && !state.ptt_active {
state.ptt_active = true;
let _ = state.controller.send(CoreCommand::SetPttActive(true));
if let Some(action) = state.hotkey_capture.take() {
if let Some(binding) = KeyBinding::from_key(&key) {
state.config.hotkeys.set_binding(action, Some(binding));
state.config.save();
} else {
state.hotkey_capture = Some(action);
}
} else if let Some(action) = state
.config
.hotkeys
.lookup_key(&key, HotkeyContext { in_call: in_call(state) })
{
handle_hotkey_pressed(state, action);
}
}
AppMessage::EventOccurred(Event::Keyboard(keyboard::Event::KeyReleased { key, .. })) => {
if state.ptt_enabled && key == state.ptt_hotkey && state.ptt_active {
if state
.config
.hotkeys
.lookup_key(&key, HotkeyContext { in_call: in_call(state) })
== Some(HotkeyAction::PushToTalk)
&& state.ptt_enabled
&& state.ptt_active
{
state.ptt_active = false;
let _ = state.controller.send(CoreCommand::SetPttActive(false));
}
@@ -1171,9 +1327,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
AppMessage::EventOccurred(_) => {}
AppMessage::NavigateToSettings => {
state.current_screen = Screen::Settings;
state.layout_picker_open = false;
state.hotkey_info_open = false;
}
AppMessage::NavigateBack => {
state.config.save();
state.hotkey_capture = None;
// Release the mic when leaving Settings if the test was running.
if state.mic_test_active {
state.mic_test_active = false;
@@ -1752,6 +1911,17 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
let top_bar = row![
horizontal_space(),
tooltip(
button(icon(IconKind::Info, 18.0, color_text))
.on_press(AppMessage::OpenHotkeyInfo)
.style(b_style(color_surface, color_blue, color_text, 6.0))
.padding(8),
container(text("Hotkeys").size(11).color(color_text))
.padding(8)
.style(c_style(color_crust, color_surface, 6.0)),
iced::widget::tooltip::Position::Bottom,
)
.gap(8),
tooltip(
button(
Canvas::new(LayoutIcon { fg: color_text })
@@ -2039,6 +2209,72 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.into()
};
let mut hotkey_rows = Column::new().spacing(8).width(iced::Length::Fill);
for action in HotkeyAction::ALL {
let capturing = state.hotkey_capture == Some(action);
let binding = if capturing {
"Press a key...".to_string()
} else {
format_binding(state.config.hotkeys.binding(action))
};
hotkey_rows = hotkey_rows.push(
row![
column![
text(action.label()).size(13).color(color_text),
text(match action.tier() {
crate::hotkeys::HotkeyTier::AppWide => "App-wide",
crate::hotkeys::HotkeyTier::RoomOnly => "Room-only",
})
.size(10)
.color(color_subtext),
]
.spacing(2)
.width(iced::Length::Fill),
container(text(binding).size(12).color(if capturing { color_yellow } else { color_subtext }))
.width(iced::Length::Fixed(110.0))
.align_x(iced::alignment::Horizontal::Right),
button(text("Set").size(12))
.on_press(AppMessage::StartHotkeyCapture(action))
.style(b_style(color_surface, color_blue, color_text, 6.0))
.padding(6),
button(text("Clear").size(12))
.on_press(AppMessage::ClearHotkey(action))
.style(b_style(color_surface, color_red, color_text, 6.0))
.padding(6),
]
.spacing(10)
.align_y(iced::alignment::Vertical::Center),
);
}
let hotkey_conflicts = state.config.hotkeys.conflicts();
let conflict_block: Element<'_, AppMessage> = if hotkey_conflicts.is_empty() {
vertical_space(0.0).into()
} else {
let mut lines = Column::new().spacing(4);
for conflict in hotkey_conflicts {
lines = lines.push(
text(format!(
"Conflict: {} is assigned to {} and {}.",
conflict.binding.label(),
conflict.first.label(),
conflict.second.label()
))
.size(11)
.color(color_red),
);
}
lines.into()
};
let hotkey_section = column![
hotkey_rows,
conflict_block,
text("Shortcuts work only while the PeerSpeak window has focus. Unset actions are ignored.")
.size(11)
.color(color_subtext),
]
.spacing(8)
.width(iced::Length::Fill);
// --- Identity (W7) ---
// Your persistent node id + a Regenerate control. When the key isn't
// persisted (disk/permission failure → ephemeral fallback) we show a
@@ -2163,6 +2399,11 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
].spacing(8).width(iced::Length::Fill),
vertical_space(section_gap),
// --- Hotkeys ---
section_header("Hotkeys"),
hotkey_section,
vertical_space(section_gap),
// --- Recording ---
section_header("Recording"),
column![
@@ -2345,7 +2586,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.height(iced::Length::Fill)
.style(c_style(color_crust, Color::TRANSPARENT, 0.0));
with_layout_picker(home.into(), state)
with_hotkey_info(with_layout_picker(home.into(), state), state)
} else {
// --- ROOM SCREEN ---
let participant_count = state.peers.len() + 1; // peers + you
@@ -2643,6 +2884,46 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
].spacing(8).align_y(iced::alignment::Vertical::Center)
);
let peer_key = peer_id.to_string();
let current_pan = state.config.peer_pan.get(&peer_key).copied().unwrap_or(0.0);
card_content = card_content.push(
row![
text("Pan:").size(12).color(color_subtext),
container(text(pan_label(current_pan)).size(11).color(color_subtext))
.width(iced::Length::Fixed(58.0)),
slider(-1.0..=1.0, current_pan, move |v| AppMessage::PeerPanChanged(peer_id_clone, v))
.step(0.05)
.on_release(AppMessage::PersistConfig),
]
.spacing(8)
.align_y(iced::alignment::Vertical::Center),
);
let eq = peer_eq_settings(&state.config, peer_id);
let eq_row = |label: &'static str, band: EqBand, value: f32| -> Element<'_, AppMessage> {
row![
container(text(format!("{label} {value:+.1} dB")).size(11).color(color_subtext))
.width(iced::Length::Fixed(86.0)),
slider(EQ_GAIN_DB_MIN..=EQ_GAIN_DB_MAX, value, move |v| {
AppMessage::PeerEqChanged(peer_id_clone, band, v)
})
.step(0.5)
.on_release(AppMessage::PersistConfig),
]
.spacing(8)
.align_y(iced::alignment::Vertical::Center)
.into()
};
card_content = card_content.push(
column![
text("EQ").size(11).color(color_subtext),
eq_row("Low", EqBand::Low, eq.low_gain_db),
eq_row("Mid", EqBand::Mid, eq.mid_gain_db),
eq_row("High", EqBand::High, eq.high_gain_db),
]
.spacing(4),
);
let card = container(card_content)
.style(c_style(
if is_speaking { color_base } else { color_mantle },
@@ -2696,10 +2977,15 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
checkbox(state.ptt_enabled).label("Push-to-Talk").on_toggle(AppMessage::TogglePtt),
vertical_space(10.0),
if state.ptt_enabled {
let ptt_binding = if state.hotkey_capture == Some(HotkeyAction::PushToTalk) {
"Press a key...".to_string()
} else {
format_binding(state.config.hotkeys.binding(HotkeyAction::PushToTalk))
};
column![
text(format!("Hotkey: {}", if state.is_setting_hotkey { "Press any key...".to_string() } else { format!("{:?}", state.ptt_hotkey) })).size(14).color(color_subtext),
button(text("Set Hotkey").size(12).align_x(iced::alignment::Horizontal::Center))
.on_press(AppMessage::StartSettingHotkey)
text(format!("PTT key: {ptt_binding}")).size(14).color(color_subtext),
button(text("Set PTT Key").size(12).align_x(iced::alignment::Horizontal::Center))
.on_press(AppMessage::StartHotkeyCapture(HotkeyAction::PushToTalk))
.style(b_style(color_surface, color_blue, color_text, 6.0))
.padding(8)
.width(iced::Length::Fill)
@@ -2987,7 +3273,10 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.height(iced::Length::Fill)
.style(c_style(color_crust, Color::TRANSPARENT, 0.0));
with_pixelpass_help(with_layout_picker(room.into(), state), state)
with_hotkey_info(
with_pixelpass_help(with_layout_picker(room.into(), state), state),
state,
)
}
}
@@ -3357,6 +3646,90 @@ fn with_layout_picker<'a>(
.into()
}
/// Overlay the live hotkey reference from the top-right info button. It reads
/// directly from config, so Settings edits are reflected immediately.
fn with_hotkey_info<'a>(
base: Element<'a, AppMessage>,
state: &'a AppState,
) -> Element<'a, AppMessage> {
if !state.hotkey_info_open {
return base;
}
let pal = state.config.theme.palette();
let crust = pal.crust;
let mantle = pal.mantle;
let surface = pal.surface;
let text_c = pal.text;
let subtext = pal.subtext;
let blue = pal.blue;
let backdrop = mouse_area(
container(horizontal_space())
.width(iced::Length::Fill)
.height(iced::Length::Fill)
.style(move |_t: &Theme| container::Style {
background: Some(Background::Color(Color { a: 0.25, ..crust })),
..Default::default()
}),
)
.on_press(AppMessage::CloseHotkeyInfo);
let mut rows = Column::new().spacing(8).width(iced::Length::Fill);
for action in HotkeyAction::ALL {
rows = rows.push(
row![
text(action.label()).size(12).color(text_c),
horizontal_space(),
text(format_binding(state.config.hotkeys.binding(action)))
.size(12)
.color(subtext),
]
.spacing(12)
.align_y(iced::alignment::Vertical::Center),
);
}
let dialog = container(
column![
row![
text("Hotkeys").size(16).color(blue),
horizontal_space(),
button(text("").size(16).color(subtext))
.on_press(AppMessage::CloseHotkeyInfo)
.style(|_t: &Theme, _s: button::Status| button::Style {
background: None,
..Default::default()
})
.padding(2),
]
.align_y(iced::alignment::Vertical::Center),
rows,
]
.spacing(14),
)
.style(move |_t: &Theme| container::Style {
text_color: Some(text_c),
background: Some(Background::Color(mantle)),
border: Border { color: surface, width: 1.0, radius: 8.0.into() },
..Default::default()
})
.padding(16)
.width(iced::Length::Fixed(320.0));
stack![
base,
backdrop,
container(column![
vertical_space(48.0),
row![horizontal_space(), dialog].width(iced::Length::Fill),
])
.width(iced::Length::Fill)
.height(iced::Length::Fill)
.padding(12),
]
.into()
}
/// Overlays the "screen sharing needs pixelpass" explainer popup over `base`
/// when open (A11). Triggered by the Share Screen / Watch controls when the
/// optional `pixelpass` companion isn't installed, so those controls open a
@@ -3725,6 +4098,7 @@ enum IconKind {
Chat,
People,
Clock,
Info,
Settings,
Copy,
Leave,
@@ -3994,6 +4368,11 @@ impl Program<AppMessage> for Icon {
f.stroke(&poly(&[(12.0, 7.0), (12.0, 12.0)], false), stk());
f.stroke(&poly(&[(12.0, 12.0), (15.5, 14.0)], false), stk());
}
IconKind::Info => {
f.stroke(&Path::circle(p(12.0, 12.0), 8.5 * s), stk());
f.stroke(&poly(&[(12.0, 10.5), (12.0, 17.0)], false), stk());
f.fill(&Path::circle(p(12.0, 7.0), 1.1 * s), col);
}
IconKind::Settings => {
f.stroke(&poly(&[(4.0, 7.0), (20.0, 7.0)], false), stk());
f.stroke(&poly(&[(4.0, 12.0), (20.0, 12.0)], false), stk());