use iced::widget::{ button, checkbox, column, container, pick_list, row, scrollable, slider, text, text_input, Column, }; use iced::{ Alignment, Background, Border, Color, Element, Length, Subscription, Task, Theme, }; use std::sync::Arc; use tokio::sync::{mpsc, Mutex}; use futures::stream; use crate::app_logic::{AppCommand, FrontendState}; use crate::net::PeerInfo; use crate::chat::ChatEntry; use chrono::{DateTime, Local, TimeZone, Utc}; // Discord-like Colors const BG_DARK: Color = Color::from_rgb(0.21, 0.22, 0.25); // #36393f const SIDEBAR_DARK: Color = Color::from_rgb(0.18, 0.19, 0.21); // #2f3136 const INPUT_BG: Color = Color::from_rgb(0.25, 0.27, 0.29); // #40444b const TEXT_COLOR: Color = Color::from_rgb(0.86, 0.86, 0.86); // #dcddde const MUTED_TEXT: Color = Color::from_rgb(0.45, 0.46, 0.48); // #72767d pub struct ChatApp { state: FrontendState, input_value: String, command_sender: mpsc::Sender, // We keep the receiver in the struct to use it in subscription state_receiver: Arc>>, // Voice Chat State input_devices: Vec, selected_device: Option, output_devices: Vec, selected_output_device: Option, is_in_voice: bool, master_volume: f32, noise_cancel_enabled: bool, } #[derive(Debug, Clone)] pub enum Message { InputChanged(String), SendMessage, BackendUpdate(FrontendState), // Voice InputDeviceSelected(String), OutputDeviceSelected(String), ToggleVoice, ToggleScreen, RefreshDevices, MasterVolumeChanged(f32), ToggleNoiseCancel(bool), CopyText(String), NoOp, } pub struct Flags { pub initial_state: FrontendState, pub command_sender: mpsc::Sender, pub state_receiver: mpsc::Receiver, } impl ChatApp { pub fn new(flags: Flags) -> (Self, Task) { let master_volume = flags.initial_state.master_volume; let noise_cancel_enabled = flags.initial_state.noise_suppression; ( Self { state: flags.initial_state, input_value: String::new(), command_sender: flags.command_sender, state_receiver: Arc::new(Mutex::new(flags.state_receiver)), input_devices: Vec::new(), selected_device: None, output_devices: Vec::new(), selected_output_device: None, is_in_voice: false, master_volume, noise_cancel_enabled, }, Task::perform(async {}, |_| Message::RefreshDevices), ) } pub fn title(&self) -> String { format!("P2P Chat - {} ({})", self.state.our_name, self.state.our_id) } pub fn update(&mut self, message: Message) -> Task { match message { Message::InputChanged(value) => { self.input_value = value; Task::none() } Message::SendMessage => { let input_text = self.input_value.trim().to_string(); if input_text.is_empty() { return Task::none(); } self.input_value.clear(); // Simple command parsing let command = if input_text.starts_with('/') { // ... same as before ... let parts: Vec<&str> = input_text.split_whitespace().collect(); match parts.as_slice() { ["/nick", name] | ["/name", name] => { Some(AppCommand::ChangeNick(name.to_string())) } ["/connect", id] | ["/join", id] => { Some(AppCommand::Connect(id.to_string())) } ["/voice"] => Some(AppCommand::ToggleVoice), ["/quit"] => Some(AppCommand::Quit), _ => Some(AppCommand::SystemMessage(format!("Unknown command: {}", input_text))), } } else { Some(AppCommand::SendMessage(input_text.clone())) }; let sender = self.command_sender.clone(); Task::perform( async move { if let Some(cmd) = command { let _ = sender.send(cmd).await; } }, |_| Message::InputChanged(String::new()), ) } Message::BackendUpdate(new_state) => { // Check if voice status changed to update UI state if needed // Currently FrontendState doesn't explicitly have voice status bool, // but we can infer or add it. For now, rely on local toggle state or messages. // Actually FrontendState has `media_status` string. if new_state.media_status.contains("🎤 LIVE") { self.is_in_voice = true; } else { self.is_in_voice = false; } // Update selected devices from backend state if they are set there if let Some(dev) = &new_state.input_device_name { if self.selected_device.as_ref() != Some(dev) { self.selected_device = Some(dev.clone()); } } if let Some(dev) = &new_state.output_device_name { if self.selected_output_device.as_ref() != Some(dev) { self.selected_output_device = Some(dev.clone()); } } self.state = new_state; Task::none() } Message::InputDeviceSelected(device) => { self.selected_device = Some(device.clone()); let sender = self.command_sender.clone(); Task::perform( async move { let _ = sender.send(AppCommand::SetInputDevice(device)).await; }, |_| Message::InputChanged(String::new()), // Dummy message ) } Message::OutputDeviceSelected(device) => { self.selected_output_device = Some(device.clone()); let sender = self.command_sender.clone(); Task::perform( async move { let _ = sender.send(AppCommand::SetOutputDevice(device)).await; }, |_| Message::InputChanged(String::new()), // Dummy message ) } Message::ToggleVoice => { let sender = self.command_sender.clone(); Task::perform( async move { let _ = sender.send(AppCommand::ToggleVoice).await; }, |_| Message::InputChanged(String::new()), // Dummy ) } Message::ToggleScreen => { let sender = self.command_sender.clone(); Task::perform( async move { let _ = sender.send(AppCommand::ToggleScreen).await; }, |_| Message::InputChanged(String::new()), // Dummy ) } Message::MasterVolumeChanged(vol) => { self.master_volume = vol; let sender = self.command_sender.clone(); Task::perform( async move { let _ = sender.send(AppCommand::SetMasterVolume(vol)).await; }, |_| Message::NoOp, ) } Message::ToggleNoiseCancel(enabled) => { self.noise_cancel_enabled = enabled; let sender = self.command_sender.clone(); Task::perform( async move { let _ = sender.send(AppCommand::ToggleNoiseCancel).await; }, |_| Message::NoOp, ) } Message::RefreshDevices => { // Use the improved device filtering from MediaState logic use cpal::traits::{HostTrait, DeviceTrait}; // Prioritize JACK if available let available_hosts = cpal::available_hosts(); let mut hosts = Vec::new(); if available_hosts.contains(&cpal::HostId::Jack) { hosts.push(cpal::host_from_id(cpal::HostId::Jack).unwrap()); } hosts.push(cpal::default_host()); let mut input_names = Vec::new(); let mut output_names = Vec::new(); for host in &hosts { if let Ok(devices) = host.input_devices() { for device in devices { if let Ok(name) = device.name() { if name.contains("dmix") || name.contains("dsnoop") || name.contains("null") { continue; } let clean_name = if let Some(start) = name.find("CARD=") { let rest = &name[start + 5..]; let card_name = rest.split(',').next().unwrap_or(rest); let prefix = name.split(':').next().unwrap_or("Unknown"); format!("{} ({})", card_name, prefix) } else if name.contains("HDA Intel PCH") { name } else { name }; input_names.push(clean_name); } } } if let Ok(devices) = host.output_devices() { for device in devices { if let Ok(name) = device.name() { if name.contains("dmix") || name.contains("dsnoop") || name.contains("null") { continue; } let clean_name = if let Some(start) = name.find("CARD=") { let rest = &name[start + 5..]; let card_name = rest.split(',').next().unwrap_or(rest); let prefix = name.split(':').next().unwrap_or("Unknown"); format!("{} ({})", card_name, prefix) } else { name }; output_names.push(clean_name); } } } } input_names.sort(); input_names.dedup(); output_names.sort(); output_names.dedup(); self.input_devices = input_names; self.output_devices = output_names; if self.selected_device.is_none() && !self.input_devices.is_empty() { // Pre-select first self.selected_device = Some(self.input_devices[0].clone()); // We don't auto-send command to avoid loop, user must select or we wait for backend state } if self.selected_output_device.is_none() && !self.output_devices.is_empty() { self.selected_output_device = Some(self.output_devices[0].clone()); } Task::none() } Message::CopyText(text) => { iced::clipboard::write(text) } Message::NoOp => Task::none(), } } pub fn view(&self) -> Element { // Chat Area let chat_content = self.state.chat_history.iter().fold( Column::new().spacing(10).padding(20), |column, entry| column.push(view_chat_entry(entry)), ); let chat_scroll = scrollable(chat_content) .height(Length::Fill) .width(Length::Fill) .id(scrollable::Id::new("chat_scroll")); // Input Area let input = text_input("Message #general", &self.input_value) .on_input(Message::InputChanged) .on_submit(Message::SendMessage) .padding(12) .style(|_theme, status| { text_input::Style { background: Background::Color(INPUT_BG), border: Border { radius: 8.0.into(), width: 0.0, color: Color::TRANSPARENT, }, icon: Color::WHITE, placeholder: MUTED_TEXT, value: TEXT_COLOR, selection: Color::from_rgb(0.4, 0.5, 0.8), } }); let input_container = container(input) .padding(15) .style(|_theme: &Theme| container::Style { background: Some(Background::Color(BG_DARK)), ..Default::default() }); // Sidebar (Peers + Voice) let identity_section = column![ text("MY IDENTITY").size(12).style(|_theme: &Theme| text::Style { color: Some(MUTED_TEXT) }), text(&self.state.our_name).size(16).style(|_theme: &Theme| text::Style { color: Some(TEXT_COLOR) }), row![ text_input("My ID", &self.state.our_id) .padding(5) .size(12) .on_input(|_| Message::NoOp) .style(|_theme, _status| text_input::Style { background: Background::Color(Color::from_rgb(0.15, 0.16, 0.18)), border: Border { radius: 4.0.into(), ..Default::default() }, value: MUTED_TEXT, placeholder: MUTED_TEXT, selection: Color::from_rgb(0.4, 0.5, 0.8), icon: Color::TRANSPARENT, }), button(text("Copy").size(12)) .on_press(Message::CopyText(self.state.our_id_full.clone())) .padding(5) .style(|_theme, _status| button::Style { background: Some(Background::Color(Color::from_rgb(0.3, 0.3, 0.35))), text_color: Color::WHITE, border: Border { radius: 4.0.into(), ..Default::default() }, ..Default::default() }) ].spacing(5) ].spacing(5).padding(10); let identity_container = container(identity_section) .style(|_theme: &Theme| container::Style { background: Some(Background::Color(Color::from_rgb(0.15, 0.16, 0.18))), ..Default::default() }); let peers_title = text("ONLINE").size(12).style(|_theme: &Theme| text::Style { color: Some(MUTED_TEXT) }); let peers_content = self.state.peers.iter().fold( Column::new().spacing(5), |column, peer| column.push(view_peer(peer)), ); let voice_section = column![ text("VOICE CONNECTED").size(12).style(|_theme: &Theme| text::Style { color: Some(if self.is_in_voice { Color::from_rgb(0.4, 0.8, 0.4) } else { MUTED_TEXT }) }), text("Input Device").size(10).style(|_theme: &Theme| text::Style { color: Some(MUTED_TEXT) }), pick_list( self.input_devices.clone(), self.selected_device.clone(), Message::InputDeviceSelected ).text_size(12).padding(5), text("Output Device").size(10).style(|_theme: &Theme| text::Style { color: Some(MUTED_TEXT) }), pick_list( self.output_devices.clone(), self.selected_output_device.clone(), Message::OutputDeviceSelected ).text_size(12).padding(5), button( text(if self.is_in_voice { "Disconnect" } else { "Join Voice" }).size(14) ) .on_press(Message::ToggleVoice) .padding(8) .style(move |_theme, _status| { let bg = if self.is_in_voice { Color::from_rgb(0.8, 0.3, 0.3) } else { Color::from_rgb(0.3, 0.6, 0.4) }; button::Style { background: Some(Background::Color(bg)), text_color: Color::WHITE, border: Border { radius: 4.0.into(), ..Default::default() }, ..Default::default() } }) .width(Length::Fill), button( text(if self.state.media_status.contains("🖥 LIVE") { "Stop Screen" } else { "Share Screen" }).size(14) ) .on_press(Message::ToggleScreen) .padding(8) .style(move |_theme, _status| { let is_sharing = self.state.media_status.contains("🖥 LIVE"); let bg = if is_sharing { Color::from_rgb(0.8, 0.3, 0.3) } else { Color::from_rgb(0.3, 0.4, 0.6) }; button::Style { background: Some(Background::Color(bg)), text_color: Color::WHITE, border: Border { radius: 4.0.into(), ..Default::default() }, ..Default::default() } }) .width(Length::Fill), // Audio Controls text("Master Volume").size(10).style(|_theme: &Theme| text::Style { color: Some(MUTED_TEXT) }), slider(0.0..=2.0, self.master_volume, Message::MasterVolumeChanged).step(0.05), checkbox("Noise Cancellation", self.noise_cancel_enabled) .on_toggle(Message::ToggleNoiseCancel) .text_size(12) .style(|_theme, _status| checkbox::Style { background: Background::Color(INPUT_BG), icon_color: Color::WHITE, border: Border { radius: 4.0.into(), ..Default::default() }, text_color: Some(TEXT_COLOR), }), ].spacing(10).padding(10); let voice_panel = container(voice_section) .style(|_theme: &Theme| container::Style { background: Some(Background::Color(Color::from_rgb(0.15, 0.16, 0.18))), // Darker panel at bottom ..Default::default() }); let sidebar = container( column![ identity_container, column![peers_title, peers_content].spacing(10).padding(10).height(Length::Fill), voice_panel ] ) .width(Length::Fixed(240.0)) .height(Length::Fill) .style(|_theme: &Theme| container::Style { background: Some(Background::Color(SIDEBAR_DARK)), ..Default::default() }); // Main Layout let main_content = column![chat_scroll, input_container] .width(Length::Fill) .height(Length::Fill); let layout = row![sidebar, main_content] .width(Length::Fill) .height(Length::Fill); container(layout) .width(Length::Fill) .height(Length::Fill) .style(|_theme: &Theme| container::Style { background: Some(Background::Color(BG_DARK)), text_color: Some(TEXT_COLOR), ..Default::default() }) .into() } pub fn subscription(&self) -> Subscription { struct BackendSubscription; let receiver = self.state_receiver.clone(); Subscription::run_with_id( std::any::TypeId::of::(), stream::unfold(receiver, |receiver| async move { let mut guard = receiver.lock().await; if let Some(state) = guard.recv().await { Some((Message::BackendUpdate(state), receiver.clone())) } else { // Wait a bit if channel closed or empty to avoid hot loop if logic changes tokio::time::sleep(std::time::Duration::from_millis(100)).await; Some((Message::BackendUpdate(FrontendState::default()), receiver.clone())) } }) ) } pub fn theme(&self) -> Theme { Theme::Dark } } // ... run function ... pub fn run(flags: Flags) -> iced::Result { iced::application( ChatApp::title, ChatApp::update, ChatApp::view ) .subscription(ChatApp::subscription) .theme(ChatApp::theme) .run_with(move || ChatApp::new(flags)) } fn view_chat_entry(entry: &ChatEntry) -> Element { let sender_color = if entry.is_self { Color::from_rgb8(200, 200, 255) } else if entry.is_system { Color::from_rgb8(255, 100, 100) } else { Color::from_rgb8(100, 200, 100) }; let sender = text(&entry.sender_name) .style(move |_theme: &Theme| text::Style { color: Some(sender_color) }) .font(iced::font::Font::DEFAULT) // Sans-serif .size(15); let content = text(&entry.text) .size(15) .style(move |_theme: &Theme| text::Style { color: Some(TEXT_COLOR) }); let time = text(format_timestamp(entry.timestamp)) .size(11) .style(move |_theme: &Theme| text::Style { color: Some(MUTED_TEXT) }); let header = row![sender, time].spacing(8).align_y(Alignment::Center); // Rounded message bubble if needed, or just clean text like Discord column![header, content].spacing(4).into() } fn view_peer(peer: &PeerInfo) -> Element { let name = peer.name.as_deref().unwrap_or("Unknown"); // Audio activity border let (border_width, border_color) = if peer.audio_level > 0.01 { (2.0, Color::from_rgb(0.2, 0.8, 0.2)) // Green } else { (0.0, Color::TRANSPARENT) }; let peer_info = column![ text(name).size(14).style(|_theme: &Theme| text::Style { color: Some(TEXT_COLOR) }), row![ text(if peer.audio_level > 0.01 { "🔊" } else { "🔇" }).size(12), text(peer.id.to_string().chars().take(8).collect::()) .size(10) .style(|_theme: &Theme| text::Style { color: Some(MUTED_TEXT) }), ].spacing(4) ].spacing(2); let content = row![ peer_info, button(text("Copy").size(10)) .on_press(Message::CopyText(peer.id.to_string())) .padding(4) .style(|_theme, _status| button::Style { background: Some(Background::Color(Color::from_rgb(0.25, 0.26, 0.28))), text_color: Color::WHITE, border: Border { radius: 4.0.into(), ..Default::default() }, ..Default::default() }) ] .spacing(10) .align_y(Alignment::Center); container(content) .padding(5) .style(move |_theme: &Theme| container::Style { background: None, border: Border { width: border_width, color: border_color, radius: 4.0.into(), }, ..Default::default() }) .into() } fn format_timestamp(ts: u64) -> String { if ts == 0 { return "".to_string(); } match Utc.timestamp_millis_opt(ts as i64) { chrono::LocalResult::Single(dt) => { let local: DateTime = DateTime::from(dt); local.format("%H:%M").to_string() } _ => "".to_string(), } }