611 lines
24 KiB
Rust
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(),
|
|
}
|
|
}
|