use crate::core::{CoreController, messages::{CoreCommand, UiEvent}}; use crate::network::PeerState; use iced::widget::{ container, column, row, text, button, text_input, scrollable, slider, checkbox, Column, Space, }; use iced::{ Color, Background, Border, Element, Subscription, Task, Theme, Event, keyboard, }; use iroh::EndpointId; use std::collections::HashMap; use std::sync::{Arc, OnceLock}; use tokio::sync::Mutex; static UI_RX: OnceLock>>> = OnceLock::new(); #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Screen { Home, Room, Settings, } #[derive(Debug, Clone)] pub enum AppMessage { NicknameChanged(String), TicketInputChanged(String), JoinPressed, CreatePressed, LeavePressed, ToggleMutePressed, ToggleDeafenPressed, UiEventReceived(UiEvent), CopyToClipboard, TogglePtt(bool), StartSettingHotkey, PeerVolumeChanged(EndpointId, f32), InputDeviceChanged(String), OutputDeviceChanged(String), EventOccurred(Event), NavigateToSettings, NavigateBack, } fn core_subscription() -> impl iced::futures::Stream { iced::stream::channel(100, |mut output: iced::futures::channel::mpsc::Sender| async move { if let Some(rx_lock) = UI_RX.get() { let mut guard = rx_lock.lock().await; if let Some(mut rx) = guard.take() { use iced::futures::sink::SinkExt; while let Some(event) = rx.recv().await { let _ = output.send(event).await; } } } }) } pub struct AppState { name: String, ticket_input: String, status_message: String, self_id: String, ticket: String, is_muted: bool, is_deafened: bool, ptt_enabled: bool, ptt_active: bool, ptt_hotkey: keyboard::Key, is_setting_hotkey: bool, input_device: String, output_device: String, peers: HashMap, peer_volumes: HashMap, audio_levels: HashMap, controller: Arc, current_screen: Screen, } impl Default for AppState { fn default() -> Self { let (ui_tx, ui_rx) = tokio::sync::mpsc::channel(100); let controller = Arc::new(CoreController::new(ui_tx)); let _ = UI_RX.set(Mutex::new(Some(ui_rx))); Self { name: "Peer".to_string(), ticket_input: "".to_string(), status_message: "Ready to connect".to_string(), self_id: "".to_string(), ticket: "".to_string(), is_muted: false, is_deafened: false, ptt_enabled: false, ptt_active: false, ptt_hotkey: keyboard::Key::Named(keyboard::key::Named::Space), is_setting_hotkey: false, input_device: "".to_string(), output_device: "".to_string(), peers: HashMap::new(), peer_volumes: HashMap::new(), audio_levels: HashMap::new(), controller, current_screen: Screen::Home, } } } fn theme(_state: &AppState) -> Theme { Theme::Dark } pub fn run_gui() -> iced::Result { iced::application(AppState::default, update, view) .title("PeerSpeak P2P Voice Chat") .theme(theme) .subscription(subscription) .window(iced::window::Settings { size: iced::Size::new(900.0, 600.0), position: iced::window::Position::Centered, exit_on_close_request: true, ..Default::default() }) .run() } fn subscription(state: &AppState) -> Subscription { let core_sub = Subscription::run(core_subscription).map(AppMessage::UiEventReceived); let event_sub = iced::event::listen().map(AppMessage::EventOccurred); Subscription::batch(vec![core_sub, event_sub]) } fn update(state: &mut AppState, message: AppMessage) -> Task { match message { AppMessage::NicknameChanged(val) => { state.name = val; } AppMessage::TicketInputChanged(val) => { state.ticket_input = val; } AppMessage::JoinPressed => { state.status_message = "Connecting to room...".to_string(); let _ = state.controller.send(CoreCommand::Join { name: state.name.clone(), ticket: state.ticket_input.clone(), input_device: if state.input_device.is_empty() { None } else { Some(state.input_device.clone()) }, output_device: if state.output_device.is_empty() { None } else { Some(state.output_device.clone()) }, }); } AppMessage::CreatePressed => { state.status_message = "Creating room...".to_string(); let _ = state.controller.send(CoreCommand::Join { name: state.name.clone(), ticket: "".to_string(), input_device: if state.input_device.is_empty() { None } else { Some(state.input_device.clone()) }, output_device: if state.output_device.is_empty() { None } else { Some(state.output_device.clone()) }, }); } AppMessage::LeavePressed => { let _ = state.controller.send(CoreCommand::Leave); } AppMessage::ToggleMutePressed => { let _ = state.controller.send(CoreCommand::ToggleMute); state.is_muted = !state.is_muted; } AppMessage::ToggleDeafenPressed => { let _ = state.controller.send(CoreCommand::ToggleDeafen); state.is_deafened = !state.is_deafened; } AppMessage::UiEventReceived(event) => { match event { UiEvent::RoomJoined { ticket, self_id } => { state.ticket = ticket; state.self_id = self_id; state.status_message = "Connected".to_string(); state.current_screen = Screen::Room; } UiEvent::RoomLeft => { state.ticket = "".to_string(); state.peers.clear(); state.audio_levels.clear(); state.status_message = "Ready to connect".to_string(); state.current_screen = Screen::Home; } UiEvent::PeerJoined { id, state: peer_state } => { state.peers.insert(id, peer_state); } UiEvent::PeerLeft { id } => { state.peers.remove(&id); state.audio_levels.remove(&id); } UiEvent::PeerUpdated { id, state: peer_state } => { state.peers.insert(id, peer_state); } UiEvent::AudioLevels(levels) => { for (id, val) in levels { state.audio_levels.insert(id, val); } } UiEvent::Error(err) => { state.status_message = format!("Error: {}", err); } } } AppMessage::CopyToClipboard => { if !state.ticket.is_empty() { return iced::clipboard::write(state.ticket.clone()); } } AppMessage::TogglePtt(enabled) => { state.ptt_enabled = enabled; let _ = state.controller.send(CoreCommand::SetPttMode(enabled)); } AppMessage::StartSettingHotkey => { state.is_setting_hotkey = true; } AppMessage::PeerVolumeChanged(id, vol) => { state.peer_volumes.insert(id, vol); let _ = state.controller.send(CoreCommand::SetPeerVolume(id, vol)); } AppMessage::InputDeviceChanged(val) => { state.input_device = val; } AppMessage::OutputDeviceChanged(val) => { state.output_device = val; } 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)); } } AppMessage::EventOccurred(Event::Keyboard(keyboard::Event::KeyReleased { key, .. })) => { if state.ptt_enabled && key == state.ptt_hotkey && state.ptt_active { state.ptt_active = false; let _ = state.controller.send(CoreCommand::SetPttActive(false)); } } AppMessage::EventOccurred(_) => {} AppMessage::NavigateToSettings => { state.current_screen = Screen::Settings; } AppMessage::NavigateBack => { if state.ticket.is_empty() { state.current_screen = Screen::Home; } else { state.current_screen = Screen::Room; } } } Task::none() } fn horizontal_space() -> iced::widget::Space { iced::widget::Space::new().width(iced::Length::Fill) } fn vertical_space(height: f32) -> iced::widget::Space { iced::widget::Space::new().height(height) } fn view(state: &AppState) -> Element<'_, AppMessage> { // Theme Colors let color_crust = Color::from_rgb8(17, 17, 27); let color_mantle = Color::from_rgb8(24, 24, 37); let color_base = Color::from_rgb8(30, 30, 46); let color_surface = Color::from_rgb8(49, 50, 68); let color_text = Color::from_rgb8(205, 214, 244); let color_subtext = Color::from_rgb8(166, 173, 200); let color_blue = Color::from_rgb8(137, 180, 250); let color_lavender = Color::from_rgb8(180, 190, 254); let color_red = Color::from_rgb8(243, 139, 168); let color_maroon = Color::from_rgb8(233, 146, 160); let color_green = Color::from_rgb8(166, 227, 161); // Style Helpers let c_style = move |bg: Color, b_color: Color, radius: f32| { move |_theme: &Theme| container::Style { text_color: Some(color_text), background: Some(Background::Color(bg)), border: Border { color: b_color, width: if b_color == Color::TRANSPARENT { 0.0 } else { 1.0 }, radius: radius.into(), }, ..Default::default() } }; let b_style = move |bg: Color, hover_bg: Color, text_c: Color, radius: f32| { move |_theme: &Theme, status: button::Status| { let active_bg = match status { button::Status::Hovered => hover_bg, _ => bg, }; button::Style { background: Some(Background::Color(active_bg)), text_color: text_c, border: Border { color: Color::TRANSPARENT, width: 0.0, radius: radius.into(), }, ..Default::default() } } }; let t_style = move |_theme: &Theme, _status: text_input::Status| { text_input::Style { background: Background::Color(color_crust), border: Border { color: color_surface, width: 1.0, radius: 6.0.into(), }, icon: color_subtext, placeholder: Color::from_rgb8(108, 112, 134), value: color_text, selection: color_blue, } }; let top_bar = row![ horizontal_space(), button(text("⚙ Settings").size(14)) .on_press(AppMessage::NavigateToSettings) .style(b_style(color_surface, color_blue, color_text, 6.0)) .padding(8) ].width(iced::Length::Fill).padding(10); if state.current_screen == Screen::Settings { let content = column![ text("Settings").size(32).color(color_text), vertical_space(20.0), text("Future settings and controls will be added here.").size(16).color(color_subtext), vertical_space(40.0), button( text("Back") .size(16) .align_x(iced::alignment::Horizontal::Center) ) .on_press(AppMessage::NavigateBack) .style(b_style(color_surface, color_blue, color_text, 8.0)) .padding(14) .width(iced::Length::Fixed(150.0)) ] .align_x(iced::alignment::Horizontal::Center) .spacing(10); return container(content) .width(iced::Length::Fill) .height(iced::Length::Fill) .center_x(iced::Length::Fill) .center_y(iced::Length::Fill) .style(c_style(color_crust, Color::TRANSPARENT, 0.0)) .into(); } if state.current_screen == Screen::Home { // --- HOME SCREEN --- let logo = text("PEERSPEAK") .size(36) .color(color_blue); let subtitle = text("NAT-traversing full-mesh voice chat") .size(16) .color(color_subtext); let nickname_input = column![ text("Nickname").size(14).color(color_subtext), vertical_space(4.0), text_input("Enter nickname...", &state.name) .on_input(AppMessage::NicknameChanged) .style(t_style) .padding(10) ]; let create_btn = button( text("Create New Room") .size(16) .align_x(iced::alignment::Horizontal::Center) ) .on_press(AppMessage::CreatePressed) .style(b_style(color_blue, color_lavender, color_crust, 8.0)) .padding(12) .width(iced::Length::Fill); let join_group = column![ text("Join Existing Room").size(14).color(color_subtext), vertical_space(4.0), text_input("Paste room ticket here...", &state.ticket_input) .on_input(AppMessage::TicketInputChanged) .style(t_style) .padding(10), vertical_space(8.0), button( text("Join Room") .size(16) .align_x(iced::alignment::Horizontal::Center) ) .on_press(AppMessage::JoinPressed) .style(b_style(color_surface, color_blue, color_text, 8.0)) .padding(12) .width(iced::Length::Fill) ]; let status = text(&state.status_message) .size(14) .color(color_subtext); let content = container( column![ logo, subtitle, vertical_space(20.0), nickname_input, vertical_space(16.0), create_btn, vertical_space(16.0), text("— OR —").size(12).color(color_surface).align_x(iced::alignment::Horizontal::Center), vertical_space(16.0), join_group, vertical_space(16.0), text("Device Settings (Optional Node Target IDs)").size(14).color(color_subtext), row![ text_input("Input Target", &state.input_device).on_input(AppMessage::InputDeviceChanged).style(t_style.clone()).padding(8), horizontal_space(), text_input("Output Target", &state.output_device).on_input(AppMessage::OutputDeviceChanged).style(t_style.clone()).padding(8), ].spacing(10), vertical_space(20.0), status ] .spacing(10) .align_x(iced::alignment::Horizontal::Center) ) .style(c_style(color_mantle, color_surface, 12.0)) .padding(30) .width(420); let scroll = scrollable(content); container( column![ top_bar, vertical_space(20.0), scroll ].align_x(iced::alignment::Horizontal::Center) ) .width(iced::Length::Fill) .height(iced::Length::Fill) .style(c_style(color_crust, Color::TRANSPARENT, 0.0)) .into() } else { // --- ROOM SCREEN --- let header = row![ text("PEERSPEAK") .size(20) .color(color_blue), horizontal_space(), text(format!("My ID: {}", &state.self_id[..8])) .size(14) .color(color_subtext), horizontal_space(), button(text("Copy Ticket").size(12)) .on_press(AppMessage::CopyToClipboard) .style(b_style(color_surface, color_blue, color_text, 6.0)) .padding(6) ] .align_y(iced::alignment::Vertical::Center); let header_container = container(header) .style(c_style(color_mantle, color_surface, 8.0)) .padding(15) .width(iced::Length::Fill); // Peers Column let mut peers_list = Column::new().spacing(10); // Add ourselves let self_card = container( row![ text(format!("{} (You)", &state.name)).size(16).color(color_text), horizontal_space(), if state.is_muted { text("[Muted]").size(14).color(color_red) } else { text("[Active]").size(14).color(color_green) } ] .align_y(iced::alignment::Vertical::Center) ) .style(c_style(color_base, color_surface, 6.0)) .padding(12); peers_list = peers_list.push(self_card); for (peer_id, peer) in &state.peers { let level = state.audio_levels.get(peer_id).copied().unwrap_or(0.0); let is_speaking = level > 0.01; let indicator = if peer.is_muted { text("[Muted]").size(14).color(color_red) } else if is_speaking { text("[Speaking]").size(14).color(color_green) } else { text("[Idle]").size(14).color(color_subtext) }; let mut card_content = column![ row![ column![ text(&peer.name).size(16).color(color_text), text(format!("ID: {}", &peer_id.to_string()[..8])).size(11).color(color_subtext) ], horizontal_space(), indicator ] .align_y(iced::alignment::Vertical::Center) ].spacing(8); // Peer volume slider let current_vol = state.peer_volumes.get(peer_id).copied().unwrap_or(1.0); let peer_id_clone = *peer_id; card_content = card_content.push( row![ text("Vol:").size(12).color(color_subtext), slider(0.0..=2.0, current_vol, move |v| AppMessage::PeerVolumeChanged(peer_id_clone, v)) ].spacing(8).align_y(iced::alignment::Vertical::Center) ); let card = container(card_content) .style(c_style( if is_speaking { color_base } else { color_mantle }, if is_speaking { color_green } else { color_surface }, 6.0 )) .padding(12); peers_list = peers_list.push(card); } let scroll_peers = scrollable(peers_list); let peers_panel = container( column![ text("Room Participants").size(18).color(color_blue), vertical_space(10.0), scroll_peers ] ) .style(c_style(color_mantle, Color::TRANSPARENT, 0.0)) .padding(15) .width(iced::Length::FillPortion(2)) .height(iced::Length::Fill); // Control Panel Column let mute_text = if state.is_muted { "Unmute Mic" } else { "Mute Mic" }; let mute_bg = if state.is_muted { color_red } else { color_surface }; let mute_hover = if state.is_muted { color_maroon } else { color_blue }; let mute_fg = if state.is_muted { color_crust } else { color_text }; let deafen_text = if state.is_deafened { "Undeafen Audio" } else { "Deafen Audio" }; let deafen_bg = if state.is_deafened { color_red } else { color_surface }; let deafen_hover = if state.is_deafened { color_maroon } else { color_blue }; let deafen_fg = if state.is_deafened { color_crust } else { color_text }; let ctrl_buttons = column![ button( text(mute_text) .size(16) .align_x(iced::alignment::Horizontal::Center) ) .on_press(AppMessage::ToggleMutePressed) .style(b_style(mute_bg, mute_hover, mute_fg, 8.0)) .padding(14) .width(iced::Length::Fill), vertical_space(10.0), button( text(deafen_text) .size(16) .align_x(iced::alignment::Horizontal::Center) ) .on_press(AppMessage::ToggleDeafenPressed) .style(b_style(deafen_bg, deafen_hover, deafen_fg, 8.0)) .padding(14) .width(iced::Length::Fill), vertical_space(20.0), checkbox(state.ptt_enabled).label("Push-to-Talk").on_toggle(AppMessage::TogglePtt), vertical_space(10.0), if state.ptt_enabled { 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) .style(b_style(color_surface, color_blue, color_text, 6.0)) .padding(8) .width(iced::Length::Fill) ].spacing(8) } else { column![] }, vertical_space(30.0), button( text("Leave Room") .size(16) .align_x(iced::alignment::Horizontal::Center) ) .on_press(AppMessage::LeavePressed) .style(b_style(color_red, color_maroon, color_crust, 8.0)) .padding(14) .width(iced::Length::Fill) ]; let control_panel = container( column![ text("Controls").size(18).color(color_blue), vertical_space(15.0), ctrl_buttons, vertical_space(20.0), text(&state.status_message).size(12).color(color_subtext) ] ) .style(c_style(color_mantle, Color::TRANSPARENT, 0.0)) .padding(15) .width(iced::Length::FillPortion(1)) .height(iced::Length::Fill); let main_layout = row![peers_panel, control_panel].spacing(15); container( column![ top_bar, header_container, vertical_space(15.0), main_layout ] ) .padding(15) .width(iced::Length::Fill) .height(iced::Length::Fill) .style(c_style(color_crust, Color::TRANSPARENT, 0.0)) .into() } }