Files
p2p-chat/src/gui.rs

611 lines
24 KiB
Rust

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<AppCommand>,
// We keep the receiver in the struct to use it in subscription
state_receiver: Arc<Mutex<mpsc::Receiver<FrontendState>>>,
// Voice Chat State
input_devices: Vec<String>,
selected_device: Option<String>,
output_devices: Vec<String>,
selected_output_device: Option<String>,
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<AppCommand>,
pub state_receiver: mpsc::Receiver<FrontendState>,
}
impl ChatApp {
pub fn new(flags: Flags) -> (Self, Task<Message>) {
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<Message> {
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<Message> {
// 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<Message> {
struct BackendSubscription;
let receiver = self.state_receiver.clone();
Subscription::run_with_id(
std::any::TypeId::of::<BackendSubscription>(),
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<Message> {
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<Message> {
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::<String>())
.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<Local> = DateTime::from(dt);
local.format("%H:%M").to_string()
}
_ => "".to_string(),
}
}