From c8caf83355ed10285cd2787309a484bfe9a40e79 Mon Sep 17 00:00:00 2001 From: ReTeamSpeak Date: Wed, 13 May 2026 20:49:58 +0900 Subject: [PATCH] feat: polish connected server voice UI --- src/iced-app/src/theme.rs | 104 +++- src/iced-app/src/view.rs | 1061 ++++++++++++++++++++++++++++--------- 2 files changed, 905 insertions(+), 260 deletions(-) diff --git a/src/iced-app/src/theme.rs b/src/iced-app/src/theme.rs index 455311f..c85c141 100644 --- a/src/iced-app/src/theme.rs +++ b/src/iced-app/src/theme.rs @@ -177,6 +177,14 @@ pub fn nav_icon_color(theme: &Theme) -> Color { text_secondary(theme) } +pub fn status_color(theme: &Theme, connected: bool) -> Color { + if connected { + theme.palette().success + } else { + text_secondary(theme) + } +} + fn border(theme: &Theme) -> Color { if is_dark(theme) { DARK_BORDER @@ -274,11 +282,16 @@ pub fn card_container(theme: &Theme) -> container::Style { pub fn voice_capsule_container(theme: &Theme) -> container::Style { container::Style { - background: Some(Background::Color(elevated(theme))), + background: Some(Background::Color(Color::from_rgba( + elevated(theme).r, + elevated(theme).g, + elevated(theme).b, + if is_dark(theme) { 0.9 } else { 0.96 }, + ))), border: Border { color: border(theme), width: 1.0, - radius: 18.0.into(), + radius: 22.0.into(), }, shadow: Shadow { color: Color::from_rgba(0.0, 0.0, 0.0, if is_dark(theme) { 0.20 } else { 0.08 }), @@ -301,6 +314,24 @@ pub fn elevated_container(theme: &Theme) -> container::Style { } } +pub fn muted_elevated_container(theme: &Theme) -> container::Style { + let background = if is_dark(theme) { + Color::from_rgba(1.0, 1.0, 1.0, 0.04) + } else { + LIGHT_ELEVATED + }; + + container::Style { + background: Some(Background::Color(background)), + border: Border { + color: border(theme), + width: 1.0, + radius: 14.0.into(), + }, + ..container::Style::default() + } +} + pub fn input_style(theme: &Theme, status: text_input::Status) -> text_input::Style { let base = match status { text_input::Status::Focused => accent(theme), @@ -382,6 +413,75 @@ pub fn danger_button(_theme: &Theme, _status: button::Status) -> button::Style { } } +pub fn subtle_button(theme: &Theme, status: button::Status) -> button::Style { + let background = match status { + button::Status::Hovered | button::Status::Pressed => { + if is_dark(theme) { + Color::from_rgba(1.0, 1.0, 1.0, 0.08) + } else { + LIGHT_ELEVATED + } + } + _ => Color::TRANSPARENT, + }; + + button::Style { + background: Some(Background::Color(background)), + text_color: theme.palette().text, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 12.0.into(), + }, + shadow: Shadow::default(), + } +} + +pub fn channel_button_active(theme: &Theme, _status: button::Status) -> button::Style { + button::Style { + background: Some(Background::Color(if is_dark(theme) { + Color::from_rgba(accent(theme).r, accent(theme).g, accent(theme).b, 0.18) + } else { + Color::from_rgba(accent(theme).r, accent(theme).g, accent(theme).b, 0.12) + })), + text_color: theme.palette().text, + border: Border { + color: if is_dark(theme) { + Color::from_rgba(accent(theme).r, accent(theme).g, accent(theme).b, 0.36) + } else { + accent(theme) + }, + width: 1.0, + radius: 12.0.into(), + }, + shadow: Shadow::default(), + } +} + +pub fn channel_button(theme: &Theme, status: button::Status) -> button::Style { + let background = match status { + button::Status::Hovered | button::Status::Pressed => { + if is_dark(theme) { + Color::from_rgba(1.0, 1.0, 1.0, 0.05) + } else { + LIGHT_ELEVATED + } + } + _ => Color::TRANSPARENT, + }; + + button::Style { + background: Some(Background::Color(background)), + text_color: theme.palette().text, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 12.0.into(), + }, + shadow: Shadow::default(), + } +} + pub fn nav_button_active(theme: &Theme, _status: button::Status) -> button::Style { button::Style { background: Some(Background::Color(accent(theme))), diff --git a/src/iced-app/src/view.rs b/src/iced-app/src/view.rs index 1e2d13f..3e8d515 100644 --- a/src/iced-app/src/view.rs +++ b/src/iced-app/src/view.rs @@ -498,158 +498,227 @@ impl App { } pub(crate) fn view_identities_content(&self) -> Element<'_, Message> { + let identity_uid = self + .imported_identity + .as_ref() + .map(|identity| identity.key().to_pub().get_uid().to_string()); + let backup_guidance = identity_uid.clone().unwrap_or_else(|| { + "Import an identity to verify the backup you want to keep.".to_string() + }); + let identity_status = if let Some(identity) = &self.imported_identity { column![ - text("Selected Identity").size(17), + text("Current Identity").size(17), + vertical_space().height(6), + text("Ready for future connections and shown first so you can verify the identity in use.") + .size(13) + .style(text::secondary), + vertical_space().height(18), + text(identity.key().to_pub().get_uid()).size(18), vertical_space().height(8), - text(identity.key().to_pub().get_uid()).size(13), text(format!("Security level {}", identity.level())) .size(12) .style(text::secondary), ] } else { column![ - text("Selected Identity").size(17), - vertical_space().height(8), - text("Default client identity") + text("Current Identity").size(17), + vertical_space().height(6), + text("Using the built-in client identity until you import another one.") .size(13) .style(text::secondary), + vertical_space().height(18), + text("No imported backup selected").size(18), + vertical_space().height(8), + text("A file export is the safest way to move an identity between TeamSpeak clients.") + .size(12) + .style(text::secondary), ] }; - column![ - text("Identities").size(32), - vertical_space().height(8), - text("Manage the identity used for connecting to TeamSpeak servers.") - .size(15) - .style(text::secondary), - vertical_space().height(24), - container( - column![ - identity_status.spacing(0), - vertical_space().height(16), - text("Paste TeamSpeak identity string").size(13), - vertical_space().height(8), - text_input( - "Enter exported identity string...", - &self.identity_string_input - ) - .on_input(Message::IdentityStringChanged) - .style(theme::input_style), - vertical_space().height(8), - button(text("Import From String").size(13)) - .padding([10, 18]) - .style(theme::primary_button) - .on_press(Message::ImportIdentityFromString), - vertical_space().height(16), - text("Or import from file").size(13), - vertical_space().height(8), - text_input( - "Path to exported identity file...", - &self.identity_file_input - ) - .on_input(Message::IdentityFilePathChanged) - .style(theme::input_style), - vertical_space().height(8), - button(text("Import From File").size(13)) - .padding([10, 18]) - .style(theme::secondary_button) - .on_press(Message::ImportIdentityFromFile), + let identity_guidance = container( + column![ + text("Identity Safety").size(17), + vertical_space().height(8), + text("Your identity is your long-term TeamSpeak fingerprint.") + .size(13) + .style(text::secondary), + vertical_space().height(12), + text("Sensitive backup: anyone with an exported identity backup can appear as you on TeamSpeak.") + .size(12) + .style(text::secondary), + vertical_space().height(8), + text("Use raw identity strings only for recovery or migration when a file export is not available.") + .size(12) + .style(text::secondary), + ] + .padding(20) + .spacing(0), + ) + .style(theme::card_container); + + let import_file_card = container( + column![ + text("Import From File").size(17), + vertical_space().height(8), + text("Preferred for normal setup and migration.") + .size(13) + .style(text::secondary), + vertical_space().height(16), + text("Identity File").size(13), + text_input( + "Path to exported identity file...", + &self.identity_file_input + ) + .on_input(Message::IdentityFilePathChanged) + .style(theme::input_style), + vertical_space().height(8), + text("Start with a TeamSpeak identity export from a trusted source.") + .size(12) + .style(text::secondary), + vertical_space().height(12), + button(text("Import From File").size(13)) + .padding([10, 18]) + .style(theme::primary_button) + .on_press(Message::ImportIdentityFromFile), + ] + .padding(20) + .spacing(0), + ) + .style(theme::card_container); + + let export_card = container( + column![ + text("Backup Guidance").size(17), + vertical_space().height(8), + text("Export and backup should stay secondary to the active identity view.") + .size(13) + .style(text::secondary), + vertical_space().height(16), + text(backup_guidance).size(13), + vertical_space().height(8), + text("Store identity backups in a private location and avoid pasting raw identity strings into shared chats or notes.") + .size(12) + .style(text::secondary), + ] + .padding(20) + .spacing(0), + ) + .style(theme::card_container); + + let advanced_import_card = container( + column![ + text("Advanced Recovery").size(17), + vertical_space().height(8), + text("Paste a raw identity string only when you need manual recovery or migration.") + .size(13) + .style(text::secondary), + vertical_space().height(16), + text("Identity String").size(13), + text_input( + "Enter exported identity string...", + &self.identity_string_input + ) + .on_input(Message::IdentityStringChanged) + .style(theme::input_style), + vertical_space().height(8), + text("Double-check the source before importing. This bypasses the safer file-based flow.") + .size(12) + .style(text::secondary), + vertical_space().height(12), + button(text("Import From String").size(13)) + .padding([10, 18]) + .style(theme::secondary_button) + .on_press(Message::ImportIdentityFromString), + ] + .padding(20) + .spacing(0), + ) + .style(theme::card_container); + + scrollable( + column![ + text("Identities").size(32), + vertical_space().height(8), + text("Manage the identity used for connecting to TeamSpeak servers.") + .size(15) + .style(text::secondary), + vertical_space().height(24), + row![ + container(identity_status.spacing(0).padding(20)) + .style(theme::hero_container) + .width(Length::FillPortion(3)), + identity_guidance.width(Length::FillPortion(2)), ] - .padding(20) - .spacing(0), - ) - .style(theme::card_container), - vertical_space().height(16), - self.view_voice_capsule(), - ] - .padding(20) - .spacing(0) + .spacing(16), + vertical_space().height(16), + row![ + import_file_card.width(Length::Fill), + export_card.width(Length::Fill), + ] + .spacing(16), + vertical_space().height(16), + advanced_import_card, + vertical_space().height(16), + self.view_voice_capsule(), + ] + .padding([48, 56]) + .spacing(0), + ) .into() } pub(crate) fn view_server_content(&self) -> Element<'_, Message> { if let Some(idx) = self.selected_bookmark { if let Some(bookmark) = self.bookmarks.get(idx) { - let mut connect_form = column![ - text(bookmark.name.clone()).size(32), - text(format!("{}:{}", bookmark.address, bookmark.port)) - .size(13) - .style(text::secondary), - vertical_space().height(8), - text(if self.connected { - "Connected voice workspace" - } else { - "Connect to join channels, chat, and voice" - }) - .size(15) - .style(text::secondary), - vertical_space().height(16), - text_input("Nickname", &self.nickname) - .on_input(Message::NicknameChanged) - .style(theme::input_style), - vertical_space().height(8), - text_input("Password", &self.password) - .secure(true) - .on_input(Message::PasswordChanged) - .style(theme::input_style), - vertical_space().height(16), - row![ - horizontal_space(), - if self.connected { - button(text("Disconnect").size(13)) - .padding([10, 28]) - .style(theme::danger_button) - .on_press(Message::Disconnect) - } else { - button(text("Connect").size(13)) - .padding([10, 28]) - .style(theme::primary_button) - .on_press(Message::Connect) - }, - ], - ] - .spacing(4) - .padding(24); - - if let Some(err) = &self.error { - connect_form = connect_form.push( - container(text(err.clone()).size(12).style(text::danger)) - .padding(10) - .style(theme::elevated_container), - ); - } - if self.identity_level > 0 { - connect_form = connect_form.push( - text(format!( - "Computing identity level {}...", - self.identity_level - )) - .size(12) - .style(text::secondary), - ); - } - if self.connected { - let mic_label = if self.mic_muted { - "🔇 Mic" + let current_channel = self.current_channel_name().unwrap_or("No channel"); + let connected_text = if self.server_name.is_empty() { + &bookmark.name } else { - "🎤 Mic" + &self.server_name }; - let speaker_label = if self.speaker_muted { - "🔇 Speaker" + let total_channels = self.channels.len(); + let nested_channels = self + .channels + .iter() + .filter(|channel| channel.parent != ChannelId(0)) + .count(); + let active_listeners = self + .clients + .iter() + .filter(|client| !client.input_muted) + .count(); + let channel_members = self + .clients + .iter() + .filter(|client| { + self.own_client_id.and_then(|own_id| { + self.clients + .iter() + .find(|entry| entry.id == own_id) + .map(|entry| entry.channel) + }) == Some(client.channel) + }) + .count(); + let talking_state = if self.talk_mode == audio::TalkMode::PushToTalk { + if self.ptt_active { + "Transmitting" + } else { + "Push-to-talk armed" + } + } else if self.continuous_active { + "Voice detected" } else { - "🔊 Speaker" + "Listening for voice" }; - let headset_label = if self.headset_muted { - "🔇 Headset" - } else { - "🎧 Headset" - }; - let afk_label = if self.afk { "AFK ✓" } else { "AFK" }; - - let control_bar = row![ - button(text(mic_label).size(11)) + let status_line = format!( + "{} online of {} capacity", + self.clients.len(), + self.server_max_clients + ); + let quick_controls = row![ + button(text(if self.mic_muted { "Mic Off" } else { "Mic On" }).size(11)) .padding([6, 12]) .style(if self.mic_muted { theme::danger_button @@ -657,24 +726,37 @@ impl App { theme::secondary_button }) .on_press(Message::ToggleMicMute), - button(text(speaker_label).size(11)) - .padding([6, 12]) - .style(if self.speaker_muted { - theme::danger_button + button( + text(if self.headset_muted { + "Deafened" } else { - theme::secondary_button + "Deafen" }) - .on_press(Message::ToggleSpeakerMute), - button(text(headset_label).size(11)) - .padding([6, 12]) - .style(if self.headset_muted { - theme::danger_button + .size(11) + ) + .padding([6, 12]) + .style(if self.headset_muted { + theme::danger_button + } else { + theme::secondary_button + }) + .on_press(Message::ToggleHeadsetMute), + button( + text(if self.speaker_muted { + "Output Muted" } else { - theme::secondary_button + "Output On" }) - .on_press(Message::ToggleHeadsetMute), - horizontal_space(), - button(text(afk_label).size(11)) + .size(11) + ) + .padding([6, 12]) + .style(if self.speaker_muted { + theme::danger_button + } else { + theme::secondary_button + }) + .on_press(Message::ToggleSpeakerMute), + button(text(if self.afk { "AFK On" } else { "AFK" }).size(11)) .padding([6, 12]) .style(if self.afk { theme::danger_button @@ -688,25 +770,116 @@ impl App { let server_header = container( column![ - row![text(&self.server_name).size(24), horizontal_space(),], + row![ + column![ + row![ + text("CONNECTED").size(11).style(text::secondary), + text("●").size(11).style(|theme: &Theme| text::Style { + color: Some(theme::status_color(theme, true)), + }), + ] + .spacing(6), + vertical_space().height(6), + text(connected_text).size(30), + text(format!("{}:{}", bookmark.address, bookmark.port)) + .size(13) + .style(text::secondary), + ] + .spacing(0), + horizontal_space(), + button(text("Disconnect").size(12)) + .padding([8, 16]) + .style(theme::danger_button) + .on_press(Message::Disconnect), + ] + .align_y(iced::Alignment::Center), + vertical_space().height(16), text(format!( - "{} / {} — {}/{} online", - self.server_platform, - self.server_version, - self.clients.len(), - self.server_max_clients + "{} / {} · {}", + self.server_platform, self.server_version, status_line )) .size(13) .style(text::secondary), - vertical_space().height(8), - control_bar, + vertical_space().height(6), + text(format!( + "Joined {} · Input {} · Output {}", + current_channel, + self.input_label(), + self.output_label() + )) + .size(13) + .style(text::secondary), + vertical_space().height(16), + row![ + container( + column![ + text("Current Channel").size(11).style(text::secondary), + vertical_space().height(6), + text(current_channel).size(18), + text(format!("{} people here", channel_members)) + .size(12) + .style(text::secondary), + ] + .spacing(0) + ) + .padding(14) + .style(theme::elevated_container) + .width(Length::Fill), + container( + column![ + text("Voice Status").size(11).style(text::secondary), + vertical_space().height(6), + text(talking_state).size(18), + text(format!( + "{} · {}", + self.nc_method.label(), + self.output_label() + )) + .size(12) + .style(text::secondary), + ] + .spacing(0) + ) + .padding(14) + .style(theme::elevated_container) + .width(Length::Fill), + container( + column![ + text("Devices").size(11).style(text::secondary), + vertical_space().height(6), + text(self.input_label()).size(18), + text(format!( + "{} · {}", + self.talk_mode_label(), + self.output_label() + )) + .size(12) + .style(text::secondary), + ] + .spacing(0) + ) + .padding(14) + .style(theme::elevated_container) + .width(Length::Fill), + ] + .spacing(12), + vertical_space().height(14), + quick_controls, ] .spacing(4), ) - .padding(16) - .style(theme::card_container); + .padding(20) + .style(theme::hero_container); let search = self.channel_search.trim().to_lowercase(); + let browser_hint = if search.is_empty() { + format!( + "{} channels · {} nested · {} listening", + total_channels, nested_channels, active_listeners + ) + } else { + "Matching channels and users stay expanded while searching.".to_string() + }; let mut browser_list = column![].spacing(8); let mut match_count = 0usize; for root in self.sorted_child_channels(ChannelId(0)) { @@ -735,32 +908,82 @@ impl App { column![ text("Channel Browser").size(17), vertical_space().height(8), + text("Browse the live server tree, see who is where, and join a room without losing voice context.") + .size(12) + .style(text::secondary), + vertical_space().height(12), text_input("Search channels or users...", &self.channel_search) .on_input(Message::ChannelSearchChanged) .style(theme::input_style), vertical_space().height(12), + row![ + text(if search.is_empty() { + "Showing the live server tree." + } else { + "Filtered results" + }) + .size(11) + .style(text::secondary), + horizontal_space(), + text(browser_hint).size(11).style(text::secondary), + ], + vertical_space().height(8), scrollable(browser_list).height(Length::Fill), ] .spacing(0) - .padding(16), + .padding(18), ) .style(theme::card_container) - .height(320); + .height(420); let mut messages_col = column![].spacing(6); for msg in &self.messages { messages_col = messages_col.push( - column![ - text(&msg.invoker_name).size(11).style(text::secondary), - text(&msg.message).size(13), - ] - .spacing(2), + container( + column![ + text(&msg.invoker_name).size(11).style(text::secondary), + vertical_space().height(4), + text(&msg.message).size(13), + ] + .spacing(0), + ) + .padding(12) + .style(theme::muted_elevated_container), + ); + } + + if self.messages.is_empty() { + messages_col = messages_col.push( + container( + column![ + text("No messages yet").size(13), + vertical_space().height(6), + text("Channel chat stays available here without taking focus away from voice.") + .size(12) + .style(text::secondary), + ] + .spacing(0), + ) + .padding(14) + .style(theme::muted_elevated_container), ); } let messages_panel = container( column![ - text("Chat").size(13), + text("Chat").size(17), + vertical_space().height(8), + text("Keep up with the current channel conversation.") + .size(12) + .style(text::secondary), + vertical_space().height(12), + row![ + text(current_channel).size(11).style(text::secondary), + horizontal_space(), + text(format!("{} messages", self.messages.len())) + .size(11) + .style(text::secondary), + ], vertical_space().height(8), scrollable(messages_col).height(Length::Fill), vertical_space().height(8), @@ -778,34 +1001,84 @@ impl App { .spacing(8), ] .spacing(4) - .padding(12), + .padding(18), ) .style(theme::card_container) .height(Length::Fill); - column![ - container(connect_form).style(theme::hero_container), - vertical_space().height(16), - server_header, - vertical_space().height(16), - browser_panel, - vertical_space().height(16), - messages_panel, - vertical_space().height(16), - self.view_voice_capsule(), - ] - .spacing(0) - .padding(20) + scrollable( + column![ + server_header, + vertical_space().height(16), + row![ + browser_panel.width(Length::FillPortion(3)), + messages_panel.width(Length::FillPortion(2)), + ] + .spacing(16), + vertical_space().height(16), + self.view_voice_capsule(), + ] + .spacing(0) + .padding([48, 56]) + .max_width(1100), + ) .into() } else { - column![ - container(connect_form).style(theme::hero_container), + let mut connect_card = column![ + text(bookmark.name.clone()).size(32), + text(format!("{}:{}", bookmark.address, bookmark.port)) + .size(13) + .style(text::secondary), + vertical_space().height(8), + text("Connect to join channels, chat, and voice") + .size(15) + .style(text::secondary), vertical_space().height(16), - self.view_voice_capsule(), + text("Nickname").size(13), + text_input("Nickname", &self.nickname) + .on_input(Message::NicknameChanged) + .style(theme::input_style), + vertical_space().height(8), + text("Password").size(13), + text_input("Password", &self.password) + .secure(true) + .on_input(Message::PasswordChanged) + .style(theme::input_style), + vertical_space().height(16), + row![ + horizontal_space(), + button(text("Connect").size(13)) + .padding([10, 28]) + .style(theme::primary_button) + .on_press(Message::Connect), + ], ] - .padding(20) - .spacing(0) - .into() + .spacing(4) + .padding(24); + + if let Some(err) = &self.error { + connect_card = connect_card.push( + container(text(err.clone()).size(12).style(text::danger)) + .padding(10) + .style(theme::elevated_container), + ); + } + + if self.identity_level > 0 { + connect_card = connect_card.push( + text(format!( + "Computing identity level {}...", + self.identity_level + )) + .size(12) + .style(text::secondary), + ); + } + + container(connect_card) + .style(theme::hero_container) + .max_width(880) + .into() } } else { self.view_welcome() @@ -814,7 +1087,6 @@ impl App { self.view_welcome() } } - pub(crate) fn view_settings_content(&self) -> Element<'_, Message> { let outputs = { let mut values = vec!["System Default".to_string()]; @@ -849,7 +1121,7 @@ impl App { .style(text::secondary), vertical_space().height(16), text("Output Device").size(13), - pick_list(outputs, selected_output, |value| { + pick_list(outputs.clone(), selected_output.clone(), |value| { Message::SetOutputDevice(if value == "System Default" { String::new() } else { @@ -885,6 +1157,61 @@ impl App { ) .style(theme::card_container); + let settings_overview = container( + column![ + text("Overview").size(17), + vertical_space().height(8), + text("Keep voice, devices, and identity details calm and easy to verify at a glance.") + .size(13) + .style(text::secondary), + vertical_space().height(16), + row![ + container( + column![ + text("Theme").size(11).style(text::secondary), + vertical_space().height(6), + text(self.appearance_mode.label()).size(16), + ] + .spacing(0), + ) + .padding(14) + .style(theme::muted_elevated_container) + .width(Length::Fill), + container( + column![ + text("Talk Mode").size(11).style(text::secondary), + vertical_space().height(6), + text(self.talk_mode_label()).size(16), + ] + .spacing(0), + ) + .padding(14) + .style(theme::muted_elevated_container) + .width(Length::Fill), + container( + column![ + text("Identity").size(11).style(text::secondary), + vertical_space().height(6), + text(if self.imported_identity.is_some() { + "Imported" + } else { + "Built-in" + }) + .size(16), + ] + .spacing(0), + ) + .padding(14) + .style(theme::muted_elevated_container) + .width(Length::Fill), + ] + .spacing(12), + ] + .spacing(0) + .padding(20), + ) + .style(theme::card_container); + let voice_col = column![ text("Talk Mode").size(13), text("Choose how your microphone starts transmitting.") @@ -1034,7 +1361,7 @@ impl App { column![ text("TeamSpeak Identity").size(17), vertical_space().height(8), - text("Import from an identity string or export file.") + text("Keep identity import available in Settings without making it the primary voice flow.") .size(13) .style(text::secondary), vertical_space().height(16), @@ -1056,6 +1383,193 @@ impl App { ) .style(theme::card_container); + let general_card = container( + column![ + text("Startup And Connection").size(17), + vertical_space().height(8), + text("Current defaults keep the client focused on fast reconnects and quick return to voice.") + .size(13) + .style(text::secondary), + vertical_space().height(16), + container( + column![ + text("Default nickname").size(13), + vertical_space().height(6), + text(if self.nickname.is_empty() { + "Uses ReTeamSpeak until a bookmark nickname override is set." + } else { + &self.nickname + }) + .size(12) + .style(text::secondary), + ] + .spacing(0), + ) + .padding(14) + .style(theme::muted_elevated_container), + vertical_space().height(12), + container( + column![ + text("Current voice summary").size(13), + vertical_space().height(6), + text(self.voice_summary_line()).size(12).style(text::secondary), + ] + .spacing(0), + ) + .padding(14) + .style(theme::muted_elevated_container), + ] + .spacing(0) + .padding(20), + ) + .style(theme::card_container); + + let playback_card = container( + column![ + text("Playback").size(17), + vertical_space().height(8), + text("Choose where server audio plays and how output behaves locally.") + .size(13) + .style(text::secondary), + vertical_space().height(16), + text("Output Device").size(13), + pick_list(outputs.clone(), selected_output.clone(), |value| { + Message::SetOutputDevice(if value == "System Default" { + String::new() + } else { + value + }) + }) + .placeholder("Choose an output device") + .width(Length::Fill) + .padding([10, 12]), + vertical_space().height(8), + text(if self.headset_muted { + "Output is currently deafened from the active voice controls." + } else { + "Output follows your selected device unless you deafen locally." + }) + .size(12) + .style(text::secondary), + ] + .spacing(0) + .padding(20), + ) + .style(theme::card_container); + + let hotkeys_card = container( + column![ + text("Hotkeys").size(17), + vertical_space().height(8), + text("The core voice shortcuts are fixed for now so the active server screen stays predictable.") + .size(13) + .style(text::secondary), + vertical_space().height(16), + container( + column![ + text("Push-to-talk").size(13), + vertical_space().height(6), + text("V (hold to talk)").size(12).style(text::secondary), + ] + .spacing(0), + ) + .padding(14) + .style(theme::muted_elevated_container), + vertical_space().height(12), + container( + column![ + text("Quick voice actions").size(13), + vertical_space().height(6), + text("Mute mic, deafen, and AFK stay available from the compact voice capsule and connected server header.") + .size(12) + .style(text::secondary), + ] + .spacing(0), + ) + .padding(14) + .style(theme::muted_elevated_container), + ] + .spacing(0) + .padding(20), + ) + .style(theme::card_container); + + let notifications_card = container( + column![ + text("Notifications").size(17), + vertical_space().height(8), + text("Connection and message alerts remain intentionally lightweight in this voice-first build.") + .size(13) + .style(text::secondary), + vertical_space().height(16), + container( + column![ + text("Planned alert surfaces").size(13), + vertical_space().height(6), + text("Connection loss, direct messages, and mention-style chat events belong here next.") + .size(12) + .style(text::secondary), + ] + .spacing(0), + ) + .padding(14) + .style(theme::muted_elevated_container), + ] + .spacing(0) + .padding(20), + ) + .style(theme::card_container); + + let chat_card = container( + column![ + text("Chat").size(17), + vertical_space().height(8), + text("Chat stays secondary to voice, with lightweight composition and readable channel history.") + .size(13) + .style(text::secondary), + vertical_space().height(16), + container( + column![ + text("Current behavior").size(13), + vertical_space().height(6), + text("Channel chat is available on connected server screens. Unread states and richer conversation controls remain future polish work.") + .size(12) + .style(text::secondary), + ] + .spacing(0), + ) + .padding(14) + .style(theme::muted_elevated_container), + ] + .spacing(0) + .padding(20), + ) + .style(theme::card_container); + + let advanced_card = container( + column![ + text("Advanced").size(17), + vertical_space().height(8), + text("Low-level audio processing choices stay here so the main settings flow remains calm and user-facing.") + .size(13) + .style(text::secondary), + vertical_space().height(16), + container( + column![ + text("Noise cleanup backend").size(13), + vertical_space().height(6), + text(self.nc_method.label()).size(12).style(text::secondary), + ] + .spacing(0), + ) + .padding(14) + .style(theme::muted_elevated_container), + ] + .spacing(0) + .padding(20), + ) + .style(theme::card_container); + let appearance_card = container( column![ text("Appearance").size(17), @@ -1083,58 +1597,68 @@ impl App { .on_press(Message::SetAppearance(theme::AppearanceMode::Dark)), ] .spacing(8), + vertical_space().height(16), + row![ + container( + column![ + text("Sidebar").size(11).style(text::secondary), + vertical_space().height(6), + text("Finder-style").size(14), + ] + .spacing(0), + ) + .padding(14) + .style(theme::muted_elevated_container) + .width(Length::Fill), + container( + column![ + text("Voice surfaces").size(11).style(text::secondary), + vertical_space().height(6), + text("Compact pills").size(14), + ] + .spacing(0), + ) + .padding(14) + .style(theme::muted_elevated_container) + .width(Length::Fill), + ] + .spacing(12), + vertical_space().height(12), + container( + column![ + text("Visual direction").size(13), + vertical_space().height(6), + text("The current dark theme keeps near-black backgrounds, rounded surfaces, and a calm blue accent for the connected voice workflow.") + .size(12) + .style(text::secondary), + ] + .spacing(0), + ) + .padding(14) + .style(theme::muted_elevated_container), ] .spacing(0) .padding(20), ) .style(theme::card_container); - let placeholder_card = |title: &'static str, body: &'static str| { - container( - column![ - text(title).size(17), - vertical_space().height(8), - text(body).size(13).style(text::secondary), - ] - .spacing(0) - .padding(20), - ) - .style(theme::card_container) - }; - let settings_content: Element<'_, Message> = match self.settings_section { - SettingsSection::General => placeholder_card( - "General", - "General client preferences will live here as the settings layout is expanded.", - ) - .into(), + SettingsSection::General => { + column![settings_overview, vertical_space().height(16), general_card] + .spacing(0) + .into() + } SettingsSection::Audio => column![audio_card, vertical_space().height(16), voice_card] .spacing(0) .into(), SettingsSection::Capture => voice_card.into(), - SettingsSection::Playback => audio_card.into(), - SettingsSection::Hotkeys => placeholder_card( - "Hotkeys", - "Push-to-talk, mute, and deafen shortcuts are planned for this section.", - ) - .into(), - SettingsSection::Notifications => placeholder_card( - "Notifications", - "Connection, private message, and voice-state alerts will be grouped here.", - ) - .into(), + SettingsSection::Playback => playback_card.into(), + SettingsSection::Hotkeys => hotkeys_card.into(), + SettingsSection::Notifications => notifications_card.into(), SettingsSection::Appearance => appearance_card.into(), - SettingsSection::Chat => placeholder_card( - "Chat", - "Chat presentation and unread behavior will move into this focused section.", - ) - .into(), + SettingsSection::Chat => chat_card.into(), SettingsSection::Identities => identity_card.into(), - SettingsSection::Advanced => placeholder_card( - "Advanced", - "Advanced diagnostics and lower-level behavior stay out of the primary flow.", - ) - .into(), + SettingsSection::Advanced => advanced_card.into(), }; let settings_nav = @@ -1191,20 +1715,34 @@ impl App { } pub(crate) fn view_voice_capsule(&self) -> Element<'_, Message> { + let theme_value = self.theme(); + let status_color = theme::status_color(&theme_value, self.connected); + let connection = if self.connected { + self.connection_line() + } else { + "Disconnected".to_string() + }; + let channel = self.current_channel_name().unwrap_or("No channel"); + let talk_state = if self.talk_mode == audio::TalkMode::PushToTalk { + if self.ptt_active { + "Talking" + } else { + "Push-to-Talk" + } + } else if self.continuous_active { + "Voice Active" + } else { + "Voice Activation" + }; let mut actions = row![ button( row![ icons::view( icons::Icon::Microphone, 14.0, - theme::icon_color(&self.theme()) + theme::icon_color(&theme_value) ), - text(if self.mic_muted { - "Unmute Mic" - } else { - "Mute Mic" - }) - .size(11), + text(if self.mic_muted { "Unmute" } else { "Mute" }).size(11), ] .spacing(6), ) @@ -1224,10 +1762,10 @@ impl App { icons::Icon::SpeakerWave }, 14.0, - theme::icon_color(&self.theme()), + theme::icon_color(&theme_value), ), text(if self.headset_muted { - "Undeafen" + "Listen" } else { "Deafen" }) @@ -1258,21 +1796,20 @@ impl App { row![ column![ row![ - icons::view( - if self.connected { - icons::Icon::SpeakerWave - } else { - icons::Icon::SpeakerXMark - }, - 16.0, - theme::icon_color(&self.theme()), - ), - text(self.connection_line()).size(15), + text("●").size(12).style(move |_theme: &Theme| text::Style { + color: Some(status_color), + }), + text(connection).size(15), ] .spacing(8), - text(self.voice_summary_line()) - .size(12) - .style(text::secondary), + text(format!( + "{} · {} · {}", + channel, + self.input_label(), + talk_state + )) + .size(12) + .style(text::secondary), ] .spacing(4) .width(Length::Fill), @@ -1280,9 +1817,10 @@ impl App { ] .align_y(iced::Alignment::Center) .spacing(16) - .padding(16), + .padding([14, 16]), ) .style(theme::voice_capsule_container) + .max_width(720) .into() } @@ -1423,6 +1961,7 @@ impl App { has_children && !force_expanded && self.collapsed_channels.contains(&channel.id.0); let indent = 12 + (depth as u16 * 20); + let channel_theme = self.theme(); let mut section = column![button( row![ if has_children { @@ -1433,25 +1972,25 @@ impl App { icons::Icon::ChevronDown }, 14.0, - theme::icon_color(&self.theme()), + theme::icon_color(&channel_theme), )) } else { container(horizontal_space().width(14)).into() }, text(channel.name.clone()).size(13).width(Length::Fill), - text(format!("{} users", matching_clients.len())) + text(format!("{}", matching_clients.len())) .size(11) .style(text::secondary), ] .spacing(8) - .padding([2, 0]), + .align_y(iced::Alignment::Center), ) .width(Length::Fill) - .padding([8, indent]) + .padding([9, indent]) .style(if is_current { - theme::bookmark_button_selected + theme::channel_button_active } else { - theme::bookmark_button + theme::channel_button }) .on_press(if has_children { Message::ToggleChannelCollapsed(channel.id) @@ -1487,33 +2026,39 @@ impl App { icons::Icon::User }, 14.0, - theme::icon_color(&self.theme()), + theme::icon_color(&channel_theme), ), - text(client.name.clone()).size(12).width(Length::Fill), + column![ + text(client.name.clone()).size(12), + text(state).size(11).style(text::secondary), + ] + .spacing(2) + .width(Length::Fill), text(format!("{:.0}%", volume * 100.0)) .size(11) .style(text::secondary), button(text("-").size(10)) .padding([2, 6]) - .style(theme::secondary_button) + .style(theme::subtle_button) .on_press(Message::DecreaseClientVolume(client.id)), button(text(if locally_muted { "Unmute" } else { "Mute" }).size(10)) - .padding([2, 6]) + .padding([2, 8]) .style(if locally_muted { theme::danger_button } else { - theme::secondary_button + theme::subtle_button }) .on_press(Message::ToggleLocalClientMute(client.id)), button(text("+").size(10)) .padding([2, 6]) - .style(theme::secondary_button) + .style(theme::subtle_button) .on_press(Message::IncreaseClientVolume(client.id)), - text(state).size(11).style(text::secondary), ] - .spacing(8), + .spacing(8) + .align_y(iced::Alignment::Center), ) - .padding([2, indent + 20]), + .padding([7, indent + 22]) + .style(theme::muted_elevated_container), ); }