From 02d4e3abeb20389420cddac12c214f2301b5e114 Mon Sep 17 00:00:00 2001 From: Iason Paraskevopoulos Date: Tue, 18 Aug 2026 16:38:01 +0100 Subject: [PATCH 1/3] feat: add call controls to the screen-sharing window Adds the call controls to the screen-sharing window. Also reduces the size of the header in the same window to increase a bit the height of the stream. --- core/src/components/call_controls.rs | 347 ++++++++++++++++++++++++ core/src/components/mod.rs | 1 + core/src/components/split_button.rs | 60 +++- core/src/lib.rs | 107 +++++--- core/src/room_service.rs | 22 +- core/src/window/aspect_ratio.rs | 2 +- core/src/window/camera_window.rs | 314 +++------------------ core/src/window/screensharing_window.rs | 212 ++++++++++++--- 8 files changed, 681 insertions(+), 384 deletions(-) create mode 100644 core/src/components/call_controls.rs diff --git a/core/src/components/call_controls.rs b/core/src/components/call_controls.rs new file mode 100644 index 00000000..170bdc0b --- /dev/null +++ b/core/src/components/call_controls.rs @@ -0,0 +1,347 @@ +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; + +use iced::widget::row; +use iced::{Element, Theme}; +use socket_lib::{AudioCaptureMessage, CameraStartMessage}; +use winit::event_loop::EventLoopProxy; + +use crate::audio::capturer::list_audio_inputs; +use crate::camera::capturer::CameraCapturer; +use crate::components::split_button::{ + split_button_dropdown_wrap, split_button_sized, SplitButtonItem, SplitButtonSize, +}; +use crate::livekit::participant::ParticipantInfo; +use crate::windows::colors::ColorToken; +use crate::UserEvent; + +const ICON_MICROPHONE_ON: char = '\u{F105}'; +const ICON_MICROPHONE_OFF: char = '\u{F106}'; +const ICON_SCREEN_SHARE: char = '\u{F102}'; +const ICON_VIDEO: char = '\u{F101}'; +const ICON_PHONE_OFF: char = '\u{F103}'; + +#[derive(Debug, Clone, Copy)] +pub enum CallControlsDensity { + Regular, + Compact, +} + +impl CallControlsDensity { + const fn button_size(self) -> SplitButtonSize { + match self { + Self::Regular => SplitButtonSize::regular(), + Self::Compact => SplitButtonSize::compact(), + } + } + + const fn spacing(self) -> f32 { + match self { + Self::Regular => 8.0, + Self::Compact => 4.0, + } + } + + pub const fn total_width(self) -> f32 { + match self { + Self::Regular => 237.0, + Self::Compact => 189.0, + } + } + + const fn camera_dropdown_tail(self) -> f32 { + match self { + Self::Regular => 111.0, + Self::Compact => 87.0, + } + } + + const fn mic_dropdown_tail(self) -> f32 { + match self { + Self::Regular => 178.0, + Self::Compact => 140.0, + } + } +} + +#[derive(Debug, Clone)] +pub enum CallControlsMessage { + MicToggle, + MicDropdownToggle, + MicDropdownDismiss, + SelectMic(String), + VideoToggle, + CameraDropdownToggle, + CameraDropdownDismiss, + SelectCamera(String), + ScreenShare, + OpenScreenSharePicker, + EndCall, +} + +#[derive(Default)] +pub struct CallControlsState { + camera_active: bool, + camera_dropdown_open: bool, + available_cameras: Vec, + selected_camera_name: Option, + mic_dropdown_open: bool, + available_mics: Vec, + selected_mic_name: Option, +} + +impl CallControlsState { + pub fn new( + camera_active: bool, + selected_camera_name: Option, + selected_mic_name: Option, + ) -> Self { + Self { + camera_active, + selected_camera_name, + selected_mic_name, + ..Self::default() + } + } + + pub fn set_camera_active(&mut self, active: bool, device_name: Option) { + self.camera_active = active; + if active { + self.selected_camera_name = device_name; + } + } + + pub fn set_selected_mic_name(&mut self, name: Option) { + self.selected_mic_name = name; + } + + pub fn dismiss_dropdowns(&mut self) { + self.camera_dropdown_open = false; + self.mic_dropdown_open = false; + } + + pub fn has_open_dropdown(&self) -> bool { + self.camera_dropdown_open || self.mic_dropdown_open + } + + pub fn view<'a>( + &'a self, + participants: &'a Arc>>, + density: CallControlsDensity, + ) -> Element<'a, CallControlsMessage, Theme, iced::Renderer> { + let (is_muted, is_screensharing) = participants + .read() + .ok() + .and_then(|participants| { + participants + .get("local") + .map(|local| (local.muted(), local.is_screensharing())) + }) + .unwrap_or((false, false)); + let size = density.button_size(); + + let mic = split_button_sized( + if is_muted { + ICON_MICROPHONE_OFF + } else { + ICON_MICROPHONE_ON + }, + if is_muted { + ColorToken::Gray400.to_color() + } else { + ColorToken::Orange400.to_color() + }, + CallControlsMessage::MicToggle, + Some(CallControlsMessage::MicDropdownToggle), + self.mic_dropdown_open, + size, + ); + let video = split_button_sized( + ICON_VIDEO, + if self.camera_active { + ColorToken::Green400.to_color() + } else { + ColorToken::Gray400.to_color() + }, + CallControlsMessage::VideoToggle, + Some(CallControlsMessage::CameraDropdownToggle), + self.camera_dropdown_open, + size, + ); + let screen = split_button_sized( + ICON_SCREEN_SHARE, + if is_screensharing { + ColorToken::Green400.to_color() + } else { + ColorToken::Gray400.to_color() + }, + CallControlsMessage::ScreenShare, + Some(CallControlsMessage::OpenScreenSharePicker), + false, + size, + ); + let end_call = split_button_sized( + ICON_PHONE_OFF, + ColorToken::Red500.to_color(), + CallControlsMessage::EndCall, + None, + false, + size, + ); + + row![mic, video, screen, end_call] + .spacing(density.spacing()) + .into() + } + + pub fn wrap_dropdown<'a, Message, Map>( + &'a self, + base: Element<'a, Message, Theme, iced::Renderer>, + map: Map, + density: CallControlsDensity, + top_offset: f32, + trailing_padding: f32, + ) -> Element<'a, Message, Theme, iced::Renderer> + where + Message: Clone + 'a, + Map: Fn(CallControlsMessage) -> Message + Copy + 'a, + { + if self.camera_dropdown_open { + let items: Vec = self + .available_cameras + .iter() + .map(|camera| SplitButtonItem { + label: camera.name.clone(), + selected: self + .selected_camera_name + .as_ref() + .map_or(camera.default, |selected| selected == &camera.name), + }) + .collect(); + split_button_dropdown_wrap( + base, + &items, + map(CallControlsMessage::CameraDropdownDismiss), + move |index| { + map(CallControlsMessage::SelectCamera( + self.available_cameras[index].name.clone(), + )) + }, + top_offset, + trailing_padding + density.camera_dropdown_tail(), + ) + } else if self.mic_dropdown_open { + let items: Vec = self + .available_mics + .iter() + .map(|mic| SplitButtonItem { + label: mic.name.clone(), + selected: self + .selected_mic_name + .as_ref() + .map_or(mic.default, |selected| selected == &mic.name), + }) + .collect(); + split_button_dropdown_wrap( + base, + &items, + map(CallControlsMessage::MicDropdownDismiss), + move |index| { + map(CallControlsMessage::SelectMic( + self.available_mics[index].name.clone(), + )) + }, + top_offset, + trailing_padding + density.mic_dropdown_tail(), + ) + } else { + base + } + } + + pub fn update( + &mut self, + message: CallControlsMessage, + participants: &Arc>>, + event_loop_proxy: &EventLoopProxy, + ) { + let send = |event| { + if let Err(error) = event_loop_proxy.send_event(event) { + log::error!("CallControls: failed to send event: {error:?}"); + } + }; + + match message { + CallControlsMessage::MicToggle => { + let muted = participants + .read() + .ok() + .and_then(|participants| participants.get("local").map(ParticipantInfo::muted)) + .unwrap_or(false); + send(if muted { + UserEvent::UnmuteAudio + } else { + UserEvent::MuteAudio + }); + } + CallControlsMessage::MicDropdownToggle => { + self.camera_dropdown_open = false; + if !self.mic_dropdown_open { + self.available_mics = list_audio_inputs(); + } + self.mic_dropdown_open = !self.mic_dropdown_open; + } + CallControlsMessage::MicDropdownDismiss => self.mic_dropdown_open = false, + CallControlsMessage::SelectMic(name) => { + self.mic_dropdown_open = false; + send(UserEvent::StartAudioCapture { + msg: AudioCaptureMessage { device_name: name }, + from_socket: false, + }); + } + CallControlsMessage::VideoToggle => send(if self.camera_active { + UserEvent::StopCamera + } else { + UserEvent::StartCamera { + msg: CameraStartMessage { device_name: None }, + from_socket: false, + } + }), + CallControlsMessage::CameraDropdownToggle => { + self.mic_dropdown_open = false; + if !self.camera_dropdown_open { + self.available_cameras = CameraCapturer::list_devices(); + } + self.camera_dropdown_open = !self.camera_dropdown_open; + } + CallControlsMessage::CameraDropdownDismiss => self.camera_dropdown_open = false, + CallControlsMessage::SelectCamera(name) => { + self.camera_dropdown_open = false; + send(UserEvent::StartCamera { + msg: CameraStartMessage { + device_name: Some(name), + }, + from_socket: false, + }); + } + CallControlsMessage::ScreenShare => { + let active = participants + .read() + .ok() + .and_then(|participants| { + participants + .get("local") + .map(ParticipantInfo::is_screensharing) + }) + .unwrap_or(false); + send(if active { + UserEvent::StopScreenShare + } else { + UserEvent::GetAvailableContent + }); + } + CallControlsMessage::OpenScreenSharePicker => send(UserEvent::GetAvailableContent), + CallControlsMessage::EndCall => send(UserEvent::CallEnd), + } + } +} diff --git a/core/src/components/mod.rs b/core/src/components/mod.rs index dffcc399..ed744f03 100644 --- a/core/src/components/mod.rs +++ b/core/src/components/mod.rs @@ -1,3 +1,4 @@ +pub mod call_controls; pub mod dropdown; pub mod fonts; pub mod segmented_control; diff --git a/core/src/components/split_button.rs b/core/src/components/split_button.rs index a7c8d523..7c6cdd15 100644 --- a/core/src/components/split_button.rs +++ b/core/src/components/split_button.rs @@ -17,15 +17,47 @@ pub struct SplitButtonItem { pub selected: bool, } -/// Build the split button element. -/// Accepts icon as char (icon font). Returns just the button. -/// `dropdown_open` highlights the chevron when the dropdown is visible. -pub fn split_button<'a, Message: Clone + 'a>( +#[derive(Debug, Clone, Copy)] +pub struct SplitButtonSize { + main_width: f32, + dropdown_width: f32, + hit_height: f32, + inset: f32, + icon_size: f32, + chevron_size: f32, +} + +impl SplitButtonSize { + pub const fn regular() -> Self { + Self { + main_width: 32.0, + dropdown_width: 22.0, + hit_height: 22.0, + inset: 2.0, + icon_size: 16.0, + chevron_size: 14.0, + } + } + + pub const fn compact() -> Self { + Self { + main_width: 26.0, + dropdown_width: 18.0, + hit_height: 22.0, + inset: 2.0, + icon_size: 14.0, + chevron_size: 12.0, + } + } +} + +pub fn split_button_sized<'a, Message: Clone + 'a>( icon_char: char, bg: Color, on_main_press: Message, on_dropdown_toggle: Option, dropdown_open: bool, + size: SplitButtonSize, ) -> iced::Element<'a, Message, Theme, iced::Renderer> { let hover_bg = { let c = ColorToken::Gray600.to_color(); @@ -34,7 +66,7 @@ pub fn split_button<'a, Message: Clone + 'a>( let icon = text(icon_char.to_string()) .font(ICONS_FONT) - .size(16.0) + .size(size.icon_size) .color(Color::WHITE) .align_x(Alignment::Center) .align_y(Alignment::Center); @@ -46,8 +78,8 @@ pub fn split_button<'a, Message: Clone + 'a>( .center_x(Length::Fill) .center_y(Length::Fill), ) - .width(Length::Fixed(32.0)) - .height(Length::Fixed(22.0)) + .width(Length::Fixed(size.main_width)) + .height(Length::Fixed(size.hit_height)) .on_press(on_main_press.clone()) .padding(Padding::from([1, 0])) .style(move |_theme: &Theme, status| hit_area_style(status, hover_bg)); @@ -58,7 +90,7 @@ pub fn split_button<'a, Message: Clone + 'a>( if let Some(dropdown_msg) = on_dropdown_toggle { let chevron = text(ICON_CHEVRON_DOWN.to_string()) .font(ICONS_FONT) - .size(14.0) + .size(size.chevron_size) .color(Color::WHITE) .align_x(Alignment::Center) .align_y(Alignment::Center); @@ -70,8 +102,8 @@ pub fn split_button<'a, Message: Clone + 'a>( .center_x(Length::Fill) .center_y(Length::Fill), ) - .width(Length::Fixed(22.0)) - .height(Length::Fixed(22.0)) + .width(Length::Fixed(size.dropdown_width)) + .height(Length::Fixed(size.hit_height)) .on_press(dropdown_msg) .padding(0) .style(move |_theme: &Theme, status| { @@ -87,14 +119,14 @@ pub fn split_button<'a, Message: Clone + 'a>( main_btn.into() }; - let inner_layer = container(inner_row).padding(Padding::new(2.0)); + let inner_layer = container(inner_row).padding(Padding::new(size.inset)); let total_width = if has_dropdown { - 2.0 + 32.0 + 1.0 + 22.0 + 2.0 + size.inset * 2.0 + size.main_width + 1.0 + size.dropdown_width } else { - 2.0 + 32.0 + 2.0 + size.inset * 2.0 + size.main_width }; - let total_height = 2.0 + 22.0 + 2.0; + let total_height = size.inset * 2.0 + size.hit_height; let base_btn = button(Space::new()) .width(Length::Fixed(total_width)) diff --git a/core/src/lib.rs b/core/src/lib.rs index 66cbb271..bdb42871 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -800,10 +800,23 @@ impl<'a> Application<'a> { &mut self, event_loop: &ActiveEventLoop, buffer: Arc, - participants: Vec<(String, String, bool)>, + participants: Arc< + std::sync::RwLock< + std::collections::HashMap, + >, + >, + sharer_identity: Option, redraw_rx: Option>, redraw_tx: Option>, ) { + let selected_camera_name = self + .camera_capturer + .lock() + .unwrap() + .active_device_name() + .map(str::to_owned); + let camera_active = selected_camera_name.is_some(); + let selected_mic_name = self.audio_capturer.active_device_name().map(str::to_owned); let (redraw_rx, redraw_tx) = redraw_rx.zip(redraw_tx).unwrap_or_else(|| { let (tx, rx) = std::sync::mpsc::channel::(); @@ -815,6 +828,10 @@ impl<'a> Application<'a> { ScreensharingWindowConfig { screen_share_buffer: buffer, participants, + sharer_identity, + camera_active, + selected_camera_name, + selected_mic_name, draw_persist: self.controller_draw_persist, last_mode: self.last_mode.clone(), redraw_rx, @@ -836,6 +853,24 @@ impl<'a> Application<'a> { } } + fn set_call_controls_camera(&mut self, active: bool, device_name: Option) { + if let Some(window) = &mut self.camera_window { + window.set_camera_active(active, device_name.clone()); + } + if let Some(window) = &mut self.screensharing_window { + window.set_camera_active(active, device_name); + } + } + + fn set_call_controls_mic(&mut self, name: Option) { + if let Some(window) = &mut self.camera_window { + window.set_selected_mic_name(name.clone()); + } + if let Some(window) = &mut self.screensharing_window { + window.set_selected_mic_name(name); + } + } + fn open_camera_window( &mut self, event_loop: &ActiveEventLoop, @@ -1464,6 +1499,8 @@ impl<'a> ApplicationHandler for Application<'a> { self.stop_mic(); self.audio_player.stop(); self.stop_camera(); + self.set_call_controls_mic(None); + self.set_call_controls_camera(false, None); if let Some(cm) = self.context_manager.as_mut() { if let Some(wm) = self.window_manager.as_mut() { @@ -1708,8 +1745,9 @@ impl<'a> ApplicationHandler for Application<'a> { } log::debug!("user_event: Room service created: {room_service:?}"); let room_service = room_service.unwrap(); + let participants = room_service.participants(); if let Some(cam) = self.camera_window.as_mut() { - cam.set_participants(room_service.participants()); + cam.set_participants(participants.clone()); } self.room_service = Some(room_service); } @@ -1972,13 +2010,9 @@ impl<'a> ApplicationHandler for Application<'a> { } if result.is_ok() { - if let Some(cam) = &mut self.camera_window { - cam.set_selected_mic_name( - self.audio_capturer - .active_device_name() - .map(|s| s.to_string()), - ); - } + let active_mic_name = + self.audio_capturer.active_device_name().map(str::to_owned); + self.set_call_controls_mic(active_mic_name); if !from_socket { if let Some(device_name) = self.audio_capturer.active_device_name() { if let Err(e) = self @@ -2148,10 +2182,6 @@ impl<'a> ApplicationHandler for Application<'a> { capturer.active_device_name().map(|s| s.to_string()) }; - if let Some(cam) = &mut self.camera_window { - cam.set_camera_active(true, actual_name.clone()); - } - if !from_socket { if let Some(name) = &actual_name { if let Err(e) = self.socket.send(Message::ActiveCameraChanged(name.clone())) @@ -2162,13 +2192,12 @@ impl<'a> ApplicationHandler for Application<'a> { } room_service.send_participants_snapshot(); + self.set_call_controls_camera(true, actual_name); } UserEvent::StopCamera => { log::info!("user_event: StopCamera"); self.stop_camera(); - if let Some(cam) = &mut self.camera_window { - cam.set_camera_active(false, None); - } + self.set_call_controls_camera(false, None); if let Some(room_service) = self.room_service.as_ref() { room_service.send_participants_snapshot(); let participants = room_service.participants(); @@ -2193,10 +2222,18 @@ impl<'a> ApplicationHandler for Application<'a> { UserEvent::OpenScreensharing => { log::info!("user_event: OpenScreensharing"); let buffer = Arc::new(crate::livekit::video::VideoBufferManager::default()); - self.open_screensharing_window(event_loop, buffer, Vec::new(), None, None); + self.open_screensharing_window( + event_loop, + buffer, + Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())), + None, + None, + None, + ); } UserEvent::OpenScreenShareWindow { participants, + sharer_identity, redraw_rx, redraw_tx, } => { @@ -2216,7 +2253,8 @@ impl<'a> ApplicationHandler for Application<'a> { if let (Some((rx, tx)), Some(buffer)) = (redraw_rx.zip(redraw_tx), buffer) { screensharing_window.update_window_with_new_sharer( buffer, - &participants, + participants, + sharer_identity.clone(), draw_persist, last_mode, rx, @@ -2231,6 +2269,7 @@ impl<'a> ApplicationHandler for Application<'a> { event_loop, screen_share_buffer, participants, + sharer_identity, redraw_rx, redraw_tx, ); @@ -2306,13 +2345,9 @@ impl<'a> ApplicationHandler for Application<'a> { } UserEvent::DefaultInputDeviceChanged => { self.audio_capturer.handle_default_device_changed(false); - if let Some(cam) = &mut self.camera_window { - cam.set_selected_mic_name( - self.audio_capturer - .active_device_name() - .map(|s| s.to_string()), - ); - } + self.set_call_controls_mic( + self.audio_capturer.active_device_name().map(str::to_owned), + ); if let Some(device_name) = self.audio_capturer.active_device_name() { if let Err(e) = self .socket @@ -2325,13 +2360,9 @@ impl<'a> ApplicationHandler for Application<'a> { UserEvent::AudioCaptureError => { log::warn!("user_event: AudioCaptureError - capture thread died"); self.audio_capturer.handle_default_device_changed(true); - if let Some(cam) = &mut self.camera_window { - cam.set_selected_mic_name( - self.audio_capturer - .active_device_name() - .map(|s| s.to_string()), - ); - } + self.set_call_controls_mic( + self.audio_capturer.active_device_name().map(str::to_owned), + ); if let Some(device_name) = self.audio_capturer.active_device_name() { if let Err(e) = self .socket @@ -3052,7 +3083,12 @@ pub enum UserEvent { OpenCamera, OpenScreensharing, OpenScreenShareWindow { - participants: Vec<(String, String, bool)>, + participants: Arc< + std::sync::RwLock< + std::collections::HashMap, + >, + >, + sharer_identity: Option, redraw_rx: Option< std::sync::Arc< std::sync::Mutex< @@ -3207,7 +3243,10 @@ impl RenderEventLoop { Message::OpenCamera => UserEvent::OpenCamera, Message::OpenScreensharing => UserEvent::OpenScreensharing, Message::OpenScreenShareWindow => UserEvent::OpenScreenShareWindow { - participants: Vec::new(), + participants: Arc::new(std::sync::RwLock::new( + std::collections::HashMap::new(), + )), + sharer_identity: None, redraw_rx: None, redraw_tx: None, }, diff --git a/core/src/room_service.rs b/core/src/room_service.rs index 9bea5340..9ce59638 100644 --- a/core/src/room_service.rs +++ b/core/src/room_service.rs @@ -745,24 +745,6 @@ impl RoomService { } } -fn collect_remote_participants( - participants: &Arc>>, - sharer_identity: &str, -) -> Vec<(String, String, bool)> { - let guard = participants.read().unwrap(); - guard - .iter() - .filter(|(key, _)| *key != "local") - .map(|(identity, info)| { - ( - identity.clone(), - info.name().to_string(), - identity == sharer_identity, - ) - }) - .collect() -} - /// Handles room service commands in an async loop. /// /// This function processes commands sent through the `service_rx` channel and executes @@ -2023,9 +2005,9 @@ fn start_remote_screen_share_stream( snapshot_sender.send_participants_snapshot(); - let remote_participants = collect_remote_participants(participants, &sharer_identity); if let Err(e) = event_loop_proxy.send_event(UserEvent::OpenScreenShareWindow { - participants: remote_participants, + participants: participants.clone(), + sharer_identity: Some(sharer_identity), redraw_rx: Some(redraw_rx), redraw_tx: Some(redraw_tx), }) { diff --git a/core/src/window/aspect_ratio.rs b/core/src/window/aspect_ratio.rs index 0f931dfc..b622e4ad 100644 --- a/core/src/window/aspect_ratio.rs +++ b/core/src/window/aspect_ratio.rs @@ -12,7 +12,7 @@ impl WindowConstant { pub const DEFAULT_WIDTH: f64 = 600.0; pub const MIN_WIDTH: f64 = 500.0; pub const PADDING: f32 = 12.0; - pub const HEADER_HEIGHT: f32 = 42.0; + pub const HEADER_HEIGHT: f32 = 34.0; pub const HEADER_SIDE_PADDING: f32 = 4.0; pub const SKELETON_H: f64 = Self::HEADER_HEIGHT as f64 + Self::PADDING as f64; pub const SKELETON_W: f64 = 2.0 * Self::PADDING as f64; diff --git a/core/src/window/camera_window.rs b/core/src/window/camera_window.rs index 7a1271da..859936bd 100644 --- a/core/src/window/camera_window.rs +++ b/core/src/window/camera_window.rs @@ -38,10 +38,10 @@ use winit::window::{Window, WindowAttributes, WindowId}; use thiserror::Error; -use crate::audio::capturer::list_audio_inputs; -use crate::camera::capturer::CameraCapturer; +use crate::components::call_controls::{ + CallControlsDensity, CallControlsMessage, CallControlsState, +}; use crate::components::fonts::{self as fonts_mod, GEIST_MEDIUM, GEIST_REGULAR, ICONS_FONT}; -use crate::components::split_button::{split_button, split_button_dropdown_wrap, SplitButtonItem}; use crate::components::toast::{self, ToastPosition, ToastState}; use crate::graphics::graphics_window_context::{ ContextManager, GraphicsWindowContext, GraphicsWindowContextError, @@ -52,7 +52,6 @@ use crate::livekit::video::VideoBufferManager; use crate::windows::colors::ColorToken; use crate::windows::shadows::ShadowToken; use crate::UserEvent; -use socket_lib::CameraStartMessage; /// Initial camera window dimensions (logical pixels). const CAMERA_WINDOW_WIDTH: f64 = 1035.0; @@ -122,11 +121,7 @@ const HIDE_NAME_TILE_THRESHOLD: f32 = 150.0; const COMPACT_TOP_INSET: f32 = 24.0; -const ICON_MICROPHONE_ON: char = '\u{F105}'; const ICON_MICROPHONE_OFF: char = '\u{F106}'; -const ICON_SCREEN_SHARE: char = '\u{F102}'; -const ICON_VIDEO: char = '\u{F101}'; -const ICON_PHONE_OFF: char = '\u{F103}'; const ICON_PIN_ANGLE: char = '\u{F10B}'; const PIN_CORNER_WIDTH: f64 = CAMERA_WINDOW_MIN_WIDTH; @@ -155,20 +150,10 @@ pub enum CameraWindowError { #[derive(Debug, Clone)] pub enum CameraMessage { - MicToggle, - ScreenShare, - OpenScreenSharePicker, - VideoToggle, - EndCall, + CallControls(CallControlsMessage), ToggleSelfVisibility, /// Mouse entered or left the local participant tile (for hover-only chrome). LocalTileHover(bool), - CameraDropdownToggle, - CameraDropdownDismiss, - SelectCamera(String), - MicDropdownToggle, - MicDropdownDismiss, - SelectMic(String), PinToCorner, } @@ -176,7 +161,6 @@ struct CameraState { // TODO: why do we keep state for this instead of reusing the viewport_size from the CameraWindow? viewport_size: IcedSize, /// Local camera on/off state, updated from StartCamera/StopCamera handlers. - camera_active: bool, /// When true, the local participant tile is hidden (floating control restores it). self_hidden: bool, /// True while the pointer is over the local tile (show hide-self control). @@ -184,29 +168,16 @@ struct CameraState { /// Window narrower than `COMPACT_WIDTH_THRESHOLD` hides header, name labels, etc. is_compact: bool, toast: Option, - camera_dropdown_open: bool, - available_cameras: Vec, - selected_camera_name: Option, - mic_dropdown_open: bool, - available_mics: Vec, - selected_mic_name: Option, } impl Default for CameraState { fn default() -> Self { Self { viewport_size: IcedSize::new(CAMERA_WINDOW_WIDTH as f32, CAMERA_WINDOW_HEIGHT as f32), - camera_active: false, self_hidden: false, local_tile_hovered: false, is_compact: false, toast: None, - camera_dropdown_open: false, - available_cameras: Vec::new(), - selected_camera_name: None, - mic_dropdown_open: false, - available_mics: Vec::new(), - selected_mic_name: None, } } } @@ -226,6 +197,7 @@ pub struct CameraWindow { cursor: mouse::Cursor, modifiers: ModifiersState, state: CameraState, + call_controls: CallControlsState, participants: Arc>>, event_loop_proxy: EventLoopProxy, redraw_tx: mpsc::Sender, @@ -332,10 +304,9 @@ impl CameraWindow { .unwrap_or(false); let state = CameraState { viewport_size: IcedSize::new(logical.width, logical.height), - camera_active, - selected_mic_name: active_mic_name, ..Default::default() }; + let call_controls = CallControlsState::new(camera_active, None, active_mic_name); let (redraw_tx, redraw_rx) = mpsc::channel(); let redraw_thread = Some(spawn_redraw_thread(redraw_rx, window.clone())); @@ -353,6 +324,7 @@ impl CameraWindow { cursor: mouse::Cursor::Unavailable, modifiers: ModifiersState::default(), state, + call_controls, participants, event_loop_proxy, redraw_tx, @@ -423,10 +395,9 @@ impl CameraWindow { .unwrap_or(false); self.state = CameraState { viewport_size: IcedSize::new(logical.width, logical.height), - camera_active, - selected_mic_name: active_mic_name, ..Default::default() }; + self.call_controls = CallControlsState::new(camera_active, None, active_mic_name); self.last_rendered_frame_ids.clear(); self.screensharing_active = false; self.resize_timer = None; @@ -478,14 +449,11 @@ impl CameraWindow { /// Update the local camera active state. Call from StartCamera/StopCamera handlers. pub fn set_camera_active(&mut self, active: bool, device_name: Option) { - self.state.camera_active = active; - if active { - self.state.selected_camera_name = device_name; - } + self.call_controls.set_camera_active(active, device_name); } pub fn set_selected_mic_name(&mut self, name: Option) { - self.state.selected_mic_name = name; + self.call_controls.set_selected_mic_name(name); } /// Handle a winit `WindowEvent` — forward to iced and manage resize / redraw. @@ -516,7 +484,13 @@ impl CameraWindow { let cache = self.cache.take().unwrap_or_default(); let mut interface = UserInterface::build( - Self::view(&self.state, &self.participants, true, &mut HashMap::new()), + Self::view( + &self.state, + &self.call_controls, + &self.participants, + true, + &mut HashMap::new(), + ), self.viewport.logical_size(), cache, &mut self.renderer, @@ -673,79 +647,14 @@ impl CameraWindow { /// - Responsive participant grid with name labels fn view<'a>( state: &'a CameraState, + call_controls: &'a CallControlsState, participants: &'a Arc>>, outer_skip: bool, last_rendered_frame_ids: &mut HashMap, ) -> iced::Element<'a, CameraMessage, Theme, iced::Renderer> { - // ── Control buttons ──────────────────────────────────────────────── - let is_muted = participants - .read() - .ok() - .and_then(|p| p.get("local").map(|info| info.muted())) - .unwrap_or(false); - - let mic_bg = if is_muted { - ColorToken::Gray400.to_color() - } else { - ColorToken::Orange400.to_color() - }; - let mic_icon = if is_muted { - ICON_MICROPHONE_OFF - } else { - ICON_MICROPHONE_ON - }; - let mic_button = split_button( - mic_icon, - mic_bg, - CameraMessage::MicToggle, - Some(CameraMessage::MicDropdownToggle), - state.mic_dropdown_open, - ); - - let video_bg_color = if state.camera_active { - ColorToken::Green400.to_color() - } else { - ColorToken::Gray400.to_color() - }; - let video_button = split_button( - ICON_VIDEO, - video_bg_color, - CameraMessage::VideoToggle, - Some(CameraMessage::CameraDropdownToggle), - state.camera_dropdown_open, - ); - - let is_screensharing = participants - .read() - .ok() - .and_then(|participants| { - participants - .get("local") - .map(ParticipantInfo::is_screensharing) - }) - .unwrap_or(false); - let screen_bg_color = if is_screensharing { - ColorToken::Green400.to_color() - } else { - ColorToken::Gray400.to_color() - }; - let screen_button = split_button( - ICON_SCREEN_SHARE, - screen_bg_color, - CameraMessage::ScreenShare, - Some(CameraMessage::OpenScreenSharePicker), - false, - ); - - let end_call_button = split_button( - ICON_PHONE_OFF, - ColorToken::Red500.to_color(), - CameraMessage::EndCall, - None, - false, - ); - - let controls = row![mic_button, video_button, screen_button, end_call_button].spacing(8); + let controls = call_controls + .view(participants, CallControlsDensity::Regular) + .map(CameraMessage::CallControls); // ── Header with centered controls (space for native traffic lights) let header = row![ @@ -811,58 +720,15 @@ impl CameraWindow { }) .clip(true); - let base: iced::Element<'a, CameraMessage, Theme, iced::Renderer> = - if state.camera_dropdown_open { - let items: Vec = state - .available_cameras - .iter() - .map(|cam| { - let is_selected = match &state.selected_camera_name { - Some(name) => name == &cam.name, - None => cam.default, - }; - SplitButtonItem { - label: cam.name.clone(), - selected: is_selected, - } - }) - .collect(); - - split_button_dropdown_wrap( - base_inner.into(), - &items, - CameraMessage::CameraDropdownDismiss, - |i| CameraMessage::SelectCamera(state.available_cameras[i].name.clone()), - HEADER_HEIGHT + 2.0, - state.viewport_size.width / 2.0 - 13.0, - ) - } else if state.mic_dropdown_open { - let items: Vec = state - .available_mics - .iter() - .map(|dev| { - let is_selected = match &state.selected_mic_name { - Some(sel) => sel == &dev.name, - None => dev.default, - }; - SplitButtonItem { - label: dev.name.clone(), - selected: is_selected, - } - }) - .collect(); - - split_button_dropdown_wrap( - base_inner.into(), - &items, - CameraMessage::MicDropdownDismiss, - |i| CameraMessage::SelectMic(state.available_mics[i].name.clone()), - HEADER_HEIGHT + 2.0, - state.viewport_size.width / 2.0 + 44.0, - ) - } else { - base_inner.into() - }; + let trailing_padding = + (state.viewport_size.width - CallControlsDensity::Regular.total_width()) / 2.0; + let base = call_controls.wrap_dropdown( + base_inner.into(), + CameraMessage::CallControls, + CallControlsDensity::Regular, + HEADER_HEIGHT + 2.0, + trailing_padding, + ); let floating_show_btn = if state.self_hidden { Some( @@ -937,75 +803,9 @@ impl CameraWindow { /// Handle a camera UI message (state update). fn update(&mut self, message: CameraMessage) { match message { - CameraMessage::MicToggle => { - let is_muted = self - .participants - .read() - .ok() - .and_then(|p| p.get("local").map(|info| info.muted())) - .unwrap_or(false); - - let event = if is_muted { - UserEvent::UnmuteAudio - } else { - UserEvent::MuteAudio - }; - log::info!("CameraWindow: mic toggle -> {:?}", event); - if let Err(e) = self.event_loop_proxy.send_event(event) { - log::error!("CameraWindow: failed to send mic toggle event: {e:?}"); - } - } - CameraMessage::ScreenShare => { - let is_screensharing = self - .participants - .read() - .ok() - .and_then(|participants| { - participants - .get("local") - .map(ParticipantInfo::is_screensharing) - }) - .unwrap_or(false); - - let event = if is_screensharing { - UserEvent::StopScreenShare - } else { - UserEvent::GetAvailableContent - }; - - log::info!("CameraWindow: screen share toggle -> {:?}", event); - if let Err(error) = self.event_loop_proxy.send_event(event) { - log::error!("CameraWindow: failed to send screen share event: {error:?}"); - } - } - CameraMessage::OpenScreenSharePicker => { - log::info!("CameraWindow: opening screen share picker"); - if let Err(error) = self - .event_loop_proxy - .send_event(UserEvent::GetAvailableContent) - { - log::error!("CameraWindow: failed to open screen share picker: {error:?}"); - } - } - CameraMessage::VideoToggle => { - let event = if self.state.camera_active { - UserEvent::StopCamera - } else { - UserEvent::StartCamera { - msg: CameraStartMessage { device_name: None }, - from_socket: false, - } - }; - log::info!("CameraWindow: video toggle -> {:?}", event); - if let Err(e) = self.event_loop_proxy.send_event(event) { - log::error!("CameraWindow: failed to send camera event: {e:?}"); - } - } - CameraMessage::EndCall => { - log::info!("CameraWindow: end call -> CallEnd"); - if let Err(e) = self.event_loop_proxy.send_event(UserEvent::CallEnd) { - log::error!("CameraWindow: failed to send CallEnd event: {e:?}"); - } + CameraMessage::CallControls(message) => { + self.call_controls + .update(message, &self.participants, &self.event_loop_proxy) } CameraMessage::ToggleSelfVisibility => { self.state.self_hidden = !self.state.self_hidden; @@ -1016,53 +816,6 @@ impl CameraWindow { CameraMessage::LocalTileHover(hovered) => { self.state.local_tile_hovered = hovered; } - CameraMessage::CameraDropdownToggle => { - self.state.mic_dropdown_open = false; - if !self.state.camera_dropdown_open { - self.state.available_cameras = CameraCapturer::list_devices(); - } - self.state.camera_dropdown_open = !self.state.camera_dropdown_open; - } - CameraMessage::CameraDropdownDismiss => { - self.state.camera_dropdown_open = false; - } - CameraMessage::SelectCamera(name) => { - self.state.camera_dropdown_open = false; - self.state.selected_camera_name = Some(name.clone()); - let msg = CameraStartMessage { - device_name: Some(name), - }; - if let Err(e) = self.event_loop_proxy.send_event(UserEvent::StartCamera { - msg, - from_socket: false, - }) { - log::error!("Failed to send StartCamera: {e:?}"); - } - } - CameraMessage::MicDropdownToggle => { - self.state.camera_dropdown_open = false; - if !self.state.mic_dropdown_open { - self.state.available_mics = list_audio_inputs(); - } - self.state.mic_dropdown_open = !self.state.mic_dropdown_open; - } - CameraMessage::MicDropdownDismiss => { - self.state.mic_dropdown_open = false; - } - CameraMessage::SelectMic(name) => { - self.state.mic_dropdown_open = false; - self.state.selected_mic_name = Some(name.clone()); - let msg = socket_lib::AudioCaptureMessage { device_name: name }; - if let Err(e) = self - .event_loop_proxy - .send_event(UserEvent::StartAudioCapture { - msg, - from_socket: false, - }) - { - log::error!("Failed to send StartAudioCapture: {e:?}"); - } - } CameraMessage::PinToCorner => { if self.state.is_compact { self.unpin(); @@ -1154,6 +907,7 @@ impl CameraWindow { let mut interface = UserInterface::build( Self::view( &self.state, + &self.call_controls, &self.participants, false, &mut self.last_rendered_frame_ids, diff --git a/core/src/window/screensharing_window.rs b/core/src/window/screensharing_window.rs index 15f533b0..44cb3e47 100644 --- a/core/src/window/screensharing_window.rs +++ b/core/src/window/screensharing_window.rs @@ -10,8 +10,9 @@ //! - Shadow tokens for consistent depth //! - Pill-shaped control buttons with solid/gradient backgrounds +use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; +use std::sync::{Arc, RwLock}; use std::time::{Duration, Instant as StdInstant}; use iced::mouse; @@ -42,6 +43,9 @@ use super::aspect_ratio::{ WindowConstant, }; use super::drawing_helpers; +use crate::components::call_controls::{ + CallControlsDensity, CallControlsMessage, CallControlsState, +}; use crate::components::dropdown::{dropdown_overlay, dropdown_trigger_button, DropdownItemDef}; use crate::components::fonts::{self as fonts_mod, GEIST_MEDIUM, GEIST_REGULAR}; use crate::components::segmented_control::{ @@ -55,6 +59,7 @@ use crate::graphics::graphics_window_context::{ ContextManager, GraphicsWindowContext, GraphicsWindowContextError, }; use crate::graphics::yuv_renderer::YuvVideoProgram; +use crate::livekit::participant::ParticipantInfo; use crate::utils::clock; use crate::utils::geometry::{Extent, Position}; use crate::windows::colors::ColorToken; @@ -80,6 +85,10 @@ pub fn screensharing_window_attributes() -> WindowAttributes { attrs } +const SCREENSHARE_CALL_CONTROLS_MIN_WIDTH: f32 = 640.0; +const SCREENSHARE_SEGMENTED_CONTROLS_WIDTH: f32 = 132.0; +const SCREENSHARE_SETTINGS_BUTTON_WIDTH: f32 = 44.0; + /// Available screen area detected at runtime by probing with a temporary window. /// This replaces hardcoded OS chrome offsets (menubar, taskbar, dock) with /// actual values from the window manager. @@ -325,6 +334,7 @@ pub enum ScreensharingWindowError { #[derive(Debug, Clone)] pub enum ScreensharingMessage { + CallControls(CallControlsMessage), TabSelected(&'static str), ToggleDropdown, DismissDropdown, @@ -436,6 +446,8 @@ pub struct ScreensharingWindow { cursor: mouse::Cursor, modifiers: ModifiersState, state: ScreensharingState, + call_controls: CallControlsState, + call_participants: Arc>>, /// Target size of a programmatic resize in flight (logical pixels). programmatic_resize_target: Option<(f64, f64)>, /// True when the mouse cursor is inside the participant image area. @@ -467,9 +479,43 @@ pub struct ScreensharingWindow { custom_cursor_point: winit::window::CustomCursor, } +pub struct ScreensharingParticipants { + pub remote: Vec<(String, String, bool)>, + pub state: Arc>>, +} + +impl ScreensharingParticipants { + fn from_shared( + state: Arc>>, + sharer_identity: Option<&str>, + ) -> Self { + let remote = state + .read() + .map(|participants| { + participants + .iter() + .filter(|(identity, _)| identity.as_str() != "local") + .map(|(identity, info)| { + ( + identity.clone(), + info.name().to_string(), + sharer_identity == Some(identity.as_str()), + ) + }) + .collect() + }) + .unwrap_or_default(); + Self { remote, state } + } +} + pub struct ScreensharingWindowConfig { pub screen_share_buffer: Arc, - pub participants: Vec<(String, String, bool)>, + pub participants: Arc>>, + pub sharer_identity: Option, + pub camera_active: bool, + pub selected_camera_name: Option, + pub selected_mic_name: Option, pub draw_persist: bool, pub last_mode: Option, pub redraw_rx: std::sync::mpsc::Receiver, @@ -549,6 +595,10 @@ impl ScreensharingWindow { let ScreensharingWindowConfig { screen_share_buffer, participants, + sharer_identity, + camera_active, + selected_camera_name, + selected_mic_name, draw_persist, last_mode, redraw_rx, @@ -684,8 +734,14 @@ impl ScreensharingWindow { (pointer, pencil, point) }; + let ScreensharingParticipants { + remote, + state: call_participants, + } = ScreensharingParticipants::from_shared(participants, sharer_identity.as_deref()); let (initial_state, participants_manager) = - build_initial_state(&participants, draw_persist, &last_mode); + build_initial_state(&remote, draw_persist, &last_mode); + let call_controls = + CallControlsState::new(camera_active, selected_camera_name, selected_mic_name); let redraw_in_progress = Arc::new(AtomicBool::new(false)); let redraw_thread = spawn_redraw_thread( redraw_rx, @@ -705,6 +761,8 @@ impl ScreensharingWindow { cursor: mouse::Cursor::Unavailable, modifiers: ModifiersState::default(), state: initial_state, + call_controls, + call_participants, screen_area, programmatic_resize_target: None, mouse_in_participant_area: false, @@ -847,13 +905,22 @@ impl ScreensharingWindow { self.state.remote_control_allowed = allowed; } + pub fn set_camera_active(&mut self, active: bool, device_name: Option) { + self.call_controls.set_camera_active(active, device_name); + } + + pub fn set_selected_mic_name(&mut self, name: Option) { + self.call_controls.set_selected_mic_name(name); + } + /// Update the window for a new sharer: refresh the display name and swap /// the redraw channel so the newly spawned `process_video_stream` can /// drive redraws. pub fn update_window_with_new_sharer( &mut self, screen_share_buffer: Arc, - participants: &[(String, String, bool)], + participants: Arc>>, + sharer_identity: Option, draw_persist: bool, last_mode: Option, new_rx: std::sync::mpsc::Receiver, @@ -861,13 +928,16 @@ impl ScreensharingWindow { ) { // A fresh buffer Arc is created per room/stream — point the window at it. self.screen_share_buffer = screen_share_buffer; + let ScreensharingParticipants { remote, state } = + ScreensharingParticipants::from_shared(participants, sharer_identity.as_deref()); + self.call_participants = state; - let (state, participants_manager) = - build_initial_state(participants, draw_persist, &last_mode); + let (state, participants_manager) = build_initial_state(&remote, draw_persist, &last_mode); self.state = state; self.participants_manager = participants_manager; // Window-level state. + self.call_controls.dismiss_dropdowns(); self.local_participant_in_control = false; self.programmatic_resize_target = None; self.mouse_in_participant_area = false; @@ -994,6 +1064,7 @@ impl ScreensharingWindow { let logical_x = (position.x / scale_factor as f64) as f32; let logical_y = (position.y / scale_factor as f64) as f32; let inside = !self.state.dropdown_open + && !self.call_controls.has_open_dropdown() && logical_x >= rect.x && logical_x < rect.x + rect.width && logical_y >= rect.y @@ -1396,6 +1467,9 @@ impl ScreensharingWindow { let mut interface = UserInterface::build( Self::view( &self.state, + &self.call_controls, + &self.call_participants, + self.viewport.logical_size().width, &self.screen_share_buffer, &self.participants_manager, &self.click_animation_renderer, @@ -1491,6 +1565,10 @@ impl ScreensharingWindow { let logical: winit::dpi::LogicalSize = new_size.to_logical(self.window.scale_factor()); + if logical.width < SCREENSHARE_CALL_CONTROLS_MIN_WIDTH as f64 { + self.call_controls.dismiss_dropdowns(); + } + // Classify this resize event. if let Some((target_w, target_h)) = self.programmatic_resize_target { let matches_target = (logical.width - target_w).abs() < 2.0 @@ -1594,6 +1672,9 @@ impl ScreensharingWindow { fn view<'a>( state: &'a ScreensharingState, + call_controls: &'a CallControlsState, + call_participants: &'a Arc>>, + viewport_width: f32, screen_share_buffer: &'a Arc, participants: &'a ParticipantsManager, click_animation_renderer: &'a ClickAnimationRenderer, @@ -1620,45 +1701,69 @@ impl ScreensharingWindow { ScreensharingMessage::TabSelected, ); - // ── Header: stack-based layout so the segmented control is truly - // centered across the full window width, independent of name width. - // Layer 1: name on the left - // Layer 2: segmented control absolutely centered - let cog_button = dropdown_trigger_button( - ICON_COG, - state.dropdown_open, - ScreensharingMessage::ToggleDropdown, - ); - + // ── Header: keep call controls between the centered drawing controls and settings. let traffic_light_spacer = if cfg!(target_os = "macos") { 68.0 } else { 0.0 }; - - let header_ends = row![ - Space::new().width(Length::Fixed(traffic_light_spacer)), - name_label, - Space::new().width(Length::Fill), - cog_button, - ] - .align_y(Alignment::Center) - .width(Length::Fill); - - let header_center = container(seg_ctrl) - .width(Length::Fill) - .height(Length::Fill) - .center_x(Length::Fill) - .center_y(Length::Fill); - + let show_call_controls = viewport_width >= SCREENSHARE_CALL_CONTROLS_MIN_WIDTH; let header_left_padding = if cfg!(target_os = "macos") { WindowConstant::PADDING } else { WindowConstant::HEADER_SIDE_PADDING }; + let cog_button = dropdown_trigger_button( + ICON_COG, + state.dropdown_open, + ScreensharingMessage::ToggleDropdown, + ); + + let header_content: iced::Element<'a, ScreensharingMessage, Theme, iced::Renderer> = + if show_call_controls { + let header_ends = row![ + Space::new().width(Length::Fixed(traffic_light_spacer)), + name_label, + Space::new().width(Length::Fill), + cog_button, + ] + .align_y(Alignment::Center) + .width(Length::Fill); + let center_and_call_controls = row![ + Space::new().width(Length::Fixed(SCREENSHARE_SETTINGS_BUTTON_WIDTH)), + Space::new().width(Length::Fill), + seg_ctrl, + container( + call_controls + .view(call_participants, CallControlsDensity::Compact) + .map(ScreensharingMessage::CallControls), + ) + .width(Length::Fill) + .center_x(Length::Fill), + Space::new().width(Length::Fixed(SCREENSHARE_SETTINGS_BUTTON_WIDTH)), + ] + .width(Length::Fill) + .align_y(Alignment::Center); + stack![center_and_call_controls, header_ends].into() + } else { + let header_ends = row![ + Space::new().width(Length::Fixed(traffic_light_spacer)), + name_label, + Space::new().width(Length::Fill), + cog_button, + ] + .align_y(Alignment::Center) + .width(Length::Fill); + let header_center = container(seg_ctrl) + .width(Length::Fill) + .height(Length::Fill) + .center_x(Length::Fill) + .center_y(Length::Fill); + stack![header_ends, header_center].into() + }; - let header = container(stack![header_ends, header_center]) + let header = container(header_content) .width(Length::Fill) .padding(Padding { top: 4.0, right: WindowConstant::HEADER_SIDE_PADDING, - bottom: WindowConstant::PADDING, + bottom: 4.0, left: header_left_padding, }); @@ -1808,7 +1913,7 @@ impl ScreensharingWindow { .clip(true) .into(); - if state.dropdown_open { + let base = if state.dropdown_open { let items = [ DropdownItemDef { label: "Fade Out", @@ -1835,12 +1940,45 @@ impl ScreensharingWindow { ) } else { base + }; + + if viewport_width >= SCREENSHARE_CALL_CONTROLS_MIN_WIDTH { + let call_controls_slot_width = viewport_width + - header_left_padding + - WindowConstant::HEADER_SIDE_PADDING + - SCREENSHARE_SEGMENTED_CONTROLS_WIDTH + - SCREENSHARE_SETTINGS_BUTTON_WIDTH * 2.0; + let call_controls_slot_width = call_controls_slot_width / 2.0; + let trailing_padding = WindowConstant::HEADER_SIDE_PADDING + + SCREENSHARE_SETTINGS_BUTTON_WIDTH + + (call_controls_slot_width - CallControlsDensity::Compact.total_width()).max(0.0) + / 2.0; + call_controls.wrap_dropdown( + base, + ScreensharingMessage::CallControls, + CallControlsDensity::Compact, + WindowConstant::HEADER_HEIGHT, + trailing_padding, + ) + } else { + base } } /// Handle a screensharing UI message (state update). fn update(&mut self, message: ScreensharingMessage) { match message { + ScreensharingMessage::CallControls(message) => { + if matches!( + &message, + CallControlsMessage::MicDropdownToggle + | CallControlsMessage::CameraDropdownToggle + ) { + self.state.dropdown_open = false; + } + self.call_controls + .update(message, &self.call_participants, &self.event_loop_proxy); + } ScreensharingMessage::TabSelected(id) => { self.state.tab_anim = seg_ctrl_mod::start_animation(SEGMENTED_BUTTONS, self.state.active_tab, id); @@ -1850,6 +1988,7 @@ impl ScreensharingWindow { log::info!("ScreensharingWindow: tab selected = {}", id); } ScreensharingMessage::ToggleDropdown => { + self.call_controls.dismiss_dropdowns(); self.state.dropdown_open = !self.state.dropdown_open; log::info!( "ScreensharingWindow: dropdown toggled = {}", @@ -2014,6 +2153,9 @@ impl ScreensharingWindow { let mut interface = UserInterface::build( Self::view( &self.state, + &self.call_controls, + &self.call_participants, + self.viewport.logical_size().width, &self.screen_share_buffer, &self.participants_manager, &self.click_animation_renderer, From 014a02cee1bac727246c7a6e084164b7799f1826 Mon Sep 17 00:00:00 2001 From: Iason Paraskevopoulos Date: Tue, 18 Aug 2026 17:28:10 +0100 Subject: [PATCH 2/3] chore: fix clippy --- core/src/lib.rs | 3 +-- core/src/window/screensharing_window.rs | 15 +++++++-------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/core/src/lib.rs b/core/src/lib.rs index bdb42871..8f80611a 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -2257,8 +2257,7 @@ impl<'a> ApplicationHandler for Application<'a> { sharer_identity.clone(), draw_persist, last_mode, - rx, - tx, + (rx, tx), ); } screensharing_window.focus_window(); diff --git a/core/src/window/screensharing_window.rs b/core/src/window/screensharing_window.rs index 44cb3e47..9554f47f 100644 --- a/core/src/window/screensharing_window.rs +++ b/core/src/window/screensharing_window.rs @@ -923,8 +923,10 @@ impl ScreensharingWindow { sharer_identity: Option, draw_persist: bool, last_mode: Option, - new_rx: std::sync::mpsc::Receiver, - new_tx: std::sync::mpsc::Sender, + (new_rx, new_tx): ( + std::sync::mpsc::Receiver, + std::sync::mpsc::Sender, + ), ) { // A fresh buffer Arc is created per room/stream — point the window at it. self.screen_share_buffer = screen_share_buffer; @@ -1466,8 +1468,7 @@ impl ScreensharingWindow { let cache = self.cache.take().unwrap_or_default(); let mut interface = UserInterface::build( Self::view( - &self.state, - &self.call_controls, + (&self.state, &self.call_controls), &self.call_participants, self.viewport.logical_size().width, &self.screen_share_buffer, @@ -1671,8 +1672,7 @@ impl ScreensharingWindow { } fn view<'a>( - state: &'a ScreensharingState, - call_controls: &'a CallControlsState, + (state, call_controls): (&'a ScreensharingState, &'a CallControlsState), call_participants: &'a Arc>>, viewport_width: f32, screen_share_buffer: &'a Arc, @@ -2152,8 +2152,7 @@ impl ScreensharingWindow { let cache = self.cache.take().unwrap_or_default(); let mut interface = UserInterface::build( Self::view( - &self.state, - &self.call_controls, + (&self.state, &self.call_controls), &self.call_participants, self.viewport.logical_size().width, &self.screen_share_buffer, From 5f3831a109d85995ff5ead9670a80e55499db96b Mon Sep 17 00:00:00 2001 From: Iason Paraskevopoulos Date: Tue, 18 Aug 2026 17:28:25 +0100 Subject: [PATCH 3/3] chore: update prompts --- core/prompts/clear_warnings.md | 25 ++++++++++--------------- core/prompts/review.md | 4 ++++ 2 files changed, 14 insertions(+), 15 deletions(-) create mode 100644 core/prompts/review.md diff --git a/core/prompts/clear_warnings.md b/core/prompts/clear_warnings.md index 17ad2a73..517816f6 100644 --- a/core/prompts/clear_warnings.md +++ b/core/prompts/clear_warnings.md @@ -1,27 +1,22 @@ -# Clear Warnings, Clippy & Formatting +# Clear Warnings and Clippy -Fix all compiler warnings, clippy lints, and formatting issues in this project. Work in this order: +Fix all compiler warnings and clippy lints in this project without changing behavior. Work in this order: -## 1. Formatting -```bash -cargo fmt --all -- --check -``` -Fix any formatting issues reported. run `cargo fmt` directly. +## 1. Build warnings -## 2. Build Warnings ```bash task build_dev ``` -Fix all warnings (unused imports, dead code, unused variables, etc). Remove or use the flagged items. If unsure how to handle them leave them as is and say it. +Fix all warnings (unused imports, dead code, unused variables, etc.). Remove or use the flagged items. If a warning is intentional because of platform-conditional code, preserve the code and explain it. -## 3. Clippy +## 2. Clippy ```bash -cargo clippy --all-features +cargo clippy --all-features -- -D warnings ``` Fix all clippy lints. Do not use `#[allow(clippy::...)]`. ## Rules -- Run each command, fix all issues, re-run to confirm zero warnings/errors. -- Do NOT introduce behavioral changes — only clean up. -- Use subagents to parallelize independent fixes across files. -- If a warning is intentional (e.g. platform-conditional code), add a targeted `#[allow(...)]` with a comment explaining why. +- Run each command, fix all issues, and re-run it to confirm zero warnings or errors. +- Do not run `cargo fmt` or core tests. +- Do not introduce behavioral changes; only clean up warnings and lints. +- Keep the diff minimal and review unrelated worktree changes before editing. diff --git a/core/prompts/review.md b/core/prompts/review.md new file mode 100644 index 00000000..13b25e2e --- /dev/null +++ b/core/prompts/review.md @@ -0,0 +1,4 @@ +Do not trust the author. Assume ill intent. Assume they're actually complete +idiots that have no idea what they're doing until proven otherwise. This person +is out to fuck your day up. Make sure this work is rock solid, and report anything +otherwise. Flag unessary changes, where the functionality stayed the same.