The viewer playback settings (hardware decode + buffering) only shaped mpv's argv; vlc_args() was fixed, so a VLC viewer silently ignored them. The load-bearing case is hardware decode: mpv defaults to software decode (the A-bug fix), but VLC hardware-decodes by default, so a VLC viewer with the default hardware_decode=false still got GPU decode and could hit the frame-1 freeze the default exists to avoid — the toggle did nothing. vlc_args() now takes the settings and maps the knobs that translate cleanly to VLC: hardware decode (--avcodec-hw=none/any) and buffering posture (network/live caching ms). The genuinely mpv-specific knobs (cache_mb byte-cache, extra_mpv_args) stay mpv-only; the Settings UI hints are reworded to say which knobs are mpv-only vs universal. +2 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
10028 lines
404 KiB
Rust
10028 lines
404 KiB
Rust
use crate::audio::clip_player::{
|
||
ClipPlayer, SharedClipStatus, format_time as format_clip_time, progress as clip_progress,
|
||
seek_target, status_snapshot,
|
||
};
|
||
use crate::audio::eq::{EQ_GAIN_DB_MAX, EQ_GAIN_DB_MIN, EqSettings};
|
||
use crate::audio::{AudioDevice, enumerate_audio_devices};
|
||
use crate::config::{
|
||
AppConfig, AudioProfile, NetworkMode, RecordingMode, RoomLayout, ShareBuffering, SharePlayer,
|
||
ShareQuality,
|
||
};
|
||
use crate::core::{
|
||
CoreController,
|
||
messages::{CoreCommand, UiEvent},
|
||
};
|
||
use crate::hotkeys::{HotkeyAction, HotkeyContext, KeyBinding, format_binding};
|
||
use crate::network::PeerState;
|
||
use crate::notify::{self, Sound};
|
||
use crate::presence::PresenceMode;
|
||
use crate::theme::{AppTheme, Palette};
|
||
use crate::widget::context_input::{context_input, locked_value};
|
||
use crate::widget::selectable_text::selectable_rich_text;
|
||
|
||
use iced::widget::canvas::{Action, Frame, Geometry, Path, Program};
|
||
use iced::widget::text_input;
|
||
use iced::widget::{
|
||
Canvas, Column, button, canvas, checkbox, column, container, mouse_area, pick_list,
|
||
progress_bar, radio, responsive, row, scrollable, slider, span, stack, text, tooltip,
|
||
};
|
||
use iced::{
|
||
Background, Border, Color, Element, Event, Point, Rectangle, Renderer, Size, Subscription,
|
||
Task, Theme, keyboard, mouse,
|
||
};
|
||
use iroh::EndpointId;
|
||
use std::collections::{HashMap, HashSet, VecDeque};
|
||
use std::sync::{Arc, OnceLock};
|
||
use tokio::sync::Mutex;
|
||
|
||
static UI_RX: OnceLock<Mutex<Option<tokio::sync::mpsc::Receiver<UiEvent>>>> = OnceLock::new();
|
||
|
||
const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||
const GIT_SHORT: &str = env!("PEERSPEAK_GIT_SHORT");
|
||
|
||
fn app_build_label() -> String {
|
||
if GIT_SHORT == "unknown" {
|
||
format!("PeerSpeak v{APP_VERSION}")
|
||
} else {
|
||
format!("PeerSpeak v{APP_VERSION} ({GIT_SHORT})")
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum Screen {
|
||
Home,
|
||
Room,
|
||
Settings,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum SettingsCategory {
|
||
Audio,
|
||
Hotkeys,
|
||
Recording,
|
||
Profile,
|
||
Appearance,
|
||
Network,
|
||
Advanced,
|
||
Notifications,
|
||
Games,
|
||
}
|
||
|
||
impl SettingsCategory {
|
||
const ALL: [SettingsCategory; 9] = [
|
||
SettingsCategory::Audio,
|
||
SettingsCategory::Hotkeys,
|
||
SettingsCategory::Recording,
|
||
SettingsCategory::Profile,
|
||
SettingsCategory::Appearance,
|
||
SettingsCategory::Network,
|
||
SettingsCategory::Advanced,
|
||
SettingsCategory::Notifications,
|
||
SettingsCategory::Games,
|
||
];
|
||
|
||
fn label(self) -> &'static str {
|
||
match self {
|
||
SettingsCategory::Audio => "Audio",
|
||
SettingsCategory::Hotkeys => "Hotkeys",
|
||
SettingsCategory::Recording => "Recording",
|
||
SettingsCategory::Profile => "Profile",
|
||
SettingsCategory::Appearance => "Appearance",
|
||
SettingsCategory::Network => "Network",
|
||
SettingsCategory::Advanced => "Advanced",
|
||
SettingsCategory::Notifications => "Notifications",
|
||
SettingsCategory::Games => "Games",
|
||
}
|
||
}
|
||
|
||
fn hint(self) -> &'static str {
|
||
match self {
|
||
SettingsCategory::Audio => "Devices, mic gate, echo",
|
||
SettingsCategory::Hotkeys => "Focused keyboard shortcuts",
|
||
SettingsCategory::Recording => "Mixed and stem capture",
|
||
SettingsCategory::Profile => "Avatar and identity",
|
||
SettingsCategory::Appearance => "Layout and theme",
|
||
SettingsCategory::Network => "Relay and privacy mode",
|
||
SettingsCategory::Advanced => "Screen sharing",
|
||
SettingsCategory::Notifications => "Chimes and sounds",
|
||
SettingsCategory::Games => "Detection, presence, backgrounds",
|
||
}
|
||
}
|
||
}
|
||
|
||
impl std::fmt::Display for SettingsCategory {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
f.write_str(self.label())
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum ShareMaxHeightChoice {
|
||
Source,
|
||
P720,
|
||
P1080,
|
||
P1440,
|
||
}
|
||
|
||
impl ShareMaxHeightChoice {
|
||
const ALL: [ShareMaxHeightChoice; 4] = [
|
||
ShareMaxHeightChoice::Source,
|
||
ShareMaxHeightChoice::P720,
|
||
ShareMaxHeightChoice::P1080,
|
||
ShareMaxHeightChoice::P1440,
|
||
];
|
||
|
||
fn from_config(value: Option<u32>) -> Self {
|
||
match value {
|
||
Some(720) => ShareMaxHeightChoice::P720,
|
||
Some(1080) => ShareMaxHeightChoice::P1080,
|
||
Some(1440) => ShareMaxHeightChoice::P1440,
|
||
_ => ShareMaxHeightChoice::Source,
|
||
}
|
||
}
|
||
|
||
fn to_config(self) -> Option<u32> {
|
||
match self {
|
||
ShareMaxHeightChoice::Source => None,
|
||
ShareMaxHeightChoice::P720 => Some(720),
|
||
ShareMaxHeightChoice::P1080 => Some(1080),
|
||
ShareMaxHeightChoice::P1440 => Some(1440),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl std::fmt::Display for ShareMaxHeightChoice {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
f.write_str(match self {
|
||
ShareMaxHeightChoice::Source => "Source",
|
||
ShareMaxHeightChoice::P720 => "720p",
|
||
ShareMaxHeightChoice::P1080 => "1080p",
|
||
ShareMaxHeightChoice::P1440 => "1440p",
|
||
})
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum ShareFramerateChoice {
|
||
Preset,
|
||
Fps15,
|
||
Fps24,
|
||
Fps30,
|
||
Fps60,
|
||
}
|
||
|
||
impl ShareFramerateChoice {
|
||
const ALL: [ShareFramerateChoice; 5] = [
|
||
ShareFramerateChoice::Preset,
|
||
ShareFramerateChoice::Fps15,
|
||
ShareFramerateChoice::Fps24,
|
||
ShareFramerateChoice::Fps30,
|
||
ShareFramerateChoice::Fps60,
|
||
];
|
||
|
||
fn from_config(value: Option<u32>) -> Self {
|
||
match value {
|
||
Some(15) => ShareFramerateChoice::Fps15,
|
||
Some(24) => ShareFramerateChoice::Fps24,
|
||
Some(30) => ShareFramerateChoice::Fps30,
|
||
Some(60) => ShareFramerateChoice::Fps60,
|
||
_ => ShareFramerateChoice::Preset,
|
||
}
|
||
}
|
||
|
||
fn to_config(self) -> Option<u32> {
|
||
match self {
|
||
ShareFramerateChoice::Preset => None,
|
||
ShareFramerateChoice::Fps15 => Some(15),
|
||
ShareFramerateChoice::Fps24 => Some(24),
|
||
ShareFramerateChoice::Fps30 => Some(30),
|
||
ShareFramerateChoice::Fps60 => Some(60),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl std::fmt::Display for ShareFramerateChoice {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
f.write_str(match self {
|
||
ShareFramerateChoice::Preset => "Preset default",
|
||
ShareFramerateChoice::Fps15 => "15 fps",
|
||
ShareFramerateChoice::Fps24 => "24 fps",
|
||
ShareFramerateChoice::Fps30 => "30 fps",
|
||
ShareFramerateChoice::Fps60 => "60 fps",
|
||
})
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum ShareMaxViewersChoice {
|
||
Auto,
|
||
One,
|
||
Two,
|
||
Three,
|
||
Four,
|
||
Five,
|
||
Six,
|
||
Seven,
|
||
Eight,
|
||
}
|
||
|
||
impl ShareMaxViewersChoice {
|
||
const ALL: [ShareMaxViewersChoice; 9] = [
|
||
ShareMaxViewersChoice::Auto,
|
||
ShareMaxViewersChoice::One,
|
||
ShareMaxViewersChoice::Two,
|
||
ShareMaxViewersChoice::Three,
|
||
ShareMaxViewersChoice::Four,
|
||
ShareMaxViewersChoice::Five,
|
||
ShareMaxViewersChoice::Six,
|
||
ShareMaxViewersChoice::Seven,
|
||
ShareMaxViewersChoice::Eight,
|
||
];
|
||
|
||
fn from_config(value: Option<u32>) -> Self {
|
||
match value {
|
||
Some(1) => ShareMaxViewersChoice::One,
|
||
Some(2) => ShareMaxViewersChoice::Two,
|
||
Some(3) => ShareMaxViewersChoice::Three,
|
||
Some(4) => ShareMaxViewersChoice::Four,
|
||
Some(5) => ShareMaxViewersChoice::Five,
|
||
Some(6) => ShareMaxViewersChoice::Six,
|
||
Some(7) => ShareMaxViewersChoice::Seven,
|
||
Some(8) => ShareMaxViewersChoice::Eight,
|
||
_ => ShareMaxViewersChoice::Auto,
|
||
}
|
||
}
|
||
|
||
fn to_config(self) -> Option<u32> {
|
||
match self {
|
||
ShareMaxViewersChoice::Auto => None,
|
||
ShareMaxViewersChoice::One => Some(1),
|
||
ShareMaxViewersChoice::Two => Some(2),
|
||
ShareMaxViewersChoice::Three => Some(3),
|
||
ShareMaxViewersChoice::Four => Some(4),
|
||
ShareMaxViewersChoice::Five => Some(5),
|
||
ShareMaxViewersChoice::Six => Some(6),
|
||
ShareMaxViewersChoice::Seven => Some(7),
|
||
ShareMaxViewersChoice::Eight => Some(8),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl std::fmt::Display for ShareMaxViewersChoice {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
f.write_str(match self {
|
||
ShareMaxViewersChoice::Auto => "Auto",
|
||
ShareMaxViewersChoice::One => "1",
|
||
ShareMaxViewersChoice::Two => "2",
|
||
ShareMaxViewersChoice::Three => "3",
|
||
ShareMaxViewersChoice::Four => "4",
|
||
ShareMaxViewersChoice::Five => "5",
|
||
ShareMaxViewersChoice::Six => "6",
|
||
ShareMaxViewersChoice::Seven => "7",
|
||
ShareMaxViewersChoice::Eight => "8",
|
||
})
|
||
}
|
||
}
|
||
|
||
const SHARE_CACHE_MB_OPTIONS: [u32; 8] = [1, 2, 4, 8, 16, 32, 64, 128];
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
enum HomeLayoutMode {
|
||
FocusedEmpty,
|
||
ThreeColumn,
|
||
Stacked,
|
||
}
|
||
|
||
fn home_layout_mode(width: f32, has_recents: bool, has_friends: bool) -> HomeLayoutMode {
|
||
if width < 900.0 {
|
||
HomeLayoutMode::Stacked
|
||
} else if !has_recents && !has_friends {
|
||
HomeLayoutMode::FocusedEmpty
|
||
} else if width >= 1280.0 {
|
||
HomeLayoutMode::ThreeColumn
|
||
} else {
|
||
HomeLayoutMode::Stacked
|
||
}
|
||
}
|
||
|
||
/// One rendered room-chat line. `mine` distinguishes our own (locally echoed)
|
||
/// messages from peers' for colouring.
|
||
#[derive(Debug, Clone)]
|
||
struct ChatEntry {
|
||
name: String,
|
||
text: String,
|
||
mine: bool,
|
||
/// Sender's node id string, used to key their avatar colour (W4). `None` only
|
||
/// for any future system-generated lines.
|
||
from: Option<String>,
|
||
/// Optional file attachment descriptor. The bytes (if fetched) live in
|
||
/// `AppState.attachments` keyed by `(author, attachment.id)`; the entry only
|
||
/// holds the descriptor so history stays cheap.
|
||
attachment: Option<crate::files::ChatAttachment>,
|
||
}
|
||
|
||
/// Fetch state of a chat attachment's bytes (session-only).
|
||
#[derive(Debug, Clone)]
|
||
enum AttachmentState {
|
||
/// Bytes in hand (image decoded-valid, or a file ready to save).
|
||
Ready(Vec<u8>),
|
||
/// Fetch or decode failed; carries a short reason for the UI.
|
||
Failed(String),
|
||
}
|
||
|
||
/// Identifies one fetched attachment by BOTH the authoring peer and the
|
||
/// attachment id. The id is attacker-chosen, so a malicious peer can reuse a
|
||
/// victim's id to alias displayed/saved content; keying on the author too means
|
||
/// each chat line resolves only its own sender's bytes (Tier C F-12).
|
||
type AttachmentKey = (EndpointId, crate::files::AttachmentId);
|
||
|
||
/// Cap on retained attachment results so an insider streaming distinct
|
||
/// attachments can't grow the cache without bound (Tier C F-02). Sized well
|
||
/// above any realistic on-screen image working set.
|
||
const ATTACHMENT_CACHE_CAP: usize = 64;
|
||
|
||
/// One cached attachment result: its fetch state plus, for ready images, the
|
||
/// pre-built iced image handle (built once on arrival, not per redraw — the
|
||
/// e917c53 flicker fix). `handle` is `None` for files and failures.
|
||
#[derive(Debug, Clone)]
|
||
struct AttachmentEntry {
|
||
state: AttachmentState,
|
||
handle: Option<iced::widget::image::Handle>,
|
||
}
|
||
|
||
/// Bounded store of fetched chat-attachment results, keyed by [`AttachmentKey`].
|
||
///
|
||
/// Eviction is insertion-order (oldest first), NOT true LRU: iced's `view`
|
||
/// borrows `&self`, so the render read path cannot reorder an access-ordered
|
||
/// cache. With a generous cap the newest entries — the ones actually on screen —
|
||
/// are always retained, so a normal session never evicts; only an abusive stream
|
||
/// of distinct attachments hits the bound (Tier C F-02).
|
||
///
|
||
/// Construct via [`AttachmentCache::new`] — there is deliberately no `Default`,
|
||
/// because a zero cap would make `insert` evict endlessly.
|
||
#[derive(Debug)]
|
||
struct AttachmentCache {
|
||
entries: HashMap<AttachmentKey, AttachmentEntry>,
|
||
/// Keys in insertion order; the front is the eviction candidate. Holds
|
||
/// exactly the present keys (one entry each), so it is bounded by `cap`.
|
||
order: VecDeque<AttachmentKey>,
|
||
cap: usize,
|
||
}
|
||
|
||
impl AttachmentCache {
|
||
fn new(cap: usize) -> Self {
|
||
Self {
|
||
entries: HashMap::new(),
|
||
order: VecDeque::new(),
|
||
cap: cap.max(1),
|
||
}
|
||
}
|
||
|
||
/// Insert or replace an entry. A brand-new key evicts the oldest entries
|
||
/// until there is room; replacing an existing key keeps its position (and so
|
||
/// its age), only updating the value.
|
||
fn insert(
|
||
&mut self,
|
||
key: AttachmentKey,
|
||
state: AttachmentState,
|
||
handle: Option<iced::widget::image::Handle>,
|
||
) {
|
||
if !self.entries.contains_key(&key) {
|
||
while self.entries.len() >= self.cap {
|
||
match self.order.pop_front() {
|
||
Some(old) => {
|
||
self.entries.remove(&old);
|
||
}
|
||
None => break,
|
||
}
|
||
}
|
||
self.order.push_back(key);
|
||
}
|
||
self.entries.insert(key, AttachmentEntry { state, handle });
|
||
}
|
||
|
||
fn get(&self, key: &AttachmentKey) -> Option<&AttachmentState> {
|
||
self.entries.get(key).map(|e| &e.state)
|
||
}
|
||
|
||
fn handle(&self, key: &AttachmentKey) -> Option<&iced::widget::image::Handle> {
|
||
self.entries.get(key).and_then(|e| e.handle.as_ref())
|
||
}
|
||
|
||
fn is_ready(&self, key: &AttachmentKey) -> bool {
|
||
matches!(
|
||
self.entries.get(key),
|
||
Some(AttachmentEntry {
|
||
state: AttachmentState::Ready(_),
|
||
..
|
||
})
|
||
)
|
||
}
|
||
|
||
fn clear(&mut self) {
|
||
self.entries.clear();
|
||
self.order.clear();
|
||
}
|
||
|
||
#[cfg(test)]
|
||
fn len(&self) -> usize {
|
||
self.entries.len()
|
||
}
|
||
}
|
||
|
||
/// Cap on retained chat history so a long call can't grow it without bound.
|
||
const CHAT_HISTORY_MAX: usize = 300;
|
||
|
||
/// Which room-screen divider a drag is resizing.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum DividerKind {
|
||
/// Vertical divider between the Participants and Controls panels (resizes the
|
||
/// Participants panel width).
|
||
Panels,
|
||
/// Horizontal divider between the main row and the Chat dock (resizes the
|
||
/// Chat dock height).
|
||
Chat,
|
||
/// Vertical divider between Chat and Controls in the 3-column layout (resizes
|
||
/// the Controls panel width).
|
||
Controls,
|
||
/// Vertical divider on the left edge of the Chat drawer (resizes the drawer
|
||
/// width) in the drawer layout.
|
||
ChatDrawer,
|
||
/// Vertical divider on the left edge of the Playlist drawer (resizes the
|
||
/// drawer width).
|
||
PlaylistDrawer,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum EqBand {
|
||
Low,
|
||
Mid,
|
||
High,
|
||
}
|
||
|
||
/// Minimum width of the Participants panel (px).
|
||
const PARTICIPANTS_MIN_W: f32 = 200.0;
|
||
/// Minimum width reserved for the Controls panel when resizing Participants (px).
|
||
const CONTROLS_MIN_W: f32 = 220.0;
|
||
/// Minimum height of the Chat dock (px).
|
||
const CHAT_MIN_H: f32 = 110.0;
|
||
/// Minimum height reserved above the Chat dock (header + main row) when resizing
|
||
/// the dock (px).
|
||
const ABOVE_CHAT_MIN_H: f32 = 300.0;
|
||
/// Thickness of a draggable divider (px).
|
||
const DIVIDER_THICKNESS: f32 = 8.0;
|
||
/// Upper bound for waiting on orderly core shutdown before letting the window exit.
|
||
const SHUTDOWN_TIMEOUT_SECS: u64 = 5;
|
||
/// How long a room-level clock-skew warning remains visible without dismissal.
|
||
const CLOCK_SKEW_WARNING_VISIBLE_SECS: u64 = 12;
|
||
|
||
/// Clamp the Participants panel width so neither it nor the Controls panel drops
|
||
/// below its minimum, given the current window width.
|
||
fn clamp_participants_width(width: f32, window_w: f32) -> f32 {
|
||
let max = (window_w - CONTROLS_MIN_W).max(PARTICIPANTS_MIN_W);
|
||
width.clamp(PARTICIPANTS_MIN_W, max)
|
||
}
|
||
|
||
/// Clamp the Chat dock height so neither it nor the area above it drops below its
|
||
/// minimum, given the current window height.
|
||
fn clamp_chat_height(height: f32, window_h: f32) -> f32 {
|
||
let max = (window_h - ABOVE_CHAT_MIN_H).max(CHAT_MIN_H);
|
||
height.clamp(CHAT_MIN_H, max)
|
||
}
|
||
|
||
/// Minimum width of the Chat column / drawer (px).
|
||
const CHAT_MIN_W: f32 = 200.0;
|
||
|
||
/// Clamp the Controls panel width (3-column layout) so neither it nor the rest of
|
||
/// the row drops below its minimum, given the current window width.
|
||
fn clamp_controls_width(width: f32, window_w: f32) -> f32 {
|
||
// Leave room for the Participants panel + a minimum Chat column.
|
||
let max = (window_w - PARTICIPANTS_MIN_W - CHAT_MIN_W).max(CONTROLS_MIN_W);
|
||
width.clamp(CONTROLS_MIN_W, max)
|
||
}
|
||
|
||
/// Clamp the Chat drawer width (drawer layout) so neither it nor the rest of the
|
||
/// row drops below its minimum, given the current window width.
|
||
fn clamp_chat_drawer_width(width: f32, window_w: f32) -> f32 {
|
||
let max = (window_w - PARTICIPANTS_MIN_W - CONTROLS_MIN_W).max(CHAT_MIN_W);
|
||
width.clamp(CHAT_MIN_W, max)
|
||
}
|
||
|
||
/// Clamp the Playlist drawer width so neither it nor the room body drops below
|
||
/// its minimum, given the current window width.
|
||
fn clamp_playlist_drawer_width(width: f32, window_w: f32) -> f32 {
|
||
let max = (window_w - PARTICIPANTS_MIN_W - CONTROLS_MIN_W).max(CHAT_MIN_W);
|
||
width.clamp(CHAT_MIN_W, max)
|
||
}
|
||
|
||
#[allow(clippy::large_enum_variant)]
|
||
#[derive(Debug, Clone)]
|
||
pub enum AppMessage {
|
||
NicknameChanged(String),
|
||
TicketInputChanged(String),
|
||
RoomNameChanged(String),
|
||
JoinPressed,
|
||
CreatePressed,
|
||
LeavePressed,
|
||
ToggleMutePressed,
|
||
ToggleDeafenPressed,
|
||
UiEventReceived(UiEvent),
|
||
CopyToClipboard,
|
||
/// Copy an arbitrary string to the clipboard (e.g. the full node ID).
|
||
CopyText(String),
|
||
/// No-op message for controlled read-only selectable fields.
|
||
Noop,
|
||
TogglePtt(bool),
|
||
StartHotkeyCapture(HotkeyAction),
|
||
ClearHotkey(HotkeyAction),
|
||
PeerVolumeChanged(EndpointId, f32),
|
||
PeerPanChanged(EndpointId, f32),
|
||
PeerGateChanged(EndpointId, f32),
|
||
PeerEqChanged(EndpointId, EqBand, f32),
|
||
/// Toggle local mute of a peer (silence them just for us).
|
||
TogglePeerMute(EndpointId),
|
||
InputDeviceSelected(AudioDevice),
|
||
OutputDeviceSelected(AudioDevice),
|
||
/// Live input-gain drag (applies immediately, persisted on release).
|
||
InputVolumeChanged(f32),
|
||
/// Live output-gain drag (applies immediately, persisted on release).
|
||
OutputVolumeChanged(f32),
|
||
/// Persist the current config to disk (slider release).
|
||
PersistConfig,
|
||
NoiseGateChanged(f32),
|
||
/// Live value while dragging the gate handle on the meter — updates the gate
|
||
/// immediately but does not persist (saved once on release via NoiseGateChanged).
|
||
NoiseGateDragging(f32),
|
||
NetworkModeSelected(NetworkMode),
|
||
AudioProfileSelected(AudioProfile),
|
||
RecordingModeSelected(RecordingMode),
|
||
ScreenShareQualitySelected(ShareQuality),
|
||
ScreenSharePlayerSelected(SharePlayer),
|
||
ScreenShareBufferingSelected(ShareBuffering),
|
||
ScreenShareMaxHeightSelected(ShareMaxHeightChoice),
|
||
ScreenShareFramerateSelected(ShareFramerateChoice),
|
||
ScreenShareMaxViewersSelected(ShareMaxViewersChoice),
|
||
ScreenShareCacheMbSelected(u32),
|
||
ScreenShareBitrateChanged(String),
|
||
ToggleScreenShareForceSoftwareEncode(bool),
|
||
ToggleScreenShareHardwareDecode(bool),
|
||
ScreenShareExtraMpvArgsChanged(String),
|
||
ScreenShareExtraHostArgsChanged(String),
|
||
/// Choose the friends presence posture (W7): invisible / normal / discoverable.
|
||
PresenceModeSelected(PresenceMode),
|
||
/// Friends list (W7 P5): add-form edits, add, remove, and local rename.
|
||
FriendAddIdChanged(String),
|
||
FriendAddNameChanged(String),
|
||
AddFriend,
|
||
/// Add a peer you're in a call with to your friends list (W7), using their
|
||
/// live presence name + address so they're reachable immediately.
|
||
AddFriendFromRoom(EndpointId),
|
||
RemoveFriend(EndpointId),
|
||
RenameFriend(EndpointId, String),
|
||
/// Manually rescan all friends' presence now (the ⟳ button), instead of waiting
|
||
/// for the 60s scheduler tick. Statuses update as the probe replies arrive.
|
||
RefreshFriends,
|
||
/// Join the gathering a friend is in (W7 B2), via the member ticket their
|
||
/// presence reply carried. Mirrors a manual ticket join.
|
||
JoinFriendRoom(String),
|
||
/// Rejoin a room from the recents list (W7 P5), via its stored ticket. Mirrors
|
||
/// a manual ticket join; best-effort (works only while the room is still live).
|
||
JoinRecent(String),
|
||
/// Drop a room from the recents list (W7 P5), the × on a recent entry.
|
||
RemoveRecent(String),
|
||
EventOccurred(Event),
|
||
NavigateToSettings,
|
||
NavigateBack,
|
||
SelectSettingsCategory(SettingsCategory),
|
||
ToggleNotifications(bool),
|
||
ToggleEchoCancellation(bool),
|
||
CustomSoundPathChanged(Sound, String),
|
||
/// Toggle the per-sound enable flag for a single chime (W6).
|
||
ToggleSoundEnabled(Sound, bool),
|
||
/// Open / cancel the "Regenerate identity?" confirm modal (W7).
|
||
OpenRegenerateIdentityConfirm,
|
||
CloseRegenerateIdentityConfirm,
|
||
/// Confirmed: mint a fresh persistent identity, discarding the old one.
|
||
ConfirmRegenerateIdentity,
|
||
ToggleMicTest(bool),
|
||
/// Start/stop recording the call; the core confirms via Recording{Started,Stopped}.
|
||
ToggleRecording,
|
||
/// Live edits to the chat input line.
|
||
ChatInputChanged(String),
|
||
/// Open the native picker to attach a file to the chat.
|
||
PickAttachmentFile,
|
||
/// Result of the attach picker: (filename, bytes), or None if cancelled.
|
||
AttachmentFilePicked(Option<(String, Vec<u8>)>),
|
||
/// Save (downloading first if needed) a received attachment to disk. Carries
|
||
/// the full `(author, id)` key so the correct sender's bytes are fetched and
|
||
/// saved even if another peer reused the same attachment id (Tier C F-12).
|
||
SaveAttachment(AttachmentKey),
|
||
/// Open the click-to-enlarge image lightbox for this attachment (author + id).
|
||
OpenImageLightbox(AttachmentKey),
|
||
/// Close the image lightbox overlay.
|
||
CloseImageLightbox,
|
||
/// Result of the async save dialog: a status line to show, or None if cancelled.
|
||
AttachmentSaved(Option<String>),
|
||
/// Fetch (if needed) and start an inline audio attachment. Carries the full
|
||
/// `(author, id)` key (see [`AppMessage::SaveAttachment`]).
|
||
PlayAudio(AttachmentKey),
|
||
PauseAudio,
|
||
ResumeAudio,
|
||
SeekAudio(crate::files::AttachmentId, f32),
|
||
/// Adjust the universal inline-clip playback volume (`1.0` = unity) from the
|
||
/// master slider. Persisted to config; applied live only in universal mode.
|
||
SetClipVolume(f32),
|
||
/// Adjust volume from a clip's own row slider. In universal mode this drives
|
||
/// the shared level; otherwise it sets just that clip's in-memory level.
|
||
SetClipVolumeFor(crate::files::AttachmentId, f32),
|
||
/// Toggle whether one universal level governs every clip (checked) or each
|
||
/// clip keeps its own level (unchecked).
|
||
ToggleUniversalClipVolume(bool),
|
||
/// Switch the music panel tab (Personal/Public).
|
||
MusicSelectTab(MusicTab),
|
||
/// Open the native multi-file picker to add audio files to the playlist.
|
||
MusicBrowse,
|
||
/// Picked audio file paths to append to the playlist (None = cancelled).
|
||
MusicFilesPicked(Option<Vec<std::path::PathBuf>>),
|
||
/// Play the playlist track at this index.
|
||
MusicPlayIndex(usize),
|
||
/// Toggle play/pause of the current music track.
|
||
MusicPlayPause,
|
||
/// Advance to the next / previous playlist track.
|
||
MusicNext,
|
||
MusicPrev,
|
||
/// Seek the current music track to this 0.0..=1.0 fraction.
|
||
MusicSeek(f32),
|
||
/// Set the local music playback volume (and persist it).
|
||
MusicSetVolume(f32),
|
||
/// Set the tuned-in source's music playback volume (and persist it).
|
||
MusicSetSourceVolume(f32),
|
||
/// Show / hide the room now-playing player bar (persisted).
|
||
TogglePlayerBar,
|
||
/// Open / close the full playlist drawer.
|
||
TogglePlaylistDrawer,
|
||
/// Remove the playlist track at this index.
|
||
MusicRemove(usize),
|
||
/// Move a personal playlist track one slot up/down.
|
||
MusicMoveUp(usize),
|
||
MusicMoveDown(usize),
|
||
/// Toggle whether our local playlist track is advertised for tune-in.
|
||
MusicToggleBroadcast(bool),
|
||
/// Tune the music sink into a peer's advertised music timeline.
|
||
MusicListen(EndpointId),
|
||
/// Stop listening to a peer's music timeline.
|
||
MusicStopListen,
|
||
/// Redraw cadence while an inline clip is active.
|
||
AudioTick,
|
||
/// Send the current chat input line (Enter or the Send button).
|
||
ChatSubmit,
|
||
/// Open a clicked chat link in the system browser (A13).
|
||
OpenUrl(String),
|
||
/// A room divider was dragged by the given pixel delta along its drag axis
|
||
/// (horizontal for the Panels divider, vertical for the Chat divider).
|
||
DividerDragged(DividerKind, f32),
|
||
/// Open / close the room-layout picker popup.
|
||
OpenLayoutPicker,
|
||
CloseLayoutPicker,
|
||
/// Open / close the live hotkey reference popup.
|
||
OpenHotkeyInfo,
|
||
CloseHotkeyInfo,
|
||
/// Open / close the "screen sharing needs pixelpass" explainer popup (A11).
|
||
OpenPixelpassHelp,
|
||
ClosePixelpassHelp,
|
||
/// Dismiss the room-level clock-skew warning banner.
|
||
DismissClockSkewWarning,
|
||
/// Auto-clear cadence while the clock-skew warning banner is visible.
|
||
ClockSkewWarningTick,
|
||
/// Slow re-render so the friends "scanned Nm ago" indicator advances over time.
|
||
RescanLabelTick,
|
||
/// Choose a room layout (applied live + persisted, closes the popup).
|
||
SelectRoomLayout(RoomLayout),
|
||
/// Choose a UI theme (applied live + persisted).
|
||
SelectTheme(AppTheme),
|
||
/// Choose our avatar (monogram or a preset); applied live + persisted (W4).
|
||
SelectAvatar(crate::avatar::Avatar),
|
||
/// Open the native file picker to choose a custom avatar image (W4 Phase 3).
|
||
PickAvatarFile,
|
||
/// Result of the avatar file picker: the chosen file's raw bytes, or `None`
|
||
/// if the user cancelled.
|
||
AvatarFilePicked(Option<Vec<u8>>),
|
||
/// Open the native file picker to choose a custom UI background image (W16).
|
||
PickBackgroundFile,
|
||
/// Result of the background file picker: the chosen file's raw bytes, or
|
||
/// `None` if the user cancelled.
|
||
BackgroundFilePicked(Option<Vec<u8>>),
|
||
/// Clear the custom background, reverting to the theme background (W16).
|
||
RemoveBackground,
|
||
/// Set the background legibility scrim strength (0.0..=1.0) (W16).
|
||
SetBackgroundDim(f32),
|
||
/// Toggle the Chat drawer open/closed (drawer layout).
|
||
ToggleDrawerChat,
|
||
/// Start/stop sharing our own screen. When not sharing, opens the audio
|
||
/// picker (A23) instead of starting immediately; when sharing, stops.
|
||
ToggleScreenShare,
|
||
/// Close the screen-share audio picker without sharing.
|
||
CloseSharePicker,
|
||
/// Select which app's audio to share in the picker: `Some(name)` for one app,
|
||
/// `None` for the whole desktop ("All system audio").
|
||
SelectShareAudioApp(Option<String>),
|
||
/// Session-only quality preset for the next share start.
|
||
SelectShareQualityOverride(ShareQuality),
|
||
/// Confirm the picker: start the share with the currently selected audio app.
|
||
ConfirmShareScreen,
|
||
/// Watch a peer's screen share, identified by their pixelpass ticket.
|
||
WatchShare(String),
|
||
/// Result of asynchronously enqueueing the core shutdown command.
|
||
ShutdownCommandSent(bool),
|
||
/// Fallback close if the core does not acknowledge shutdown promptly.
|
||
ShutdownTimeout,
|
||
// --- Game detection (W17/W18) ---
|
||
/// Toggle broadcasting the detected game to peers (opt-in, default off).
|
||
ToggleGamePresence(bool),
|
||
/// Choose the manual detection override (Auto / None / the current game).
|
||
GameOverrideSelected(GameOverrideChoice),
|
||
/// Add-mapping form edits (executable basename → display name).
|
||
GameMapExeChanged(String),
|
||
GameMapNameChanged(String),
|
||
/// Commit the add-mapping form into the process map.
|
||
AddGameMapping,
|
||
/// Remove a process→name mapping by its executable key.
|
||
RemoveGameMapping(String),
|
||
/// Open the native picker to set a per-game background for the given game id.
|
||
PickGameBackground(String),
|
||
/// Result of the per-game background picker: (game id, chosen bytes or None).
|
||
GameBackgroundPicked(String, Option<Vec<u8>>),
|
||
/// Clear a per-game background mapping by game id.
|
||
RemoveGameBackground(String),
|
||
}
|
||
|
||
/// The manual game-detection override as shown in the Settings picker. Maps to a
|
||
/// [`crate::game::ManualOverride`] using the app's currently-detected game for the
|
||
/// `Current` choice (so "force this game" carries the live id + name).
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum GameOverrideChoice {
|
||
/// Trust auto-detection (default).
|
||
Auto,
|
||
/// Force "not playing" — never broadcast a game.
|
||
None,
|
||
/// Pin the game currently detected (only offered while something is detected).
|
||
Current,
|
||
}
|
||
|
||
impl std::fmt::Display for GameOverrideChoice {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
f.write_str(match self {
|
||
GameOverrideChoice::Auto => "Auto-detect",
|
||
GameOverrideChoice::None => "None (don't show a game)",
|
||
GameOverrideChoice::Current => "Pin current game",
|
||
})
|
||
}
|
||
}
|
||
|
||
fn core_subscription() -> impl iced::futures::Stream<Item = UiEvent> {
|
||
iced::stream::channel(
|
||
100,
|
||
|mut output: iced::futures::channel::mpsc::Sender<UiEvent>| 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;
|
||
}
|
||
}
|
||
}
|
||
},
|
||
)
|
||
}
|
||
|
||
fn now_ms() -> u64 {
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.map(|d| d.as_millis() as u64)
|
||
.unwrap_or(0)
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
struct ClockSkewBanner {
|
||
skew_secs: u64,
|
||
peer_ahead: bool,
|
||
expires_at: std::time::Instant,
|
||
}
|
||
|
||
/// One entry in the personal music playlist (Phase 1: a local file).
|
||
#[derive(Debug, Clone)]
|
||
struct MusicTrack {
|
||
/// Absolute path on disk. Persisted (as a String) in config.
|
||
path: std::path::PathBuf,
|
||
/// Display name derived from the file name.
|
||
name: String,
|
||
}
|
||
|
||
/// Which music tab is shown. Phase 1: only `Personal` is functional; `Public`
|
||
/// is a stub placeholder for the Phase 2 tune-in view.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum MusicTab {
|
||
Personal,
|
||
Public,
|
||
}
|
||
|
||
pub struct AppState {
|
||
name: String,
|
||
ticket_input: String,
|
||
/// The optional cosmetic room label typed on the home "Create" card (W7).
|
||
/// Carried in the minted ticket so joiners inherit it; empty = unnamed room.
|
||
room_name_input: String,
|
||
status_message: String,
|
||
/// When the last manual friends "Rescan" pass completed, for the live
|
||
/// "scanned Nm ago" indicator in the Friends panel. `None` until the first
|
||
/// rescan this session. Only the on-demand button updates this, not the 15s
|
||
/// auto-refresh.
|
||
last_rescan: Option<std::time::Instant>,
|
||
self_id: String,
|
||
ticket: String,
|
||
is_muted: bool,
|
||
is_deafened: bool,
|
||
ptt_enabled: bool,
|
||
ptt_active: bool,
|
||
hotkey_capture: Option<HotkeyAction>,
|
||
input_devices: Vec<AudioDevice>,
|
||
output_devices: Vec<AudioDevice>,
|
||
selected_input: Option<AudioDevice>,
|
||
selected_output: Option<AudioDevice>,
|
||
config: AppConfig,
|
||
/// Decoded bytes of the custom background image (W16), cached so `view()`
|
||
/// doesn't read the file from disk on every redraw. Loaded on startup and
|
||
/// refreshed when the background is changed/removed. `None` = no custom bg.
|
||
background_image: Option<bytes::Bytes>,
|
||
/// The locally-detected running game (game detection), as reported by core.
|
||
/// Drives the per-game background (W18) and a local "Playing …" indicator.
|
||
/// `None` = nothing detected. Independent of whether we broadcast it to peers
|
||
/// (that's `config.game_presence_enabled`).
|
||
current_game: Option<crate::game::DetectedGame>,
|
||
/// Add-mapping form state in the Games settings: the executable basename and
|
||
/// display name being entered for a new process→name mapping.
|
||
game_map_exe_input: String,
|
||
game_map_name_input: String,
|
||
/// Current manual-override selection shown in the Games settings picker.
|
||
/// Session-only (not persisted); defaults to Auto each launch.
|
||
game_override: GameOverrideChoice,
|
||
peers: HashMap<EndpointId, PeerState>,
|
||
audio_levels: HashMap<EndpointId, f32>,
|
||
/// Peers we've locally muted (their audio isn't mixed into our output).
|
||
locally_muted: HashSet<EndpointId>,
|
||
/// When we joined the current room, for the in-room call-duration timer.
|
||
call_started: Option<std::time::Instant>,
|
||
/// Whether a local call recording is in progress (confirmed by the core).
|
||
recording: bool,
|
||
/// When the current recording started, for the header REC timer.
|
||
recording_started: Option<std::time::Instant>,
|
||
/// Room text-chat history (newest last) and the pending input line.
|
||
chat_messages: Vec<ChatEntry>,
|
||
chat_input: String,
|
||
/// Fetched/failed bytes + cached image handles for chat attachments, keyed by
|
||
/// `(author, id)` and bounded. Session-only (cleared on leave); never
|
||
/// persisted. (Tier C F-02 bound + F-12 author keying.)
|
||
attachments: AttachmentCache,
|
||
/// When `Some`, the image lightbox overlay is open showing this attachment
|
||
/// (click-to-enlarge from the chat panel). Closed = `None`.
|
||
image_lightbox: Option<AttachmentKey>,
|
||
/// Attachments the user asked to save before the bytes arrived; when the
|
||
/// fetch completes a save dialog is opened for them. Keyed by `(author, id)`
|
||
/// so a same-id attachment from a different sender can't trigger the save.
|
||
pending_saves: HashSet<AttachmentKey>,
|
||
/// Clip ids waiting for the existing attachment fetch path to return bytes.
|
||
pending_plays: HashSet<crate::files::AttachmentId>,
|
||
/// Filename-hinted audio whose fetched bytes or decoder validation failed;
|
||
/// these entries fall back to the normal file chip.
|
||
invalid_audio: HashSet<crate::files::AttachmentId>,
|
||
/// Independent system-default-device player for chat clips. It never enters
|
||
/// the call capture/mixer path.
|
||
clip_player: ClipPlayer,
|
||
clip_status: SharedClipStatus,
|
||
/// Per-clip playback gain used when universal clip volume is disabled
|
||
/// (`config.clip_volume_universal == false`). In-memory only; absent clips
|
||
/// default to unity. Universal mode ignores this and uses `config.clip_volume`.
|
||
clip_volumes: HashMap<crate::files::AttachmentId, f32>,
|
||
/// Dedicated player for the music playlist (separate from `clip_player`, which
|
||
/// serves chat-audio attachments, so the two never interrupt each other).
|
||
music_player: ClipPlayer,
|
||
music_status: SharedClipStatus,
|
||
/// Personal playlist, loaded from `config.music_playlist` at startup.
|
||
music_playlist: Vec<MusicTrack>,
|
||
/// Index into `music_playlist` of the track currently loaded in `music_player`.
|
||
music_current: Option<usize>,
|
||
/// True while a music track is meant to be playing — used to distinguish a
|
||
/// natural track end (auto-advance) from a user stop/pause in `AudioTick`.
|
||
music_active: bool,
|
||
/// Selected music tab.
|
||
music_tab: MusicTab,
|
||
/// Persisted preference: let others tune into our current personal track.
|
||
music_broadcasting: bool,
|
||
/// Files-plane id for the track we're currently serving/broadcasting.
|
||
music_broadcast_id: Option<crate::files::AttachmentId>,
|
||
/// Files-plane id/size for the track currently advertised as upcoming.
|
||
music_broadcast_next: Option<(usize, crate::files::AttachmentId, u64)>,
|
||
/// Peer whose shared music timeline currently owns the music sink.
|
||
music_listening_to: Option<EndpointId>,
|
||
/// Source track id currently loaded in the music sink.
|
||
music_listen_loaded: Option<crate::files::AttachmentId>,
|
||
/// Source track id currently being fetched.
|
||
music_listen_inflight: Option<crate::files::AttachmentId>,
|
||
/// Prefetched next-track bytes from the tuned-in source.
|
||
music_prefetch: Option<(crate::files::AttachmentId, Vec<u8>)>,
|
||
/// Source next-track id currently being prefetched.
|
||
music_prefetch_inflight: Option<crate::files::AttachmentId>,
|
||
/// Last known window size, tracked so divider clamps stay valid on resize.
|
||
/// (The divider positions themselves are persisted in `config`.)
|
||
window_size: Size,
|
||
/// Whether the room-layout picker popup is open (launch + in-call screens).
|
||
layout_picker_open: bool,
|
||
/// Whether the hotkey reference popup is open.
|
||
hotkey_info_open: bool,
|
||
/// Whether the pixelpass screen-share explainer popup is open (A11).
|
||
pixelpass_help_open: bool,
|
||
/// Whether the screen-share audio picker is open (A23). Opened by Share
|
||
/// Screen when not already sharing; lets the user capture one app's audio
|
||
/// instead of the whole desktop (which echoes the call back to viewers).
|
||
share_picker_open: bool,
|
||
/// Apps currently producing audio, shown in the share picker. Populated from
|
||
/// `UiEvent::AudioAppsListed` after the picker requests an enumeration.
|
||
share_audio_apps: Vec<String>,
|
||
/// The picker's current selection: `Some(name)` = capture that app's audio,
|
||
/// `None` = "All system audio" (whole desktop; may echo the call).
|
||
share_audio_selection: Option<String>,
|
||
/// Session-only quality override for the next screen-share start.
|
||
share_quality_selection: ShareQuality,
|
||
/// A share start is in flight: `ConfirmShareScreen` was sent but the core
|
||
/// hasn't yet replied with `ScreenShareStarted`/an error. Blocks reopening
|
||
/// the picker (and re-confirming) during that startup window. Cleared on
|
||
/// `ScreenShareStarted`, `ScreenShareStopped`, or any `Error`.
|
||
share_starting: bool,
|
||
/// While sharing a specific app's audio (A23 strict mode): `true` when that
|
||
/// app's audio has stopped (or hasn't started yet), so viewers currently hear
|
||
/// silence. Drives a transient warning. Always `false` for whole-desktop
|
||
/// shares (pixelpass emits no `app_audio` events then) and when not sharing.
|
||
share_audio_dropped: bool,
|
||
/// Whether the current share is a specific-app capture (vs whole-desktop).
|
||
/// Set from the confirmed selection on `ScreenShareStarted`, cleared on
|
||
/// stop/reset. Gates applying `ShareAudioActive`, so a late event from a
|
||
/// just-killed host can't flip the warning on a new whole-desktop share or
|
||
/// after stop (audit P3, unscoped events).
|
||
share_audio_app_active: bool,
|
||
/// Whether the resolved pixelpass supports `--strict-audio` (per-app audio).
|
||
/// `false` ⇒ the picker offers whole-desktop only, because a per-app share
|
||
/// would pass a flag an older pixelpass rejects (audit P2). Optimistic `true`
|
||
/// until the core's `AudioAppsListed` reports otherwise.
|
||
share_app_audio_supported: bool,
|
||
/// Room-level warning for a validly signed peer whose gossip timestamp falls
|
||
/// outside the replay freshness window. The peer is not yet in the roster, so
|
||
/// this is not attached to a participant card.
|
||
clock_skew_warning: Option<ClockSkewBanner>,
|
||
/// Whether the Chat drawer is open (drawer layout only).
|
||
drawer_chat_open: bool,
|
||
/// Whether the Playlist drawer is open beside the room body.
|
||
playlist_drawer_open: bool,
|
||
/// Raw mic level (normalized RMS, `0.0..=1.0`) for the settings meter.
|
||
mic_level: f32,
|
||
/// Whether the standalone (off-call) mic test stream is running.
|
||
mic_test_active: bool,
|
||
/// Peers whose audio link is currently down (initial connect or reconnect).
|
||
connecting: HashSet<EndpointId>,
|
||
/// Peers we've had a live link to at least once — used to say "Reconnecting"
|
||
/// rather than "Connecting" the second time around.
|
||
ever_connected: HashSet<EndpointId>,
|
||
controller: Arc<CoreController>,
|
||
current_screen: Screen,
|
||
settings_category: SettingsCategory,
|
||
/// Whether we're currently sharing our own screen (confirmed by the core).
|
||
self_sharing: bool,
|
||
/// Whether the `pixelpass` binary is available, gating the Share controls.
|
||
pixelpass_available: bool,
|
||
/// Our persistent node id (W7), known from startup regardless of room state
|
||
/// (distinct from `self_id`, which is room-scoped). `None` until the core
|
||
/// reports it via `IdentityStatus`.
|
||
self_node_id: Option<String>,
|
||
/// Whether our identity is persisted to disk. `false` = degraded ephemeral
|
||
/// fallback (the key file couldn't be read/written) → the UI shows a warning,
|
||
/// because the id won't survive the next launch and friends will stop
|
||
/// recognising us. Defaults `true` (optimistic until told otherwise).
|
||
identity_persisted: bool,
|
||
/// The reason the identity isn't persisted, for the warning explainer.
|
||
identity_error: Option<String>,
|
||
/// Whether the "Regenerate identity?" confirm modal is open.
|
||
regenerate_identity_confirm_open: bool,
|
||
/// Saved friends (W7) — a READ-ONLY MIRROR of the core-owned store, refreshed by
|
||
/// `UiEvent::FriendsUpdated`. The GUI no longer loads/saves it; add/remove/rename
|
||
/// go to core as commands.
|
||
friends: crate::friends::FriendStore,
|
||
/// True when core couldn't load `friends.json` (malformed) and is in a degraded
|
||
/// read-only state — the GUI disables edits + warns so we don't clobber it (A16).
|
||
friends_read_only: bool,
|
||
/// Latest live presence per friend (W7 B2), from `UiEvent::FriendPresence`. A
|
||
/// missing entry = treat as offline/unknown.
|
||
friend_presence: std::collections::HashMap<EndpointId, crate::presence::FriendPresence>,
|
||
/// "Add friend" form inputs: their node id (hex) and an optional name.
|
||
friend_add_id: String,
|
||
friend_add_name: String,
|
||
/// Inline feedback for the add-friend form (e.g. a bad id), cleared on edit.
|
||
friend_add_error: Option<String>,
|
||
/// Window close has been requested and the GUI is waiting for core teardown.
|
||
closing: bool,
|
||
}
|
||
|
||
impl AppState {
|
||
fn reset_room_state(&mut self) {
|
||
self.clip_player.stop();
|
||
self.music_player.stop();
|
||
self.music_current = None;
|
||
self.music_active = false;
|
||
self.music_broadcast_id = None;
|
||
self.music_broadcast_next = None;
|
||
self.music_listening_to = None;
|
||
self.music_listen_loaded = None;
|
||
self.music_listen_inflight = None;
|
||
self.music_prefetch = None;
|
||
self.music_prefetch_inflight = None;
|
||
self.peers.clear();
|
||
self.audio_levels.clear();
|
||
self.locally_muted.clear();
|
||
self.chat_messages.clear();
|
||
self.chat_input.clear();
|
||
self.attachments.clear();
|
||
self.image_lightbox = None;
|
||
self.pending_saves.clear();
|
||
self.pending_plays.clear();
|
||
self.invalid_audio.clear();
|
||
self.connecting.clear();
|
||
self.ever_connected.clear();
|
||
self.recording = false;
|
||
self.recording_started = None;
|
||
self.call_started = None;
|
||
self.mic_level = 0.0;
|
||
self.self_sharing = false;
|
||
self.share_picker_open = false;
|
||
self.share_audio_apps.clear();
|
||
self.share_audio_selection = None;
|
||
self.share_quality_selection = self.config.screen_share.quality;
|
||
self.share_starting = false;
|
||
self.share_audio_dropped = false;
|
||
self.share_audio_app_active = false;
|
||
self.share_app_audio_supported = true;
|
||
self.clock_skew_warning = None;
|
||
}
|
||
|
||
fn custom_sound_path(&self, sound: Sound) -> &str {
|
||
let opt = match sound {
|
||
Sound::SelfJoin => &self.config.custom_sound_self_join,
|
||
Sound::PeerJoin => &self.config.custom_sound_peer_join,
|
||
Sound::PeerLeave => &self.config.custom_sound_peer_leave,
|
||
Sound::ReconnectAttempt => &self.config.custom_sound_reconnect_attempt,
|
||
Sound::Reconnected => &self.config.custom_sound_reconnected,
|
||
Sound::SelfLeave => &self.config.custom_sound_self_leave,
|
||
Sound::MicToggle => &self.config.custom_sound_mic_toggle,
|
||
Sound::ReconnectFailed => &self.config.custom_sound_reconnect_failed,
|
||
};
|
||
opt.as_deref().unwrap_or("")
|
||
}
|
||
}
|
||
|
||
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)));
|
||
|
||
let mut config = AppConfig::load();
|
||
// The window opens at the restored size (see `run_gui`); clamp the
|
||
// persisted divider positions against THAT size, not a hardcoded default,
|
||
// so they stay valid for the window we're actually about to show.
|
||
let (ww, wh) = (config.window_width, config.window_height);
|
||
config.participants_width = clamp_participants_width(config.participants_width, ww);
|
||
config.chat_height = clamp_chat_height(config.chat_height, wh);
|
||
config.controls_width = clamp_controls_width(config.controls_width, ww);
|
||
config.chat_drawer_width = clamp_chat_drawer_width(config.chat_drawer_width, ww);
|
||
config.playlist_drawer_width =
|
||
clamp_playlist_drawer_width(config.playlist_drawer_width, ww);
|
||
notify::set_enabled(config.notifications_enabled);
|
||
for sound in Sound::ALL {
|
||
notify::set_sound_enabled(sound, config.sound_enabled(sound));
|
||
}
|
||
let _ = controller.send(CoreCommand::SetNoiseGateThreshold(
|
||
config.noise_gate_threshold,
|
||
));
|
||
let _ = controller.send(CoreCommand::SetInputVolume(config.input_volume));
|
||
let _ = controller.send(CoreCommand::SetOutputVolume(config.output_volume));
|
||
let _ = controller.send(CoreCommand::SetNetworkMode(config.network_mode));
|
||
let _ = controller.send(CoreCommand::SetAudioProfile(config.audio_profile));
|
||
let _ = controller.send(CoreCommand::SetRecordingMode(config.recording_mode));
|
||
let _ = controller.send(CoreCommand::SetPixelpassPath(config.pixelpass_path.clone()));
|
||
let _ = controller.send(CoreCommand::SetPresenceMode(config.presence_mode));
|
||
// Game detection (W17/W18): seed the opt-in broadcast flag + the user's
|
||
// process→name mappings from config. The manual override starts at Auto.
|
||
let _ = controller.send(CoreCommand::SetGamePresenceEnabled(
|
||
config.game_presence_enabled,
|
||
));
|
||
let _ = controller.send(CoreCommand::SetGameProcessMap(
|
||
config.game_process_map.clone(),
|
||
));
|
||
for (peer, settings) in &config.peer_eq {
|
||
if let Ok(id) = peer.parse::<EndpointId>() {
|
||
let _ = controller.send(CoreCommand::SetPeerEq(id, *settings));
|
||
}
|
||
}
|
||
for (peer, pan) in &config.peer_pan {
|
||
if let Ok(id) = peer.parse::<EndpointId>() {
|
||
let _ = controller.send(CoreCommand::SetPeerPan(id, *pan));
|
||
}
|
||
}
|
||
for (peer, volume) in &config.peer_volume {
|
||
if let Ok(id) = peer.parse::<EndpointId>() {
|
||
let _ = controller.send(CoreCommand::SetPeerVolume(id, *volume));
|
||
}
|
||
}
|
||
for (peer, threshold) in &config.peer_gate {
|
||
if let Ok(id) = peer.parse::<EndpointId>() {
|
||
let _ = controller.send(CoreCommand::SetPeerGate(id, *threshold));
|
||
}
|
||
}
|
||
let pixelpass_available =
|
||
crate::screenshare::is_available(config.pixelpass_path.as_deref());
|
||
let all_devices = enumerate_audio_devices();
|
||
let input_devices: Vec<_> = all_devices.iter().filter(|d| d.is_input).cloned().collect();
|
||
let output_devices: Vec<_> = all_devices
|
||
.iter()
|
||
.filter(|d| !d.is_input)
|
||
.cloned()
|
||
.collect();
|
||
|
||
let selected_input = input_devices
|
||
.iter()
|
||
.find(|d| d.name == config.input_device)
|
||
.cloned();
|
||
let selected_output = output_devices
|
||
.iter()
|
||
.find(|d| d.name == config.output_device)
|
||
.cloned();
|
||
|
||
let background_image = load_background_bytes(&config);
|
||
let (clip_player, clip_status) = ClipPlayer::new(config.clip_volume);
|
||
let (music_player, music_status) = ClipPlayer::new(config.music_volume);
|
||
let music_broadcasting = config.music_broadcast;
|
||
let share_quality_selection = config.screen_share.quality;
|
||
let music_playlist = config
|
||
.music_playlist
|
||
.iter()
|
||
.map(|p| {
|
||
let path = std::path::PathBuf::from(p);
|
||
let name = path
|
||
.file_name()
|
||
.map(|n| n.to_string_lossy().into_owned())
|
||
.unwrap_or_else(|| p.clone());
|
||
MusicTrack { path, name }
|
||
})
|
||
.collect();
|
||
|
||
Self {
|
||
// Pre-fill the nickname with the last one used (or "Peer" by default).
|
||
name: config.username.clone(),
|
||
ticket_input: "".to_string(),
|
||
room_name_input: "".to_string(),
|
||
status_message: "Ready to connect".to_string(),
|
||
last_rescan: None,
|
||
self_id: "".to_string(),
|
||
ticket: "".to_string(),
|
||
is_muted: false,
|
||
is_deafened: false,
|
||
ptt_enabled: false,
|
||
ptt_active: false,
|
||
hotkey_capture: None,
|
||
input_devices,
|
||
output_devices,
|
||
selected_input,
|
||
selected_output,
|
||
config,
|
||
background_image,
|
||
current_game: None,
|
||
game_map_exe_input: String::new(),
|
||
game_map_name_input: String::new(),
|
||
game_override: GameOverrideChoice::Auto,
|
||
peers: HashMap::new(),
|
||
audio_levels: HashMap::new(),
|
||
locally_muted: HashSet::new(),
|
||
call_started: None,
|
||
recording: false,
|
||
recording_started: None,
|
||
chat_messages: Vec::new(),
|
||
attachments: AttachmentCache::new(ATTACHMENT_CACHE_CAP),
|
||
image_lightbox: None,
|
||
pending_saves: HashSet::new(),
|
||
pending_plays: HashSet::new(),
|
||
invalid_audio: HashSet::new(),
|
||
clip_player,
|
||
clip_status,
|
||
clip_volumes: HashMap::new(),
|
||
music_player,
|
||
music_status,
|
||
music_playlist,
|
||
music_current: None,
|
||
music_active: false,
|
||
music_tab: MusicTab::Personal,
|
||
music_broadcasting,
|
||
music_broadcast_id: None,
|
||
music_broadcast_next: None,
|
||
music_listening_to: None,
|
||
music_listen_loaded: None,
|
||
music_listen_inflight: None,
|
||
music_prefetch: None,
|
||
music_prefetch_inflight: None,
|
||
chat_input: String::new(),
|
||
window_size: Size::new(ww, wh),
|
||
layout_picker_open: false,
|
||
hotkey_info_open: false,
|
||
pixelpass_help_open: false,
|
||
share_picker_open: false,
|
||
share_audio_apps: Vec::new(),
|
||
share_audio_selection: None,
|
||
share_quality_selection,
|
||
share_starting: false,
|
||
share_audio_dropped: false,
|
||
share_audio_app_active: false,
|
||
share_app_audio_supported: true,
|
||
clock_skew_warning: None,
|
||
drawer_chat_open: false,
|
||
playlist_drawer_open: false,
|
||
mic_level: 0.0,
|
||
mic_test_active: false,
|
||
connecting: HashSet::new(),
|
||
ever_connected: HashSet::new(),
|
||
controller,
|
||
current_screen: Screen::Home,
|
||
settings_category: SettingsCategory::Audio,
|
||
self_sharing: false,
|
||
pixelpass_available,
|
||
self_node_id: None,
|
||
identity_persisted: true,
|
||
identity_error: None,
|
||
regenerate_identity_confirm_open: false,
|
||
// Core owns the friends store now; the GUI starts empty and fills in
|
||
// from the FriendsUpdated event core emits at startup.
|
||
friends: crate::friends::FriendStore::default(),
|
||
friends_read_only: false,
|
||
friend_presence: std::collections::HashMap::new(),
|
||
friend_add_id: String::new(),
|
||
friend_add_name: String::new(),
|
||
friend_add_error: None,
|
||
closing: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
fn theme(state: &AppState) -> Theme {
|
||
state.config.theme.base_theme()
|
||
}
|
||
|
||
/// Read the custom background PNG (W16) from disk into memory, if one is set and
|
||
/// readable. Called once on startup and whenever the background changes, so the
|
||
/// per-frame `view()` never touches the filesystem. A missing/unreadable file
|
||
/// silently yields `None` (the UI falls back to the theme background).
|
||
fn load_background_bytes(config: &AppConfig) -> Option<bytes::Bytes> {
|
||
let path = config.background.as_deref()?;
|
||
std::fs::read(path).ok().map(bytes::Bytes::from)
|
||
}
|
||
|
||
/// The effective background for the current state: the per-game override (W18) when
|
||
/// the running `game` has a mapping in `config.game_backgrounds`, otherwise the
|
||
/// single custom background (W16). A mapped-but-missing/unreadable per-game file
|
||
/// falls back to the default WITHOUT forgetting the mapping (the file may return).
|
||
fn effective_background_bytes(
|
||
config: &AppConfig,
|
||
game: Option<&crate::game::DetectedGame>,
|
||
) -> Option<bytes::Bytes> {
|
||
if let Some(g) = game
|
||
&& let Some(path) = config.game_backgrounds.get(&g.id)
|
||
&& let Ok(bytes) = std::fs::read(path)
|
||
{
|
||
return Some(bytes::Bytes::from(bytes));
|
||
}
|
||
load_background_bytes(config)
|
||
}
|
||
|
||
pub fn run_gui() -> iced::Result {
|
||
crate::log_msg(&format!("{} starting", app_build_label()));
|
||
// Restore the last window size (saved on close). Position is restored too,
|
||
// but only on X11 — Wayland's xdg-shell gives clients no way to set their own
|
||
// position, so we center there and leave placement to the compositor.
|
||
let saved = AppConfig::load();
|
||
let init_size = iced::Size::new(saved.window_width, saved.window_height);
|
||
let init_position = initial_window_position(
|
||
saved.window_x,
|
||
saved.window_y,
|
||
saved.window_width.round() as i32,
|
||
saved.window_height.round() as i32,
|
||
is_wayland(),
|
||
);
|
||
iced::application(AppState::default, update, view_with_background)
|
||
.title("PeerSpeak P2P Voice Chat")
|
||
.theme(theme)
|
||
.subscription(subscription)
|
||
.window(iced::window::Settings {
|
||
// Restored from config (defaults 900×760: taller so the bottom chat
|
||
// dock doesn't squeeze the controls column). Layout is responsive.
|
||
size: init_size,
|
||
position: init_position,
|
||
// App/taskbar icon (mainly used on X11/XWayland; native Wayland takes
|
||
// the icon from the .desktop file matched by app_id instead).
|
||
icon: window_icon(),
|
||
// app_id must match the .desktop basename so Wayland compositors
|
||
// (e.g. KWin) attach our launcher icon to the window. The field is
|
||
// Linux-only in iced (X11/Wayland); see platform_specific_settings().
|
||
platform_specific: platform_specific_settings(),
|
||
// We save the final size ourselves on CloseRequested, then exit.
|
||
exit_on_close_request: false,
|
||
..Default::default()
|
||
})
|
||
.run()
|
||
}
|
||
|
||
/// Window `PlatformSpecific` settings. `application_id` (used by X11/Wayland to
|
||
/// match our `.desktop` launcher icon) only exists in iced on Linux, so it is
|
||
/// set there and left at defaults on Windows.
|
||
#[cfg(target_os = "linux")]
|
||
fn platform_specific_settings() -> iced::window::settings::PlatformSpecific {
|
||
iced::window::settings::PlatformSpecific {
|
||
application_id: "peerspeak".to_string(),
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
#[cfg(not(target_os = "linux"))]
|
||
fn platform_specific_settings() -> iced::window::settings::PlatformSpecific {
|
||
iced::window::settings::PlatformSpecific::default()
|
||
}
|
||
|
||
/// Build the window icon from an embedded 128×128 straight-RGBA blob rendered
|
||
/// from `assets/icons/peerspeak.svg`. Using `from_rgba` (always available) keeps
|
||
/// us off iced's heavy `image` feature — the blob is raw pixels, no decoder.
|
||
fn window_icon() -> Option<iced::window::Icon> {
|
||
const RGBA: &[u8] = include_bytes!("../../assets/icons/peerspeak-128.rgba");
|
||
iced::window::icon::from_rgba(RGBA.to_vec(), 128, 128).ok()
|
||
}
|
||
|
||
/// True when running under a Wayland compositor (winit will use its Wayland
|
||
/// backend). Mirrors winit's own selection: it prefers Wayland when
|
||
/// `WAYLAND_DISPLAY` is set, otherwise falls back to X11 via `DISPLAY`.
|
||
fn is_wayland() -> bool {
|
||
std::env::var_os("WAYLAND_DISPLAY").is_some_and(|v| !v.is_empty())
|
||
}
|
||
|
||
/// Decide the initial window position from the saved coordinates.
|
||
///
|
||
/// Position restore only works on **X11** — Wayland's xdg-shell gives clients no
|
||
/// way to place their own window, so we center there and let the compositor
|
||
/// decide. Returns `Centered` when we're on Wayland or have no saved position.
|
||
fn initial_window_position(
|
||
saved_x: Option<i32>,
|
||
saved_y: Option<i32>,
|
||
win_w: i32,
|
||
win_h: i32,
|
||
is_wayland: bool,
|
||
) -> iced::window::Position {
|
||
match (saved_x, saved_y) {
|
||
(Some(x), Some(y)) if !is_wayland => {
|
||
clamp_window_position(x, y, win_w, win_h, screen_bounds())
|
||
}
|
||
_ => iced::window::Position::Centered,
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy)]
|
||
struct ScreenBounds {
|
||
x: i32,
|
||
y: i32,
|
||
width: i32,
|
||
height: i32,
|
||
}
|
||
|
||
const MIN_VISIBLE_WINDOW_MARGIN: i32 = 48;
|
||
const WINDOW_POSITION_SANITY_LIMIT: i64 = 32_000;
|
||
|
||
fn screen_bounds() -> Option<ScreenBounds> {
|
||
// iced 0.14 only exposes monitor size through window tasks after a window
|
||
// exists, not the startup virtual desktop bounds needed here. Keep the pure
|
||
// clamp ready for when dependency-free bounds can be supplied.
|
||
None
|
||
}
|
||
|
||
fn clamp_window_position(
|
||
saved_x: i32,
|
||
saved_y: i32,
|
||
win_w: i32,
|
||
win_h: i32,
|
||
bounds: Option<ScreenBounds>,
|
||
) -> iced::window::Position {
|
||
let specific = |x, y| iced::window::Position::Specific(iced::Point::new(x as f32, y as f32));
|
||
let win_w = win_w.max(1);
|
||
let win_h = win_h.max(1);
|
||
|
||
if let Some(bounds) = bounds {
|
||
if bounds.width <= 0
|
||
|| bounds.height <= 0
|
||
|| !has_min_visible_overlap(saved_x, win_w, bounds.x, bounds.width)
|
||
|| !has_min_visible_overlap(saved_y, win_h, bounds.y, bounds.height)
|
||
{
|
||
return iced::window::Position::Centered;
|
||
}
|
||
|
||
return specific(
|
||
clamp_window_position_axis(saved_x, win_w, bounds.x, bounds.width),
|
||
clamp_window_position_axis(saved_y, win_h, bounds.y, bounds.height),
|
||
);
|
||
}
|
||
|
||
if i64::from(saved_x).abs() > WINDOW_POSITION_SANITY_LIMIT
|
||
|| i64::from(saved_y).abs() > WINDOW_POSITION_SANITY_LIMIT
|
||
{
|
||
return iced::window::Position::Centered;
|
||
}
|
||
|
||
specific(saved_x, saved_y)
|
||
}
|
||
|
||
fn has_min_visible_overlap(start: i32, len: i32, bounds_start: i32, bounds_len: i32) -> bool {
|
||
let required = MIN_VISIBLE_WINDOW_MARGIN.min(len).min(bounds_len).max(1);
|
||
let start = i64::from(start);
|
||
let end = start + i64::from(len);
|
||
let bounds_start = i64::from(bounds_start);
|
||
let bounds_end = bounds_start + i64::from(bounds_len);
|
||
let overlap = end.min(bounds_end) - start.max(bounds_start);
|
||
|
||
overlap >= i64::from(required)
|
||
}
|
||
|
||
fn clamp_window_position_axis(start: i32, len: i32, bounds_start: i32, bounds_len: i32) -> i32 {
|
||
if len >= bounds_len {
|
||
return bounds_start;
|
||
}
|
||
|
||
let bounds_start = i64::from(bounds_start);
|
||
let max_start = bounds_start + i64::from(bounds_len - len);
|
||
|
||
i64::from(start).clamp(bounds_start, max_start) as i32
|
||
}
|
||
|
||
fn subscription(state: &AppState) -> Subscription<AppMessage> {
|
||
let core_sub = Subscription::run(core_subscription).map(AppMessage::UiEventReceived);
|
||
let event_sub = iced::event::listen().map(AppMessage::EventOccurred);
|
||
let clip_playing = status_snapshot(&state.clip_status).playing_id.is_some();
|
||
let music_playing = status_snapshot(&state.music_status).playing_id.is_some();
|
||
let audio_sub = if clip_playing
|
||
|| music_playing
|
||
|| state.music_active
|
||
|| state.music_listening_to.is_some()
|
||
{
|
||
iced::time::every(std::time::Duration::from_millis(250)).map(|_| AppMessage::AudioTick)
|
||
} else {
|
||
Subscription::none()
|
||
};
|
||
let clock_skew_sub = if state.clock_skew_warning.is_some() {
|
||
iced::time::every(std::time::Duration::from_secs(1))
|
||
.map(|_| AppMessage::ClockSkewWarningTick)
|
||
} else {
|
||
Subscription::none()
|
||
};
|
||
// Advance the friends "scanned Nm ago" indicator. Minute granularity, so a 30s
|
||
// tick keeps it within ~30s of accurate; only runs once a rescan has happened.
|
||
let rescan_label_sub = if state.last_rescan.is_some() {
|
||
iced::time::every(std::time::Duration::from_secs(30)).map(|_| AppMessage::RescanLabelTick)
|
||
} else {
|
||
Subscription::none()
|
||
};
|
||
Subscription::batch(vec![
|
||
core_sub,
|
||
event_sub,
|
||
audio_sub,
|
||
clock_skew_sub,
|
||
rescan_label_sub,
|
||
])
|
||
}
|
||
|
||
fn shutdown_timeout_task() -> Task<AppMessage> {
|
||
Task::perform(
|
||
async {
|
||
let (tx, rx) = iced::futures::channel::oneshot::channel();
|
||
std::thread::spawn(move || {
|
||
std::thread::sleep(std::time::Duration::from_secs(SHUTDOWN_TIMEOUT_SECS));
|
||
let _ = tx.send(());
|
||
});
|
||
let _ = rx.await;
|
||
},
|
||
|_| AppMessage::ShutdownTimeout,
|
||
)
|
||
}
|
||
|
||
/// Reconnect-chime edge trigger for `UiEvent::PeerConnecting`. Marks the peer as
|
||
/// connecting and returns `Some(Sound::ReconnectAttempt)` exactly once per outage:
|
||
/// only when the peer had a live link before (a genuine reconnect, not a first
|
||
/// dial) AND we weren't already in the connecting state (so the supervisor's
|
||
/// repeated redials while still down don't re-chime). Pure so the once-per-
|
||
/// disconnect behavior is unit-testable without a GUI or audio.
|
||
fn reconnect_attempt_chime(
|
||
connecting: &mut HashSet<EndpointId>,
|
||
ever_connected: &HashSet<EndpointId>,
|
||
id: EndpointId,
|
||
) -> Option<Sound> {
|
||
let is_reconnect_attempt = ever_connected.contains(&id);
|
||
let was_already_connecting = connecting.contains(&id);
|
||
connecting.insert(id);
|
||
(is_reconnect_attempt && !was_already_connecting).then_some(Sound::ReconnectAttempt)
|
||
}
|
||
|
||
/// Reconnect-chime edge trigger for `UiEvent::PeerConnected`. Clears the connecting
|
||
/// state, records that we've linked with this peer at least once, and returns
|
||
/// `Some(Sound::Reconnected)` only if it had connected before (a true reconnect, not
|
||
/// the first link). Pure so the logic is unit-testable.
|
||
fn reconnected_chime(
|
||
connecting: &mut HashSet<EndpointId>,
|
||
ever_connected: &mut HashSet<EndpointId>,
|
||
id: EndpointId,
|
||
) -> Option<Sound> {
|
||
let was_reconnect = ever_connected.contains(&id);
|
||
connecting.remove(&id);
|
||
ever_connected.insert(id);
|
||
was_reconnect.then_some(Sound::Reconnected)
|
||
}
|
||
|
||
fn in_call(state: &AppState) -> bool {
|
||
!state.ticket.is_empty()
|
||
}
|
||
|
||
fn toggle_mute(state: &mut AppState) {
|
||
if !in_call(state) {
|
||
return;
|
||
}
|
||
let _ = state.controller.send(CoreCommand::ToggleMute);
|
||
state.is_muted = !state.is_muted;
|
||
notify::play(
|
||
Sound::MicToggle,
|
||
state.config.custom_sound_mic_toggle.as_deref(),
|
||
);
|
||
}
|
||
|
||
fn toggle_deafen(state: &mut AppState) {
|
||
if !in_call(state) {
|
||
return;
|
||
}
|
||
let _ = state.controller.send(CoreCommand::ToggleDeafen);
|
||
state.is_deafened = !state.is_deafened;
|
||
notify::play(
|
||
Sound::MicToggle,
|
||
state.config.custom_sound_mic_toggle.as_deref(),
|
||
);
|
||
}
|
||
|
||
fn handle_hotkey_pressed(state: &mut AppState, action: HotkeyAction) {
|
||
match action {
|
||
HotkeyAction::ToggleMute => toggle_mute(state),
|
||
HotkeyAction::ToggleDeafen => toggle_deafen(state),
|
||
HotkeyAction::OpenSettings => {
|
||
state.current_screen = Screen::Settings;
|
||
state.hotkey_info_open = false;
|
||
state.layout_picker_open = false;
|
||
}
|
||
HotkeyAction::PushToTalk => {
|
||
if in_call(state) && state.ptt_enabled && !state.ptt_active {
|
||
state.ptt_active = true;
|
||
let _ = state.controller.send(CoreCommand::SetPttActive(true));
|
||
}
|
||
}
|
||
HotkeyAction::LeaveRoom => {
|
||
if in_call(state) {
|
||
let _ = state.controller.send(CoreCommand::Leave);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn set_peer_pan_config(config: &mut AppConfig, id: EndpointId, pan: f32) -> f32 {
|
||
let pan = pan.clamp(-1.0, 1.0);
|
||
let key = id.to_string();
|
||
if pan.abs() <= 0.001 {
|
||
config.peer_pan.remove(&key);
|
||
} else {
|
||
config.peer_pan.insert(key, pan);
|
||
}
|
||
pan
|
||
}
|
||
|
||
/// Store the per-peer listener volume, clamped to the slider range. Unity gain
|
||
/// (`1.0`) is the implicit default, so an at-unity entry is removed rather than
|
||
/// stored to keep the config tidy. Returns the clamped value.
|
||
fn set_peer_volume_config(config: &mut AppConfig, id: EndpointId, volume: f32) -> f32 {
|
||
let volume = volume.clamp(0.0, 2.0);
|
||
let key = id.to_string();
|
||
if (volume - 1.0).abs() <= 0.001 {
|
||
config.peer_volume.remove(&key);
|
||
} else {
|
||
config.peer_volume.insert(key, volume);
|
||
}
|
||
volume
|
||
}
|
||
|
||
/// Store the per-peer listener noise-gate threshold, clamped to the slider
|
||
/// range. `0.0` means the gate is off, so an at-zero entry is removed rather
|
||
/// than stored. Returns the clamped value.
|
||
fn set_peer_gate_config(config: &mut AppConfig, id: EndpointId, threshold: f32) -> f32 {
|
||
let threshold = threshold.clamp(0.0, METER_MAX);
|
||
let key = id.to_string();
|
||
if threshold <= 0.0 {
|
||
config.peer_gate.remove(&key);
|
||
} else {
|
||
config.peer_gate.insert(key, threshold);
|
||
}
|
||
threshold
|
||
}
|
||
|
||
fn peer_eq_settings(config: &AppConfig, id: &EndpointId) -> EqSettings {
|
||
config
|
||
.peer_eq
|
||
.get(&id.to_string())
|
||
.copied()
|
||
.unwrap_or_default()
|
||
.clamped()
|
||
}
|
||
|
||
fn set_peer_eq_config(
|
||
config: &mut AppConfig,
|
||
id: EndpointId,
|
||
band: EqBand,
|
||
gain_db: f32,
|
||
) -> EqSettings {
|
||
let key = id.to_string();
|
||
let mut settings = config.peer_eq.get(&key).copied().unwrap_or_default();
|
||
let gain_db = gain_db.clamp(EQ_GAIN_DB_MIN, EQ_GAIN_DB_MAX);
|
||
match band {
|
||
EqBand::Low => settings.low_gain_db = gain_db,
|
||
EqBand::Mid => settings.mid_gain_db = gain_db,
|
||
EqBand::High => settings.high_gain_db = gain_db,
|
||
}
|
||
settings = settings.clamped();
|
||
if settings.is_flat() {
|
||
config.peer_eq.remove(&key);
|
||
} else {
|
||
config.peer_eq.insert(key, settings);
|
||
}
|
||
settings
|
||
}
|
||
|
||
fn pan_label(pan: f32) -> String {
|
||
let pan = pan.clamp(-1.0, 1.0);
|
||
if pan.abs() <= 0.01 {
|
||
"Center".to_string()
|
||
} else if pan < 0.0 {
|
||
format!("L {:.0}%", pan.abs() * 100.0)
|
||
} else {
|
||
format!("R {:.0}%", pan * 100.0)
|
||
}
|
||
}
|
||
|
||
fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||
match message {
|
||
AppMessage::NicknameChanged(val) => {
|
||
state.name = val;
|
||
}
|
||
AppMessage::TicketInputChanged(val) => {
|
||
state.ticket_input = val;
|
||
}
|
||
AppMessage::RoomNameChanged(val) => {
|
||
state.room_name_input = val;
|
||
}
|
||
AppMessage::JoinPressed => {
|
||
let input_device = state.selected_input.as_ref().map(|d| d.name.clone());
|
||
let output_device = state.selected_output.as_ref().map(|d| d.name.clone());
|
||
if !state.ticket_input.is_empty() {
|
||
state.status_message = "Joining room...".to_string();
|
||
// Clear any prior room's UI state now, at initiation, so an early
|
||
// `PeerJoined` for the new room (which can beat `RoomJoined`) isn't
|
||
// wiped. From Home this is a no-op (already cleared on leave).
|
||
state.reset_room_state();
|
||
// Remember this nickname for next launch.
|
||
state.config.username = state.name.clone();
|
||
state.config.save();
|
||
// Core releases any standalone mic monitor on join.
|
||
state.mic_test_active = false;
|
||
let _ = state.controller.send(CoreCommand::Join {
|
||
name: state.name.clone(),
|
||
ticket: state.ticket_input.clone(),
|
||
room_name: String::new(), // joining: the label comes from the ticket
|
||
input_device,
|
||
output_device,
|
||
echo_cancellation: state.config.echo_cancellation_enabled,
|
||
avatar: state.config.avatar.clone(),
|
||
});
|
||
}
|
||
}
|
||
AppMessage::CreatePressed => {
|
||
let input_device = state.selected_input.as_ref().map(|d| d.name.clone());
|
||
let output_device = state.selected_output.as_ref().map(|d| d.name.clone());
|
||
state.status_message = "Creating room...".to_string();
|
||
// Clear any prior room's UI state at initiation (see JoinPressed).
|
||
state.reset_room_state();
|
||
// Remember this nickname for next launch.
|
||
state.config.username = state.name.clone();
|
||
state.config.save();
|
||
// Core releases any standalone mic monitor on join.
|
||
state.mic_test_active = false;
|
||
let _ = state.controller.send(CoreCommand::Join {
|
||
name: state.name.clone(),
|
||
ticket: "create".to_string(),
|
||
room_name: state.room_name_input.clone(),
|
||
input_device,
|
||
output_device,
|
||
echo_cancellation: state.config.echo_cancellation_enabled,
|
||
avatar: state.config.avatar.clone(),
|
||
});
|
||
}
|
||
AppMessage::LeavePressed => {
|
||
let _ = state.controller.send(CoreCommand::Leave);
|
||
}
|
||
AppMessage::ToggleScreenShare => {
|
||
if state.self_sharing {
|
||
let _ = state.controller.send(CoreCommand::StopScreenShare);
|
||
} else if !state.share_starting {
|
||
// Open the audio picker instead of sharing immediately, so the
|
||
// user chooses which app's audio to capture rather than the whole
|
||
// desktop (which echoes the call back to viewers, A23). Default
|
||
// selection is "All system audio" (None). Kick off a fresh
|
||
// enumeration so the list reflects what's playing right now.
|
||
// Suppressed while a start is already in flight (`share_starting`)
|
||
// so the picker can't be reopened during the startup window.
|
||
state.share_picker_open = true;
|
||
state.share_audio_selection = None;
|
||
// NB: do NOT reset `share_quality_selection` here. It is the
|
||
// per-call override set by the inline quality dropdown next to
|
||
// the Share button, and the picker has no quality control of its
|
||
// own — resetting it would silently discard the user's pick
|
||
// before `ConfirmShareScreen` reads it.
|
||
state.share_audio_apps.clear();
|
||
let _ = state.controller.send(CoreCommand::ListAudioApps);
|
||
}
|
||
}
|
||
AppMessage::CloseSharePicker => {
|
||
state.share_picker_open = false;
|
||
}
|
||
AppMessage::SelectShareAudioApp(app) => {
|
||
state.share_audio_selection = app;
|
||
}
|
||
AppMessage::SelectShareQualityOverride(quality) => {
|
||
state.share_quality_selection = quality;
|
||
}
|
||
AppMessage::ConfirmShareScreen => {
|
||
// Only a confirm from an open picker starts a share; a stray confirm
|
||
// (or one arriving while a start is already in flight) is ignored, so
|
||
// we can't double-send StartScreenShare.
|
||
if state.share_picker_open && !state.share_starting {
|
||
state.share_picker_open = false;
|
||
state.share_starting = true;
|
||
let audio_app = state.share_audio_selection.clone();
|
||
let settings = state.config.screen_share.clone();
|
||
let quality = state.share_quality_selection;
|
||
let _ = state.controller.send(CoreCommand::StartScreenShare {
|
||
audio_app,
|
||
settings,
|
||
quality,
|
||
});
|
||
state.status_message = "Starting screen share…".to_string();
|
||
}
|
||
}
|
||
AppMessage::WatchShare(ticket) => {
|
||
let settings = state.config.screen_share.clone();
|
||
let _ = state
|
||
.controller
|
||
.send(CoreCommand::ViewShare { ticket, settings });
|
||
state.status_message = "Opening screen share…".to_string();
|
||
}
|
||
AppMessage::ToggleMutePressed => {
|
||
toggle_mute(state);
|
||
}
|
||
AppMessage::ToggleDeafenPressed => {
|
||
toggle_deafen(state);
|
||
}
|
||
AppMessage::UiEventReceived(event) => {
|
||
match event {
|
||
UiEvent::RoomJoined { ticket, self_id } => {
|
||
// NB: do NOT clear peers here. `PeerJoined` rides a separate
|
||
// channel sender (the gossip event task) and routinely arrives
|
||
// BEFORE this `RoomJoined` (which the core emits only after audio
|
||
// + echo-cancel setup), so clearing here would wipe a peer that
|
||
// already announced → an empty roster. Room-scoped state is reset
|
||
// at join *initiation* instead (see the Join* handlers).
|
||
// Remember this gathering for one-click rejoin (W7 P5). The
|
||
// emitted ticket is the canonical room door (topic + member
|
||
// addr + label); push_recent de-dupes by topic and persists.
|
||
let label = crate::network::PeerSpeakTicket::label_of(&ticket);
|
||
let now = std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.map(|d| d.as_secs())
|
||
.unwrap_or(0);
|
||
crate::recents::push_recent(
|
||
&mut state.config.recents,
|
||
label,
|
||
ticket.clone(),
|
||
now,
|
||
);
|
||
state.config.save();
|
||
state.ticket = ticket;
|
||
state.self_id = self_id;
|
||
state.status_message = "Connected".to_string();
|
||
state.current_screen = Screen::Room;
|
||
state.call_started = Some(std::time::Instant::now());
|
||
// The core tore down any standalone mic monitor when joining;
|
||
// the in-call meter now drives mic_level.
|
||
state.mic_test_active = false;
|
||
notify::play(
|
||
Sound::SelfJoin,
|
||
state.config.custom_sound_self_join.as_deref(),
|
||
);
|
||
}
|
||
UiEvent::RoomLeft => {
|
||
state.reset_room_state();
|
||
state.ticket = "".to_string();
|
||
state.status_message = "Ready to connect".to_string();
|
||
state.current_screen = Screen::Home;
|
||
notify::play(
|
||
Sound::SelfLeave,
|
||
state.config.custom_sound_self_leave.as_deref(),
|
||
);
|
||
}
|
||
UiEvent::RoomReset => {
|
||
state.reset_room_state();
|
||
state.ticket = "".to_string();
|
||
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);
|
||
notify::play(
|
||
Sound::PeerJoin,
|
||
state.config.custom_sound_peer_join.as_deref(),
|
||
);
|
||
}
|
||
UiEvent::PeerLeft { id } => {
|
||
state.peers.remove(&id);
|
||
state.audio_levels.remove(&id);
|
||
state.locally_muted.remove(&id);
|
||
state.connecting.remove(&id);
|
||
state.ever_connected.remove(&id);
|
||
if state.music_listening_to == Some(id) {
|
||
state.music_listening_to = None;
|
||
state.music_player.stop();
|
||
state.music_listen_loaded = None;
|
||
state.music_listen_inflight = None;
|
||
}
|
||
notify::play(
|
||
Sound::PeerLeave,
|
||
state.config.custom_sound_peer_leave.as_deref(),
|
||
);
|
||
}
|
||
// Core-only recovery phase: presentation for this state lands in
|
||
// the separate UI follow-up. In particular, do not play the
|
||
// terminal ReconnectFailed chime here.
|
||
UiEvent::PeerRecoveryStarted { .. } => {}
|
||
UiEvent::PeerConnectionFailed { id } => {
|
||
state.peers.remove(&id);
|
||
state.audio_levels.remove(&id);
|
||
state.locally_muted.remove(&id);
|
||
state.connecting.remove(&id);
|
||
state.ever_connected.remove(&id);
|
||
notify::play(
|
||
Sound::ReconnectFailed,
|
||
state.config.custom_sound_reconnect_failed.as_deref(),
|
||
);
|
||
}
|
||
UiEvent::PeerUpdated {
|
||
id,
|
||
state: peer_state,
|
||
} => {
|
||
state.peers.insert(id, peer_state);
|
||
if state.music_listening_to == Some(id) {
|
||
reconcile_listen(state);
|
||
}
|
||
}
|
||
UiEvent::PeerConnecting { id } => {
|
||
if let Some(sound) =
|
||
reconnect_attempt_chime(&mut state.connecting, &state.ever_connected, id)
|
||
{
|
||
notify::play(
|
||
sound,
|
||
state.config.custom_sound_reconnect_attempt.as_deref(),
|
||
);
|
||
}
|
||
}
|
||
UiEvent::PeerConnected { id } => {
|
||
if let Some(sound) =
|
||
reconnected_chime(&mut state.connecting, &mut state.ever_connected, id)
|
||
{
|
||
notify::play(sound, state.config.custom_sound_reconnected.as_deref());
|
||
}
|
||
}
|
||
UiEvent::AudioLevels(levels) => {
|
||
for (id, val) in levels {
|
||
state.audio_levels.insert(id, val);
|
||
}
|
||
}
|
||
UiEvent::MicLevel(level) => {
|
||
state.mic_level = level;
|
||
}
|
||
UiEvent::RecordingStarted { path } => {
|
||
state.recording = true;
|
||
state.recording_started = Some(std::time::Instant::now());
|
||
state.status_message = format!("Recording → {path}");
|
||
}
|
||
UiEvent::RecordingStopped { path } => {
|
||
state.recording = false;
|
||
state.recording_started = None;
|
||
state.status_message = format!("Saved recording → {path}");
|
||
}
|
||
UiEvent::ChatMessage {
|
||
from,
|
||
name,
|
||
text,
|
||
attachment,
|
||
} => {
|
||
// Incoming peer content is untrusted — sanitize name + text.
|
||
// (The attachment filename was already sanitized in core.)
|
||
let text = sanitize_chat(&text);
|
||
// Keep the message if it has visible text OR an attachment (an
|
||
// image with no caption is still a real message).
|
||
if !text.is_empty() || attachment.is_some() {
|
||
let name = sanitize_chat(&name);
|
||
push_chat(
|
||
&mut state.chat_messages,
|
||
ChatEntry {
|
||
name,
|
||
text,
|
||
mine: false,
|
||
from: Some(from),
|
||
attachment,
|
||
},
|
||
);
|
||
}
|
||
}
|
||
UiEvent::AttachmentReady { from, id, data } => {
|
||
// Bytes arrived for this specific (author, id). For images we
|
||
// can cache the iced handle now (built once, not per redraw).
|
||
// If the user was waiting to save this file, the save dialog
|
||
// is opened from update() below by checking pending_saves —
|
||
// done lazily so this arm stays simple.
|
||
let key = (from, id);
|
||
let handle = crate::files::validate_image_bytes(&data)
|
||
.is_some()
|
||
.then(|| iced::widget::image::Handle::from_bytes(data.clone()));
|
||
let needs_save = state.pending_saves.remove(&key);
|
||
let needs_play = state.pending_plays.remove(&id);
|
||
state
|
||
.attachments
|
||
.insert(key, AttachmentState::Ready(data), handle);
|
||
if needs_play {
|
||
play_ready_audio(state, key);
|
||
}
|
||
if needs_save {
|
||
return save_attachment_task(state, key);
|
||
}
|
||
}
|
||
UiEvent::AttachmentFailed { from, id, error } => {
|
||
let key = (from, id);
|
||
state.pending_saves.remove(&key);
|
||
state.pending_plays.remove(&id);
|
||
state
|
||
.attachments
|
||
.insert(key, AttachmentState::Failed(error.clone()), None);
|
||
state.status_message = format!("Attachment failed: {error}");
|
||
}
|
||
UiEvent::MusicReady { from, id, data } => {
|
||
if state.music_listening_to == Some(from)
|
||
&& state.music_listen_inflight == Some(id)
|
||
{
|
||
state.music_listen_inflight = None;
|
||
if crate::files::is_probably_audio(&data) {
|
||
state.music_player.play(id, data);
|
||
state.music_player.set_volume(effective_music_volume(state));
|
||
state.music_listen_loaded = Some(id);
|
||
reconcile_listen(state);
|
||
} else {
|
||
state.music_player.stop();
|
||
state.music_listen_loaded = None;
|
||
state.status_message =
|
||
"Shared track is not valid supported audio.".to_string();
|
||
}
|
||
}
|
||
}
|
||
UiEvent::MusicPrefetched { from, id, data } => {
|
||
if state.music_listening_to == Some(from)
|
||
&& state.music_prefetch_inflight == Some(id)
|
||
{
|
||
state.music_prefetch_inflight = None;
|
||
if crate::files::is_probably_audio(&data) {
|
||
state.music_prefetch = Some((id, data));
|
||
}
|
||
}
|
||
}
|
||
UiEvent::MusicFetchFailed { from, id, error } => {
|
||
if state.music_listening_to == Some(from)
|
||
&& state.music_listen_inflight == Some(id)
|
||
{
|
||
state.music_listen_inflight = None;
|
||
state.status_message = format!("Music fetch failed: {error}");
|
||
} else if state.music_listening_to == Some(from)
|
||
&& state.music_prefetch_inflight == Some(id)
|
||
{
|
||
state.music_prefetch_inflight = None;
|
||
}
|
||
}
|
||
UiEvent::AudioAppsListed {
|
||
apps,
|
||
app_audio_supported,
|
||
} => {
|
||
// Only meaningful while the picker is open; if the user
|
||
// already cancelled, drop it.
|
||
if state.share_picker_open {
|
||
state.share_app_audio_supported = app_audio_supported;
|
||
if app_audio_supported {
|
||
// Keep the current selection if it still exists in the
|
||
// refreshed list, else fall back to "All system audio".
|
||
if let Some(sel) = &state.share_audio_selection
|
||
&& !apps.iter().any(|a| a == sel)
|
||
{
|
||
state.share_audio_selection = None;
|
||
}
|
||
state.share_audio_apps = apps;
|
||
} else {
|
||
// Older pixelpass: per-app capture would hard-fail
|
||
// (--strict-audio unknown). Force whole-desktop only.
|
||
state.share_audio_apps.clear();
|
||
state.share_audio_selection = None;
|
||
}
|
||
}
|
||
}
|
||
UiEvent::ScreenShareStarted => {
|
||
state.self_sharing = true;
|
||
state.share_starting = false;
|
||
state.share_audio_dropped = false;
|
||
// Remember whether this share captures a specific app, so we
|
||
// only apply `app_audio` warnings to app shares (P3).
|
||
state.share_audio_app_active = state.share_audio_selection.is_some();
|
||
// Defensive: ensure no picker lingers across a successful start.
|
||
state.share_picker_open = false;
|
||
state.status_message = "Sharing your screen".to_string();
|
||
}
|
||
UiEvent::ScreenShareStopped => {
|
||
state.self_sharing = false;
|
||
state.share_starting = false;
|
||
state.share_audio_dropped = false;
|
||
state.share_audio_app_active = false;
|
||
state.status_message = "Screen share stopped".to_string();
|
||
}
|
||
UiEvent::ShareAudioActive(active) => {
|
||
// Per-app audio routed/lost. Apply only while we're actually
|
||
// sharing a specific app: a late event from a just-killed host
|
||
// must not flip the warning after stop or on a whole-desktop
|
||
// share (audit P3, unscoped events).
|
||
if state.self_sharing && state.share_audio_app_active {
|
||
state.share_audio_dropped = !active;
|
||
}
|
||
}
|
||
UiEvent::ClockSkewWarning {
|
||
skew_secs,
|
||
peer_ahead,
|
||
} => {
|
||
show_clock_skew_warning(
|
||
state,
|
||
skew_secs,
|
||
peer_ahead,
|
||
std::time::Instant::now(),
|
||
);
|
||
}
|
||
UiEvent::IdentityStatus {
|
||
node_id,
|
||
persisted,
|
||
error,
|
||
} => {
|
||
state.self_node_id = Some(node_id);
|
||
state.identity_persisted = persisted;
|
||
state.identity_error = error;
|
||
}
|
||
UiEvent::FriendsUpdated { friends, read_only } => {
|
||
// Core owns the store; mirror its snapshot. Drop presence for
|
||
// anyone no longer a friend so the UI doesn't show a stale dot.
|
||
let ids: HashSet<EndpointId> = friends.iter().map(|f| f.id).collect();
|
||
state.friend_presence.retain(|id, _| ids.contains(id));
|
||
state.friends.friends = friends;
|
||
state.friends_read_only = read_only;
|
||
}
|
||
UiEvent::FriendPresence { id, presence } => {
|
||
state.friend_presence.insert(id, presence);
|
||
}
|
||
UiEvent::FriendsRescanned => {
|
||
// The manual pass finished. Stamp the time for the live "scanned
|
||
// Nm ago" indicator, and replace the transient "Rescanning…"
|
||
// banner — but only if it's still showing (don't clobber a status
|
||
// the user has since triggered, e.g. by joining a room).
|
||
state.last_rescan = Some(std::time::Instant::now());
|
||
if state.status_message == "Rescanning friends…" {
|
||
state.status_message = "Friends rescanned.".to_string();
|
||
}
|
||
}
|
||
UiEvent::PresenceModeReverted { mode } => {
|
||
// Core corrected the committed presence mode. Mirror + persist so
|
||
// the picker reflects the discovery state the endpoint actually has.
|
||
state.config.presence_mode = mode;
|
||
state.config.save();
|
||
state.status_message = if mode == PresenceMode::Normal {
|
||
"Discoverable timed out — back to Normal".to_string()
|
||
} else {
|
||
format!("Presence mode stayed {mode}")
|
||
};
|
||
}
|
||
UiEvent::GameChanged(detected) => {
|
||
// The locally-detected game changed: switch the per-game
|
||
// background (W18) if one is mapped, else fall back to the
|
||
// default. Presence broadcasting is handled in core, gated by
|
||
// the opt-in toggle; this is purely local presentation.
|
||
state.current_game = detected;
|
||
state.background_image =
|
||
effective_background_bytes(&state.config, state.current_game.as_ref());
|
||
}
|
||
UiEvent::ShutdownComplete => {
|
||
if state.closing {
|
||
return iced::exit();
|
||
}
|
||
}
|
||
UiEvent::Error(err) => {
|
||
// A failed share start (spawn error) surfaces here, not via
|
||
// ScreenShareStopped, so clear the in-flight flag to let the
|
||
// user retry instead of being wedged.
|
||
state.share_starting = false;
|
||
state.status_message = format!("Error: {}", err);
|
||
}
|
||
}
|
||
}
|
||
AppMessage::CopyToClipboard => {
|
||
if !state.ticket.is_empty() {
|
||
return iced::clipboard::write(state.ticket.clone());
|
||
}
|
||
}
|
||
AppMessage::CopyText(s) => {
|
||
return iced::clipboard::write(s);
|
||
}
|
||
AppMessage::Noop => {}
|
||
AppMessage::TogglePtt(enabled) => {
|
||
state.ptt_enabled = enabled;
|
||
let _ = state.controller.send(CoreCommand::SetPttMode(enabled));
|
||
}
|
||
AppMessage::StartHotkeyCapture(action) => {
|
||
state.hotkey_capture = Some(action);
|
||
state.hotkey_info_open = false;
|
||
}
|
||
AppMessage::ClearHotkey(action) => {
|
||
state.config.hotkeys.set_binding(action, None);
|
||
state.config.save();
|
||
}
|
||
AppMessage::PeerVolumeChanged(id, vol) => {
|
||
let vol = set_peer_volume_config(&mut state.config, id, vol);
|
||
let _ = state.controller.send(CoreCommand::SetPeerVolume(id, vol));
|
||
}
|
||
AppMessage::PeerPanChanged(id, pan) => {
|
||
let pan = set_peer_pan_config(&mut state.config, id, pan);
|
||
let _ = state.controller.send(CoreCommand::SetPeerPan(id, pan));
|
||
}
|
||
AppMessage::PeerGateChanged(id, threshold) => {
|
||
let threshold = set_peer_gate_config(&mut state.config, id, threshold);
|
||
let _ = state
|
||
.controller
|
||
.send(CoreCommand::SetPeerGate(id, threshold));
|
||
}
|
||
AppMessage::PeerEqChanged(id, band, gain_db) => {
|
||
let settings = set_peer_eq_config(&mut state.config, id, band, gain_db);
|
||
let _ = state.controller.send(CoreCommand::SetPeerEq(id, settings));
|
||
}
|
||
AppMessage::TogglePeerMute(id) => {
|
||
let now_muted = if state.locally_muted.contains(&id) {
|
||
state.locally_muted.remove(&id);
|
||
false
|
||
} else {
|
||
state.locally_muted.insert(id);
|
||
true
|
||
};
|
||
let _ = state
|
||
.controller
|
||
.send(CoreCommand::SetPeerMuted(id, now_muted));
|
||
}
|
||
AppMessage::InputDeviceSelected(dev) => {
|
||
state.config.input_device = dev.name.clone();
|
||
state.config.save();
|
||
state.selected_input = Some(dev);
|
||
}
|
||
AppMessage::OutputDeviceSelected(dev) => {
|
||
state.config.output_device = dev.name.clone();
|
||
state.config.save();
|
||
state.selected_output = Some(dev);
|
||
}
|
||
AppMessage::InputVolumeChanged(vol) => {
|
||
// Live apply; disk write deferred to release (PersistConfig).
|
||
state.config.input_volume = vol;
|
||
let _ = state.controller.send(CoreCommand::SetInputVolume(vol));
|
||
}
|
||
AppMessage::OutputVolumeChanged(vol) => {
|
||
state.config.output_volume = vol;
|
||
let _ = state.controller.send(CoreCommand::SetOutputVolume(vol));
|
||
}
|
||
AppMessage::PersistConfig => {
|
||
state.config.save();
|
||
}
|
||
AppMessage::NoiseGateChanged(val) => {
|
||
state.config.noise_gate_threshold = val;
|
||
state.config.save();
|
||
let _ = state
|
||
.controller
|
||
.send(CoreCommand::SetNoiseGateThreshold(val));
|
||
}
|
||
AppMessage::NoiseGateDragging(val) => {
|
||
// Live drag: apply immediately, defer the disk write to release.
|
||
state.config.noise_gate_threshold = val;
|
||
let _ = state
|
||
.controller
|
||
.send(CoreCommand::SetNoiseGateThreshold(val));
|
||
}
|
||
AppMessage::NetworkModeSelected(mode) => {
|
||
state.config.network_mode = mode;
|
||
state.config.save();
|
||
// Applied on the next join, since the endpoint is rebuilt then.
|
||
let _ = state.controller.send(CoreCommand::SetNetworkMode(mode));
|
||
}
|
||
AppMessage::AudioProfileSelected(profile) => {
|
||
state.config.audio_profile = profile;
|
||
state.config.save();
|
||
// Applies live to the running encoder, and to the next call.
|
||
let _ = state.controller.send(CoreCommand::SetAudioProfile(profile));
|
||
}
|
||
AppMessage::RecordingModeSelected(mode) => {
|
||
state.config.recording_mode = mode;
|
||
state.config.save();
|
||
// Takes effect on the next recording start.
|
||
let _ = state.controller.send(CoreCommand::SetRecordingMode(mode));
|
||
}
|
||
AppMessage::ScreenShareQualitySelected(quality) => {
|
||
state.config.screen_share.quality = quality;
|
||
state.share_quality_selection = quality;
|
||
state.config.save();
|
||
}
|
||
AppMessage::ScreenSharePlayerSelected(player) => {
|
||
state.config.screen_share.player = player;
|
||
state.config.save();
|
||
}
|
||
AppMessage::ScreenShareBufferingSelected(buffering) => {
|
||
state.config.screen_share.buffering = buffering;
|
||
state.config.save();
|
||
}
|
||
AppMessage::ScreenShareMaxHeightSelected(choice) => {
|
||
state.config.screen_share.max_height = choice.to_config();
|
||
state.config.save();
|
||
}
|
||
AppMessage::ScreenShareFramerateSelected(choice) => {
|
||
state.config.screen_share.framerate = choice.to_config();
|
||
state.config.save();
|
||
}
|
||
AppMessage::ScreenShareMaxViewersSelected(choice) => {
|
||
state.config.screen_share.max_viewers = choice.to_config();
|
||
state.config.save();
|
||
}
|
||
AppMessage::ScreenShareCacheMbSelected(cache_mb) => {
|
||
state.config.screen_share.cache_mb = cache_mb;
|
||
state.config.save();
|
||
}
|
||
AppMessage::ScreenShareBitrateChanged(value) => {
|
||
let trimmed = value.trim();
|
||
if trimmed.is_empty() {
|
||
state.config.screen_share.bitrate_mbps = None;
|
||
state.config.save();
|
||
} else if let Ok(mbps) = trimmed.parse::<u32>() {
|
||
state.config.screen_share.bitrate_mbps = Some(mbps);
|
||
state.config.save();
|
||
}
|
||
}
|
||
AppMessage::ToggleScreenShareForceSoftwareEncode(enabled) => {
|
||
state.config.screen_share.force_software_encode = enabled;
|
||
state.config.save();
|
||
}
|
||
AppMessage::ToggleScreenShareHardwareDecode(enabled) => {
|
||
state.config.screen_share.hardware_decode = enabled;
|
||
state.config.save();
|
||
}
|
||
AppMessage::ScreenShareExtraMpvArgsChanged(args) => {
|
||
state.config.screen_share.extra_mpv_args = args;
|
||
state.config.save();
|
||
}
|
||
AppMessage::ScreenShareExtraHostArgsChanged(args) => {
|
||
state.config.screen_share.extra_host_args = args;
|
||
state.config.save();
|
||
}
|
||
AppMessage::PresenceModeSelected(mode) => {
|
||
state.config.presence_mode = mode;
|
||
state.config.save();
|
||
// Push to core, which gates the live listener + ping scheduler (B2).
|
||
let _ = state.controller.send(CoreCommand::SetPresenceMode(mode));
|
||
}
|
||
AppMessage::FriendAddIdChanged(val) => {
|
||
state.friend_add_id = val;
|
||
state.friend_add_error = None;
|
||
}
|
||
AppMessage::FriendAddNameChanged(val) => {
|
||
state.friend_add_name = val;
|
||
state.friend_add_error = None;
|
||
}
|
||
AppMessage::AddFriend => {
|
||
// Core owns the store; the GUI just validates the id locally, then sends
|
||
// a command. The mirror (and the cleared form) update on FriendsUpdated.
|
||
if state.friends_read_only {
|
||
state.friend_add_error =
|
||
Some("Friends list is read-only (couldn't load friends.json).".to_string());
|
||
} else {
|
||
let id_str = state.friend_add_id.trim();
|
||
match id_str.parse::<EndpointId>() {
|
||
Ok(id) if state.friends.contains(&id) => {
|
||
state.friend_add_error = Some("Already in your friends list.".to_string());
|
||
}
|
||
Ok(id) => {
|
||
let name = {
|
||
let n = state.friend_add_name.trim();
|
||
if n.is_empty() {
|
||
short_id(id_str)
|
||
} else {
|
||
n.to_string()
|
||
}
|
||
};
|
||
let _ = state.controller.send(CoreCommand::AddFriend {
|
||
id,
|
||
name,
|
||
addr: None,
|
||
});
|
||
state.friend_add_id.clear();
|
||
state.friend_add_name.clear();
|
||
state.friend_add_error = None;
|
||
}
|
||
Err(_) => {
|
||
state.friend_add_error =
|
||
Some("That doesn't look like a valid node ID.".to_string());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
AppMessage::AddFriendFromRoom(id) => {
|
||
// Add a co-participant: pull their name + live address from the room
|
||
// roster so the friend is reachable right away (no wait for a future
|
||
// call to seed last_addr, unlike a bare add-by-id). No-op if read-only
|
||
// or already a friend; idempotent in core regardless.
|
||
if !state.friends_read_only
|
||
&& !state.friends.contains(&id)
|
||
&& let Some(peer) = state.peers.get(&id)
|
||
{
|
||
let name = {
|
||
let n = crate::sanitize::sanitize_name(&peer.name);
|
||
if n.is_empty() {
|
||
short_id(&id.to_string())
|
||
} else {
|
||
n
|
||
}
|
||
};
|
||
let _ = state.controller.send(CoreCommand::AddFriend {
|
||
id,
|
||
name,
|
||
addr: Some(peer.addr.clone()),
|
||
});
|
||
}
|
||
}
|
||
AppMessage::RemoveFriend(id) => {
|
||
if !state.friends_read_only {
|
||
let _ = state.controller.send(CoreCommand::RemoveFriend(id));
|
||
}
|
||
}
|
||
AppMessage::RenameFriend(id, new_name) => {
|
||
if !state.friends_read_only {
|
||
let _ = state
|
||
.controller
|
||
.send(CoreCommand::RenameFriend(id, new_name));
|
||
}
|
||
}
|
||
AppMessage::RefreshFriends => {
|
||
// Kick an immediate presence pass; the list updates as replies land and
|
||
// the banner clears on the FriendsRescanned completion event. While
|
||
// Invisible we probe no one, so say why instead of a banner that resolves
|
||
// with nothing changed.
|
||
if state.config.presence_mode == PresenceMode::Invisible {
|
||
state.status_message =
|
||
"You're invisible — turn on presence to scan friends.".to_string();
|
||
} else {
|
||
let _ = state.controller.send(CoreCommand::RefreshFriends);
|
||
state.status_message = "Rescanning friends…".to_string();
|
||
}
|
||
}
|
||
AppMessage::JoinFriendRoom(ticket) => {
|
||
// Join via the friend's member ticket (same path as a manual join).
|
||
let input_device = state.selected_input.as_ref().map(|d| d.name.clone());
|
||
let output_device = state.selected_output.as_ref().map(|d| d.name.clone());
|
||
state.status_message = "Joining your friend's room...".to_string();
|
||
// Clear any prior room's UI state at initiation (see JoinPressed).
|
||
state.reset_room_state();
|
||
state.config.username = state.name.clone();
|
||
state.config.save();
|
||
state.mic_test_active = false;
|
||
let _ = state.controller.send(CoreCommand::Join {
|
||
name: state.name.clone(),
|
||
ticket,
|
||
room_name: String::new(), // joining: the label comes from the ticket
|
||
input_device,
|
||
output_device,
|
||
echo_cancellation: state.config.echo_cancellation_enabled,
|
||
avatar: state.config.avatar.clone(),
|
||
});
|
||
}
|
||
AppMessage::JoinRecent(ticket) => {
|
||
// Rejoin a remembered room (same path as a manual ticket join). It's
|
||
// best-effort: the door only admits us while the room is still live.
|
||
let input_device = state.selected_input.as_ref().map(|d| d.name.clone());
|
||
let output_device = state.selected_output.as_ref().map(|d| d.name.clone());
|
||
state.status_message = "Rejoining a recent room...".to_string();
|
||
// Clear any prior room's UI state at initiation (see JoinPressed).
|
||
state.reset_room_state();
|
||
state.config.username = state.name.clone();
|
||
state.config.save();
|
||
state.mic_test_active = false;
|
||
let _ = state.controller.send(CoreCommand::Join {
|
||
name: state.name.clone(),
|
||
ticket,
|
||
room_name: String::new(), // rejoining: the label comes from the ticket
|
||
input_device,
|
||
output_device,
|
||
echo_cancellation: state.config.echo_cancellation_enabled,
|
||
avatar: state.config.avatar.clone(),
|
||
});
|
||
}
|
||
AppMessage::RemoveRecent(ticket) => {
|
||
crate::recents::remove_recent(&mut state.config.recents, &ticket);
|
||
state.config.save();
|
||
}
|
||
AppMessage::SelectSettingsCategory(category) => {
|
||
state.settings_category = category;
|
||
}
|
||
AppMessage::ToggleNotifications(enabled) => {
|
||
state.config.notifications_enabled = enabled;
|
||
state.config.save();
|
||
notify::set_enabled(enabled);
|
||
}
|
||
AppMessage::ToggleEchoCancellation(enabled) => {
|
||
state.config.echo_cancellation_enabled = enabled;
|
||
state.config.save();
|
||
// Applied on the next join, since the audio graph is rebuilt then.
|
||
}
|
||
AppMessage::CustomSoundPathChanged(sound, path) => {
|
||
let path_opt = if path.trim().is_empty() {
|
||
None
|
||
} else {
|
||
Some(path)
|
||
};
|
||
match sound {
|
||
Sound::SelfJoin => state.config.custom_sound_self_join = path_opt,
|
||
Sound::PeerJoin => state.config.custom_sound_peer_join = path_opt,
|
||
Sound::PeerLeave => state.config.custom_sound_peer_leave = path_opt,
|
||
Sound::ReconnectAttempt => state.config.custom_sound_reconnect_attempt = path_opt,
|
||
Sound::Reconnected => state.config.custom_sound_reconnected = path_opt,
|
||
Sound::SelfLeave => state.config.custom_sound_self_leave = path_opt,
|
||
Sound::MicToggle => state.config.custom_sound_mic_toggle = path_opt,
|
||
Sound::ReconnectFailed => state.config.custom_sound_reconnect_failed = path_opt,
|
||
}
|
||
}
|
||
AppMessage::ToggleSoundEnabled(sound, enabled) => {
|
||
state.config.set_sound_enabled(sound, enabled);
|
||
state.config.save();
|
||
notify::set_sound_enabled(sound, enabled);
|
||
}
|
||
AppMessage::OpenRegenerateIdentityConfirm => {
|
||
state.regenerate_identity_confirm_open = true;
|
||
}
|
||
AppMessage::CloseRegenerateIdentityConfirm => {
|
||
state.regenerate_identity_confirm_open = false;
|
||
}
|
||
AppMessage::OpenImageLightbox(key) => {
|
||
state.image_lightbox = Some(key);
|
||
}
|
||
AppMessage::CloseImageLightbox => {
|
||
state.image_lightbox = None;
|
||
}
|
||
AppMessage::ConfirmRegenerateIdentity => {
|
||
state.regenerate_identity_confirm_open = false;
|
||
// The core mints + persists the new key and replies with a fresh
|
||
// IdentityStatus (which updates self_node_id / persisted here).
|
||
let _ = state.controller.send(CoreCommand::RegenerateIdentity);
|
||
}
|
||
AppMessage::ToggleRecording => {
|
||
// Optimistic intent; the core flips `recording` for real via the
|
||
// Recording{Started,Stopped} events (so a failed start won't lie).
|
||
let _ = state
|
||
.controller
|
||
.send(CoreCommand::SetRecording(!state.recording));
|
||
}
|
||
AppMessage::ChatInputChanged(val) => {
|
||
state.chat_input = val;
|
||
}
|
||
AppMessage::DividerDragged(kind, delta) => {
|
||
// Apply live; the final position is persisted on drag release (the
|
||
// divider publishes PersistConfig then) to avoid per-pixel disk writes.
|
||
match kind {
|
||
DividerKind::Panels => {
|
||
state.config.participants_width = clamp_participants_width(
|
||
state.config.participants_width + delta,
|
||
state.window_size.width,
|
||
);
|
||
}
|
||
DividerKind::Chat => {
|
||
// Dragging the divider down (positive delta) gives the main row
|
||
// more room and shrinks the dock below it, so subtract.
|
||
state.config.chat_height = clamp_chat_height(
|
||
state.config.chat_height - delta,
|
||
state.window_size.height,
|
||
);
|
||
}
|
||
DividerKind::Controls => {
|
||
// Controls sits on the right; dragging the divider right (positive
|
||
// delta) gives Chat more room and shrinks Controls.
|
||
state.config.controls_width = clamp_controls_width(
|
||
state.config.controls_width - delta,
|
||
state.window_size.width,
|
||
);
|
||
}
|
||
DividerKind::ChatDrawer => {
|
||
// The drawer sits on the right; dragging its left-edge divider
|
||
// left (negative delta) widens the drawer.
|
||
state.config.chat_drawer_width = clamp_chat_drawer_width(
|
||
state.config.chat_drawer_width - delta,
|
||
state.window_size.width,
|
||
);
|
||
}
|
||
DividerKind::PlaylistDrawer => {
|
||
// The drawer sits on the right; dragging its left-edge divider
|
||
// left (negative delta) widens the drawer.
|
||
state.config.playlist_drawer_width = clamp_playlist_drawer_width(
|
||
state.config.playlist_drawer_width - delta,
|
||
state.window_size.width,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
AppMessage::OpenLayoutPicker => {
|
||
state.layout_picker_open = true;
|
||
state.hotkey_info_open = false;
|
||
}
|
||
AppMessage::CloseLayoutPicker => {
|
||
state.layout_picker_open = false;
|
||
}
|
||
AppMessage::OpenHotkeyInfo => {
|
||
state.hotkey_info_open = true;
|
||
state.layout_picker_open = false;
|
||
}
|
||
AppMessage::CloseHotkeyInfo => {
|
||
state.hotkey_info_open = false;
|
||
}
|
||
AppMessage::OpenPixelpassHelp => {
|
||
state.pixelpass_help_open = true;
|
||
}
|
||
AppMessage::ClosePixelpassHelp => {
|
||
state.pixelpass_help_open = false;
|
||
}
|
||
AppMessage::DismissClockSkewWarning => {
|
||
state.clock_skew_warning = None;
|
||
}
|
||
AppMessage::ClockSkewWarningTick => {
|
||
clear_expired_clock_skew_warning(state, std::time::Instant::now());
|
||
}
|
||
AppMessage::RescanLabelTick => {
|
||
// No state change; the tick exists purely to re-render the relative
|
||
// "scanned Nm ago" label as time passes.
|
||
}
|
||
AppMessage::SelectRoomLayout(layout) => {
|
||
state.config.room_layout = layout;
|
||
state.config.save();
|
||
state.layout_picker_open = false;
|
||
}
|
||
AppMessage::SelectTheme(theme) => {
|
||
state.config.theme = theme;
|
||
state.config.save();
|
||
}
|
||
AppMessage::SelectAvatar(avatar) => {
|
||
state.config.avatar = avatar.clone();
|
||
state.config.save();
|
||
// Re-announce to the room if we're in a call (no-op otherwise).
|
||
let _ = state.controller.send(CoreCommand::SetAvatar(avatar));
|
||
}
|
||
AppMessage::PickAvatarFile => {
|
||
// Open the native picker off the UI thread; the result comes back as
|
||
// AvatarFilePicked. Filter to the formats we can actually decode.
|
||
return Task::perform(
|
||
async {
|
||
let handle = rfd::AsyncFileDialog::new()
|
||
.add_filter("Images", &["png", "jpg", "jpeg"])
|
||
.set_title("Choose an avatar image")
|
||
.pick_file()
|
||
.await;
|
||
match handle {
|
||
Some(h) => Some(h.read().await),
|
||
None => None,
|
||
}
|
||
},
|
||
AppMessage::AvatarFilePicked,
|
||
);
|
||
}
|
||
AppMessage::AvatarFilePicked(picked) => {
|
||
if let Some(bytes) = picked {
|
||
match crate::avatar::process_upload(&bytes) {
|
||
Ok(avatar) => {
|
||
state.config.avatar = avatar.clone();
|
||
state.config.save();
|
||
state.status_message = "Avatar updated.".to_string();
|
||
let _ = state.controller.send(CoreCommand::SetAvatar(avatar));
|
||
}
|
||
Err(e) => {
|
||
state.status_message = e;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
AppMessage::PickBackgroundFile => {
|
||
// Native picker off the UI thread; result returns as BackgroundFilePicked.
|
||
return Task::perform(
|
||
async {
|
||
let handle = rfd::AsyncFileDialog::new()
|
||
.add_filter("Images", &["png", "jpg", "jpeg", "webp", "bmp"])
|
||
.set_title("Choose a background image")
|
||
.pick_file()
|
||
.await;
|
||
match handle {
|
||
Some(h) => Some(h.read().await),
|
||
None => None,
|
||
}
|
||
},
|
||
AppMessage::BackgroundFilePicked,
|
||
);
|
||
}
|
||
AppMessage::BackgroundFilePicked(picked) => {
|
||
if let Some(bytes) = picked {
|
||
match crate::background::process_background(&bytes) {
|
||
Ok(png) => match AppConfig::background_path() {
|
||
Some(path) => {
|
||
if let Some(dir) = path.parent() {
|
||
let _ = std::fs::create_dir_all(dir);
|
||
}
|
||
match std::fs::write(&path, &png) {
|
||
Ok(()) => {
|
||
state.config.background =
|
||
Some(path.to_string_lossy().into_owned());
|
||
state.config.save();
|
||
// Refresh the in-memory cache from the bytes we
|
||
// just wrote (avoids re-reading from disk).
|
||
state.background_image = Some(bytes::Bytes::from(png));
|
||
state.status_message = "Background updated.".to_string();
|
||
}
|
||
Err(e) => {
|
||
state.status_message = format!("Couldn't save background: {e}");
|
||
}
|
||
}
|
||
}
|
||
None => {
|
||
state.status_message =
|
||
"Couldn't find a config directory to save the background."
|
||
.to_string();
|
||
}
|
||
},
|
||
Err(e) => {
|
||
state.status_message = e;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
AppMessage::RemoveBackground => {
|
||
// Best-effort delete of our stored copy; clear the config + cache.
|
||
if let Some(path) = AppConfig::background_path() {
|
||
let _ = std::fs::remove_file(path);
|
||
}
|
||
state.config.background = None;
|
||
state.config.save();
|
||
state.background_image = None;
|
||
state.status_message = "Background removed.".to_string();
|
||
}
|
||
AppMessage::SetBackgroundDim(dim) => {
|
||
state.config.background_dim = dim.clamp(0.0, 1.0);
|
||
state.config.save();
|
||
}
|
||
AppMessage::ToggleGamePresence(enabled) => {
|
||
state.config.game_presence_enabled = enabled;
|
||
state.config.save();
|
||
// Core publishes/clears our game immediately (D8).
|
||
let _ = state
|
||
.controller
|
||
.send(CoreCommand::SetGamePresenceEnabled(enabled));
|
||
}
|
||
AppMessage::GameOverrideSelected(choice) => {
|
||
state.game_override = choice;
|
||
let override_ = match choice {
|
||
GameOverrideChoice::Auto => crate::game::ManualOverride::Auto,
|
||
GameOverrideChoice::None => crate::game::ManualOverride::ForceNone,
|
||
// "Pin current" carries the live detection; nothing detected = Auto.
|
||
GameOverrideChoice::Current => match &state.current_game {
|
||
Some(g) => crate::game::ManualOverride::Force(g.clone()),
|
||
None => crate::game::ManualOverride::Auto,
|
||
},
|
||
};
|
||
let _ = state
|
||
.controller
|
||
.send(CoreCommand::SetGameOverride(override_));
|
||
}
|
||
AppMessage::GameMapExeChanged(val) => {
|
||
state.game_map_exe_input = val;
|
||
}
|
||
AppMessage::GameMapNameChanged(val) => {
|
||
state.game_map_name_input = val;
|
||
}
|
||
AppMessage::AddGameMapping => {
|
||
let exe = crate::game::normalize_exe(&state.game_map_exe_input);
|
||
let name = state.game_map_name_input.trim().to_string();
|
||
if !exe.is_empty() && !name.is_empty() {
|
||
state.config.game_process_map.insert(exe, name);
|
||
state.config.save();
|
||
state.game_map_exe_input.clear();
|
||
state.game_map_name_input.clear();
|
||
let _ = state.controller.send(CoreCommand::SetGameProcessMap(
|
||
state.config.game_process_map.clone(),
|
||
));
|
||
}
|
||
}
|
||
AppMessage::RemoveGameMapping(exe) => {
|
||
if state.config.game_process_map.remove(&exe).is_some() {
|
||
state.config.save();
|
||
let _ = state.controller.send(CoreCommand::SetGameProcessMap(
|
||
state.config.game_process_map.clone(),
|
||
));
|
||
}
|
||
}
|
||
AppMessage::PickGameBackground(game_id) => {
|
||
// Native picker off the UI thread; result tagged with the game id.
|
||
return Task::perform(
|
||
async {
|
||
let handle = rfd::AsyncFileDialog::new()
|
||
.add_filter("Images", &["png", "jpg", "jpeg", "webp", "bmp"])
|
||
.set_title("Choose a per-game background")
|
||
.pick_file()
|
||
.await;
|
||
match handle {
|
||
Some(h) => Some(h.read().await),
|
||
None => None,
|
||
}
|
||
},
|
||
move |bytes| AppMessage::GameBackgroundPicked(game_id, bytes),
|
||
);
|
||
}
|
||
AppMessage::GameBackgroundPicked(game_id, picked) => {
|
||
if let Some(bytes) = picked {
|
||
match crate::background::process_background(&bytes) {
|
||
Ok(png) => match AppConfig::game_background_path(&game_id) {
|
||
Some(path) => {
|
||
if let Some(dir) = path.parent() {
|
||
let _ = std::fs::create_dir_all(dir);
|
||
}
|
||
match std::fs::write(&path, &png) {
|
||
Ok(()) => {
|
||
state.config.game_backgrounds.insert(
|
||
game_id.clone(),
|
||
path.to_string_lossy().into_owned(),
|
||
);
|
||
state.config.save();
|
||
// Apply at once if it's the running game.
|
||
if state.current_game.as_ref().map(|g| g.id.as_str())
|
||
== Some(game_id.as_str())
|
||
{
|
||
state.background_image = effective_background_bytes(
|
||
&state.config,
|
||
state.current_game.as_ref(),
|
||
);
|
||
}
|
||
state.status_message = "Game background updated.".to_string();
|
||
}
|
||
Err(e) => {
|
||
state.status_message =
|
||
format!("Couldn't save game background: {e}");
|
||
}
|
||
}
|
||
}
|
||
None => {
|
||
state.status_message =
|
||
"Couldn't find a config directory to save the background."
|
||
.to_string();
|
||
}
|
||
},
|
||
Err(e) => {
|
||
state.status_message = e;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
AppMessage::RemoveGameBackground(game_id) => {
|
||
if let Some(path) = AppConfig::game_background_path(&game_id) {
|
||
let _ = std::fs::remove_file(path);
|
||
}
|
||
if state.config.game_backgrounds.remove(&game_id).is_some() {
|
||
state.config.save();
|
||
if state.current_game.as_ref().map(|g| g.id.as_str()) == Some(game_id.as_str()) {
|
||
state.background_image =
|
||
effective_background_bytes(&state.config, state.current_game.as_ref());
|
||
}
|
||
state.status_message = "Game background removed.".to_string();
|
||
}
|
||
}
|
||
AppMessage::ToggleDrawerChat => {
|
||
state.drawer_chat_open = !state.drawer_chat_open;
|
||
}
|
||
AppMessage::TogglePlayerBar => {
|
||
state.config.show_player_bar = !state.config.show_player_bar;
|
||
if !state.config.show_player_bar {
|
||
state.playlist_drawer_open = false;
|
||
}
|
||
state.config.save();
|
||
}
|
||
AppMessage::TogglePlaylistDrawer => {
|
||
state.playlist_drawer_open = !state.playlist_drawer_open;
|
||
}
|
||
AppMessage::ChatSubmit => {
|
||
let text = sanitize_chat(&state.chat_input);
|
||
if !text.is_empty() {
|
||
// Local echo (gossip suppresses our own author, so it won't come back).
|
||
push_chat(
|
||
&mut state.chat_messages,
|
||
ChatEntry {
|
||
name: format!("{} (You)", state.name),
|
||
text: text.clone(),
|
||
mine: true,
|
||
from: Some(state.self_id.clone()),
|
||
attachment: None,
|
||
},
|
||
);
|
||
let _ = state.controller.send(CoreCommand::SendChat(text));
|
||
state.chat_input.clear();
|
||
}
|
||
}
|
||
AppMessage::PickAttachmentFile => {
|
||
// Native picker off the UI thread; returns (filename, bytes).
|
||
return Task::perform(
|
||
async {
|
||
let handle = rfd::AsyncFileDialog::new()
|
||
.set_title("Attach a file to the chat")
|
||
.pick_file()
|
||
.await;
|
||
match handle {
|
||
Some(h) => Some((h.file_name(), h.read().await)),
|
||
None => None,
|
||
}
|
||
},
|
||
AppMessage::AttachmentFilePicked,
|
||
);
|
||
}
|
||
AppMessage::AttachmentFilePicked(picked) => {
|
||
if let Some((name, bytes)) = picked {
|
||
let size = bytes.len() as u64;
|
||
if !crate::files::size_within_cap(size) {
|
||
state.status_message = format!(
|
||
"File too large — max {}.",
|
||
crate::files::human_size(crate::files::MAX_ATTACHMENT_BYTES)
|
||
);
|
||
return Task::none();
|
||
}
|
||
let kind = crate::files::classify(&bytes);
|
||
// Random 32-byte handle for this attachment.
|
||
let id: crate::files::AttachmentId = rand::random();
|
||
let att = crate::files::ChatAttachment {
|
||
name: crate::files::sanitize_filename(&name),
|
||
size,
|
||
kind,
|
||
id,
|
||
};
|
||
// Keep our own bytes locally so we see our own attachment inline
|
||
// immediately (others fetch it off the file plane). Cache under
|
||
// our own (author, id) key so the render path — which keys the
|
||
// line by `from` = self_id — finds them.
|
||
if let Ok(self_eid) = state.self_id.parse::<EndpointId>() {
|
||
let key = (self_eid, id);
|
||
let handle = (kind == crate::files::AttachmentKind::Image
|
||
&& crate::files::validate_image_bytes(&bytes).is_some())
|
||
.then(|| iced::widget::image::Handle::from_bytes(bytes.clone()));
|
||
state
|
||
.attachments
|
||
.insert(key, AttachmentState::Ready(bytes.clone()), handle);
|
||
}
|
||
push_chat(
|
||
&mut state.chat_messages,
|
||
ChatEntry {
|
||
name: format!("{} (You)", state.name),
|
||
text: String::new(),
|
||
mine: true,
|
||
from: Some(state.self_id.clone()),
|
||
attachment: Some(att.clone()),
|
||
},
|
||
);
|
||
let _ = state.controller.send(CoreCommand::SendChatFile {
|
||
text: String::new(),
|
||
attachment: att,
|
||
data: bytes,
|
||
});
|
||
}
|
||
}
|
||
AppMessage::SaveAttachment(key) => {
|
||
// If we already have the bytes, save now; otherwise fetch from the
|
||
// sender and save when AttachmentReady arrives (pending_saves). The
|
||
// key's author half is the exact sender of the clicked line.
|
||
if state.attachments.is_ready(&key) {
|
||
return save_attachment_task(state, key);
|
||
} else if let Some(att) = find_attachment_source(state, key) {
|
||
state.pending_saves.insert(key);
|
||
state.status_message = format!("Downloading {}…", att.name);
|
||
let _ = state.controller.send(CoreCommand::FetchAttachment {
|
||
from: key.0,
|
||
attachment: att,
|
||
});
|
||
}
|
||
}
|
||
AppMessage::AttachmentSaved(msg) => {
|
||
if let Some(m) = msg {
|
||
state.status_message = m;
|
||
}
|
||
}
|
||
AppMessage::PlayAudio(key) => {
|
||
if state.attachments.is_ready(&key) {
|
||
play_ready_audio(state, key);
|
||
} else if let Some(att) = find_attachment_source(state, key) {
|
||
// Repeated clicks while the transfer is pending must not launch
|
||
// duplicate fetches. (pending_plays is id-keyed — it's coupled to
|
||
// the id-keyed clip player; same-id collisions are cosmetic.)
|
||
if state.pending_plays.insert(key.1) {
|
||
state.status_message = format!("Loading {}…", att.name);
|
||
let _ = state.controller.send(CoreCommand::FetchAttachment {
|
||
from: key.0,
|
||
attachment: att,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
AppMessage::PauseAudio => state.clip_player.pause(),
|
||
AppMessage::ResumeAudio => state.clip_player.resume(),
|
||
AppMessage::SeekAudio(id, fraction) => {
|
||
let clip = status_snapshot(&state.clip_status);
|
||
if clip.playing_id == Some(id)
|
||
&& let Some(total) = clip.total
|
||
{
|
||
state.clip_player.seek(seek_target(fraction, total));
|
||
}
|
||
}
|
||
AppMessage::SetClipVolume(volume) => {
|
||
// Master slider: always stores the universal level, but only the
|
||
// active player is nudged when universal mode is actually on.
|
||
let volume = volume.clamp(0.0, 2.0);
|
||
state.config.clip_volume = volume;
|
||
state.config.save();
|
||
if state.config.clip_volume_universal {
|
||
state.clip_player.set_volume(volume);
|
||
}
|
||
}
|
||
AppMessage::SetClipVolumeFor(id, volume) => {
|
||
let volume = volume.clamp(0.0, 2.0);
|
||
if state.config.clip_volume_universal {
|
||
state.config.clip_volume = volume;
|
||
state.config.save();
|
||
state.clip_player.set_volume(volume);
|
||
} else {
|
||
state.clip_volumes.insert(id, volume);
|
||
// Only the clip the user is dragging should react immediately.
|
||
if status_snapshot(&state.clip_status).playing_id == Some(id) {
|
||
state.clip_player.set_volume(volume);
|
||
}
|
||
}
|
||
}
|
||
AppMessage::ToggleUniversalClipVolume(on) => {
|
||
state.config.clip_volume_universal = on;
|
||
state.config.save();
|
||
// Reapply the now-effective level to whatever is currently playing.
|
||
if let Some(id) = status_snapshot(&state.clip_status).playing_id {
|
||
state
|
||
.clip_player
|
||
.set_volume(effective_clip_volume(state, id));
|
||
}
|
||
}
|
||
AppMessage::MusicSelectTab(tab) => {
|
||
state.music_tab = tab;
|
||
}
|
||
AppMessage::MusicBrowse => {
|
||
return Task::perform(
|
||
async {
|
||
let handles = rfd::AsyncFileDialog::new()
|
||
.add_filter(
|
||
"Audio & playlists",
|
||
&["mp3", "flac", "ogg", "oga", "wav", "m3u", "m3u8", "pls"],
|
||
)
|
||
.set_title("Add music to your playlist")
|
||
.pick_files()
|
||
.await;
|
||
handles.map(|hs| hs.into_iter().map(|h| h.path().to_path_buf()).collect())
|
||
},
|
||
AppMessage::MusicFilesPicked,
|
||
);
|
||
}
|
||
AppMessage::MusicFilesPicked(picked) => {
|
||
if let Some(paths) = picked {
|
||
for path in paths {
|
||
if let Some(kind) = crate::playlist::playlist_kind(&path) {
|
||
match std::fs::read_to_string(&path) {
|
||
Ok(contents) => {
|
||
let base_dir =
|
||
path.parent().unwrap_or_else(|| std::path::Path::new("."));
|
||
for entry in
|
||
crate::playlist::parse_playlist(&contents, base_dir, kind)
|
||
{
|
||
let name = entry
|
||
.file_name()
|
||
.map(|n| n.to_string_lossy().into_owned())
|
||
.unwrap_or_else(|| entry.to_string_lossy().into_owned());
|
||
state.music_playlist.push(MusicTrack { path: entry, name });
|
||
}
|
||
}
|
||
Err(e) => {
|
||
state.status_message =
|
||
format!("Couldn't read playlist '{}': {e}", path.display());
|
||
}
|
||
}
|
||
} else {
|
||
let name = path
|
||
.file_name()
|
||
.map(|n| n.to_string_lossy().into_owned())
|
||
.unwrap_or_else(|| path.to_string_lossy().into_owned());
|
||
state.music_playlist.push(MusicTrack { path, name });
|
||
}
|
||
}
|
||
persist_music_playlist(state);
|
||
}
|
||
}
|
||
AppMessage::MusicPlayIndex(i) => {
|
||
play_music_index(state, i);
|
||
}
|
||
AppMessage::MusicPlayPause => {
|
||
let snap = status_snapshot(&state.music_status);
|
||
if snap.playing_id.is_some() {
|
||
if snap.paused {
|
||
state.music_player.resume();
|
||
state.music_active = true;
|
||
broadcast_music_timeline_at(state, false, snap.position);
|
||
} else {
|
||
state.music_player.pause();
|
||
state.music_active = false;
|
||
broadcast_music_timeline_at(state, true, snap.position);
|
||
}
|
||
} else if let Some(i) = state.music_current.or(if state.music_playlist.is_empty() {
|
||
None
|
||
} else {
|
||
Some(0)
|
||
}) {
|
||
play_music_index(state, i);
|
||
}
|
||
}
|
||
AppMessage::MusicNext => {
|
||
if let Some(next) = music_step(state, 1) {
|
||
play_music_index(state, next);
|
||
}
|
||
}
|
||
AppMessage::MusicPrev => {
|
||
if let Some(prev) = music_step(state, -1) {
|
||
play_music_index(state, prev);
|
||
}
|
||
}
|
||
AppMessage::MusicSeek(fraction) => {
|
||
let snap = status_snapshot(&state.music_status);
|
||
if let Some(total) = snap.total {
|
||
let target = seek_target(fraction, total);
|
||
state.music_player.seek(target);
|
||
broadcast_music_timeline_at(state, snap.paused, target);
|
||
}
|
||
}
|
||
AppMessage::MusicSetVolume(volume) => {
|
||
let volume = volume.clamp(0.0, 2.0);
|
||
state.config.music_volume = volume;
|
||
if state.music_listening_to.is_none() {
|
||
state.music_player.set_volume(volume);
|
||
}
|
||
state.config.save();
|
||
}
|
||
AppMessage::MusicSetSourceVolume(volume) => {
|
||
let volume = volume.clamp(0.0, 2.0);
|
||
if let Some(peer) = state.music_listening_to {
|
||
state
|
||
.config
|
||
.music_source_volume
|
||
.insert(peer.to_string(), volume);
|
||
state.music_player.set_volume(volume);
|
||
state.config.save();
|
||
}
|
||
}
|
||
AppMessage::MusicRemove(i) => {
|
||
if i < state.music_playlist.len() {
|
||
if state.music_current == Some(i) {
|
||
state.music_player.stop();
|
||
state.music_current = None;
|
||
state.music_active = false;
|
||
stop_music_broadcast(state);
|
||
}
|
||
state.music_playlist.remove(i);
|
||
if state.music_current.map(|c| c > i).unwrap_or(false) {
|
||
state.music_current = state.music_current.map(|c| c - 1);
|
||
}
|
||
persist_music_playlist(state);
|
||
}
|
||
}
|
||
AppMessage::MusicMoveUp(i) => {
|
||
if i > 0 {
|
||
music_swap(state, i, i.wrapping_sub(1));
|
||
}
|
||
}
|
||
AppMessage::MusicMoveDown(i) => {
|
||
if i + 1 < state.music_playlist.len() {
|
||
music_swap(state, i, i + 1);
|
||
}
|
||
}
|
||
AppMessage::MusicToggleBroadcast(enabled) => {
|
||
state.music_broadcasting = enabled;
|
||
state.config.music_broadcast = enabled;
|
||
state.config.save();
|
||
if enabled {
|
||
if state.music_listening_to.is_none()
|
||
&& status_snapshot(&state.music_status).playing_id.is_some()
|
||
{
|
||
let snap = status_snapshot(&state.music_status);
|
||
broadcast_current_music_file(state, snap.paused, snap.position);
|
||
}
|
||
} else {
|
||
stop_music_broadcast(state);
|
||
}
|
||
}
|
||
AppMessage::MusicListen(peer) => {
|
||
state.music_listening_to = Some(peer);
|
||
state.music_player.stop();
|
||
state.music_active = false;
|
||
state.music_current = None;
|
||
state.music_listen_loaded = None;
|
||
state.music_listen_inflight = None;
|
||
state.music_prefetch = None;
|
||
state.music_prefetch_inflight = None;
|
||
stop_music_broadcast(state);
|
||
reconcile_listen(state);
|
||
}
|
||
AppMessage::MusicStopListen => {
|
||
state.music_listening_to = None;
|
||
state.music_player.stop();
|
||
state.music_listen_loaded = None;
|
||
state.music_listen_inflight = None;
|
||
state.music_prefetch = None;
|
||
state.music_prefetch_inflight = None;
|
||
}
|
||
AppMessage::AudioTick => {
|
||
let clip = status_snapshot(&state.clip_status);
|
||
if let Some(failure) = clip.failure {
|
||
if failure.invalid_audio {
|
||
state.invalid_audio.insert(failure.id);
|
||
}
|
||
state.pending_plays.remove(&failure.id);
|
||
state.status_message = format!("Audio playback failed: {}", failure.error);
|
||
state.clip_player.stop();
|
||
}
|
||
reconcile_listen(state);
|
||
if state.music_active && state.music_listening_to.is_none() {
|
||
let snap = status_snapshot(&state.music_status);
|
||
if snap.playing_id.is_none() && !snap.paused {
|
||
if let Some(next) = music_step(state, 1) {
|
||
play_music_index(state, next);
|
||
} else {
|
||
state.music_active = false;
|
||
stop_music_broadcast(state);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
AppMessage::OpenUrl(url) => {
|
||
// Defence in depth: only ever hand http(s) URLs to the opener. The
|
||
// link span's href came from `linkify`, which only emits http/https,
|
||
// but re-check here so this can't be widened into launching arbitrary
|
||
// schemes/args. Each opener receives the URL as a single argv entry
|
||
// (no shell), so there's no injection surface:
|
||
// - Unix: `xdg-open <url>`.
|
||
// - Windows: `rundll32 url.dll,FileProtocolHandler <url>` — opens the
|
||
// default browser without going through `cmd`/`start`, which would
|
||
// otherwise re-parse `&` in query strings.
|
||
if url.starts_with("http://") || url.starts_with("https://") {
|
||
let spawned = {
|
||
#[cfg(unix)]
|
||
{
|
||
std::process::Command::new("xdg-open").arg(&url).spawn()
|
||
}
|
||
#[cfg(windows)]
|
||
{
|
||
std::process::Command::new("rundll32")
|
||
.args(["url.dll,FileProtocolHandler", &url])
|
||
.spawn()
|
||
}
|
||
};
|
||
if let Err(e) = spawned {
|
||
crate::log_msg(&format!("Failed to open URL {url:?}: {e}"));
|
||
}
|
||
}
|
||
}
|
||
AppMessage::ToggleMicTest(enabled) => {
|
||
state.mic_test_active = enabled;
|
||
if !enabled {
|
||
state.mic_level = 0.0;
|
||
}
|
||
let input_device = state.selected_input.as_ref().map(|d| d.name.clone());
|
||
let _ = state.controller.send(CoreCommand::SetMicMonitor {
|
||
enabled,
|
||
input_device,
|
||
});
|
||
}
|
||
AppMessage::EventOccurred(Event::Keyboard(keyboard::Event::KeyPressed { key, .. })) => {
|
||
if state.image_lightbox.is_some()
|
||
&& key == keyboard::Key::Named(keyboard::key::Named::Escape)
|
||
{
|
||
// Esc closes the image lightbox (takes priority over hotkeys so a
|
||
// user-bound Escape can't fire while the overlay is up).
|
||
state.image_lightbox = None;
|
||
} else if let Some(action) = state.hotkey_capture.take() {
|
||
if let Some(binding) = KeyBinding::from_key(&key) {
|
||
state.config.hotkeys.set_binding(action, Some(binding));
|
||
state.config.save();
|
||
} else {
|
||
state.hotkey_capture = Some(action);
|
||
}
|
||
} else if let Some(action) = state.config.hotkeys.lookup_key(
|
||
&key,
|
||
HotkeyContext {
|
||
in_call: in_call(state),
|
||
},
|
||
) {
|
||
handle_hotkey_pressed(state, action);
|
||
}
|
||
}
|
||
AppMessage::EventOccurred(Event::Keyboard(keyboard::Event::KeyReleased {
|
||
key, ..
|
||
})) => {
|
||
if state.config.hotkeys.lookup_key(
|
||
&key,
|
||
HotkeyContext {
|
||
in_call: in_call(state),
|
||
},
|
||
) == Some(HotkeyAction::PushToTalk)
|
||
&& state.ptt_enabled
|
||
&& state.ptt_active
|
||
{
|
||
state.ptt_active = false;
|
||
let _ = state.controller.send(CoreCommand::SetPttActive(false));
|
||
}
|
||
}
|
||
AppMessage::EventOccurred(Event::Window(iced::window::Event::Resized(size))) => {
|
||
state.window_size = size;
|
||
// Remember the size in-memory; it's written to disk once on close.
|
||
// Guard against bogus tiny/zero sizes some compositors emit transiently.
|
||
if size.width >= 200.0 && size.height >= 200.0 {
|
||
state.config.window_width = size.width;
|
||
state.config.window_height = size.height;
|
||
}
|
||
// Keep divider positions valid for the new window dimensions. (Saved
|
||
// with the next drag-release or other config write; not worth a disk
|
||
// write on every resize tick.)
|
||
state.config.participants_width =
|
||
clamp_participants_width(state.config.participants_width, size.width);
|
||
state.config.chat_height = clamp_chat_height(state.config.chat_height, size.height);
|
||
state.config.controls_width =
|
||
clamp_controls_width(state.config.controls_width, size.width);
|
||
state.config.chat_drawer_width =
|
||
clamp_chat_drawer_width(state.config.chat_drawer_width, size.width);
|
||
state.config.playlist_drawer_width =
|
||
clamp_playlist_drawer_width(state.config.playlist_drawer_width, size.width);
|
||
}
|
||
AppMessage::EventOccurred(Event::Window(iced::window::Event::Moved(position))) => {
|
||
// Remember the position in-memory; written to disk once on close.
|
||
// Negative coords are valid (a monitor left of/above the primary), so
|
||
// we don't clamp. On Wayland iced doesn't report position, so this
|
||
// arm simply never fires there and window_x/y stay None.
|
||
state.config.window_x = Some(position.x as i32);
|
||
state.config.window_y = Some(position.y as i32);
|
||
}
|
||
AppMessage::EventOccurred(Event::Window(iced::window::Event::CloseRequested)) => {
|
||
if state.closing {
|
||
return Task::none();
|
||
}
|
||
// We took over the close path (exit_on_close_request:false) so we can
|
||
// persist the final window size + position and give core a chance to
|
||
// leave the room/finalize recordings before quitting.
|
||
state.config.save();
|
||
state.closing = true;
|
||
state.status_message = "Shutting down...".to_string();
|
||
let tx = state.controller.command_sender();
|
||
return Task::batch(vec![
|
||
Task::perform(
|
||
async move { tx.send(CoreCommand::Shutdown).await.is_ok() },
|
||
AppMessage::ShutdownCommandSent,
|
||
),
|
||
shutdown_timeout_task(),
|
||
]);
|
||
}
|
||
AppMessage::EventOccurred(_) => {}
|
||
AppMessage::ShutdownCommandSent(sent) => {
|
||
if !sent {
|
||
return iced::exit();
|
||
}
|
||
}
|
||
AppMessage::ShutdownTimeout => {
|
||
if state.closing {
|
||
return iced::exit();
|
||
}
|
||
}
|
||
AppMessage::NavigateToSettings => {
|
||
state.current_screen = Screen::Settings;
|
||
state.layout_picker_open = false;
|
||
state.hotkey_info_open = false;
|
||
}
|
||
AppMessage::NavigateBack => {
|
||
state.config.save();
|
||
state.hotkey_capture = None;
|
||
// Release the mic when leaving Settings if the test was running.
|
||
if state.mic_test_active {
|
||
state.mic_test_active = false;
|
||
state.mic_level = 0.0;
|
||
let _ = state.controller.send(CoreCommand::SetMicMonitor {
|
||
enabled: false,
|
||
input_device: None,
|
||
});
|
||
}
|
||
if state.ticket.is_empty() {
|
||
state.current_screen = Screen::Home;
|
||
} else {
|
||
state.current_screen = Screen::Room;
|
||
}
|
||
}
|
||
}
|
||
Task::none()
|
||
}
|
||
|
||
/// One-line explanation of a network posture for the settings picker.
|
||
fn network_mode_hint(mode: NetworkMode) -> &'static str {
|
||
match mode {
|
||
NetworkMode::RelayNoDiscovery => {
|
||
"n0 relay for NAT traversal; no presence published to n0 DNS."
|
||
}
|
||
NetworkMode::N0Full => "n0 relay + DNS discovery. Most reliable, most metadata shared.",
|
||
NetworkMode::DirectOnly => "Fully serverless. May fail behind strict/CGNAT networks.",
|
||
}
|
||
}
|
||
|
||
/// One-line explanation of an audio/network profile for the settings picker (W12).
|
||
fn audio_profile_hint(profile: AudioProfile) -> &'static str {
|
||
match profile {
|
||
AudioProfile::LowLatency => {
|
||
"Lowest delay, no loss recovery. Best on a clean LAN or wired link."
|
||
}
|
||
AudioProfile::Balanced => "Default: voice quality with light loss recovery.",
|
||
AudioProfile::BadNetwork => {
|
||
"Most resilient on a lossy/congested link: heavier loss recovery, lower bitrate."
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One-line explanation of a recording mode for the settings picker.
|
||
fn recording_mode_hint(mode: RecordingMode) -> &'static str {
|
||
match mode {
|
||
RecordingMode::Mixed => "One WAV: your mic blended with everyone you hear.",
|
||
RecordingMode::Multitrack => {
|
||
"One WAV per person + your mic, sample-aligned — mix it yourself."
|
||
}
|
||
RecordingMode::Both => "Per-person stems + your mic AND a ready-made mixed WAV.",
|
||
}
|
||
}
|
||
|
||
/// One-line explanation of a presence posture for the settings picker (W7).
|
||
fn presence_mode_hint(mode: PresenceMode) -> &'static str {
|
||
match mode {
|
||
PresenceMode::Invisible => "Answer no one — appear offline to everyone, even friends.",
|
||
PresenceMode::Normal => "Answer friends only, from your saved address. No presence beacon.",
|
||
PresenceMode::Discoverable => {
|
||
"Also publish so friends can still find you after you change networks (opt-in)."
|
||
}
|
||
}
|
||
}
|
||
|
||
fn share_quality_hint(quality: ShareQuality) -> &'static str {
|
||
match quality {
|
||
ShareQuality::Auto => "Use pixelpass bandwidth pre-flight; falls back to Medium.",
|
||
ShareQuality::Low => "Lower bandwidth: up to 480p, about 1 Mbps.",
|
||
ShareQuality::Medium => "Balanced preset: up to 720p, about 2.5 Mbps.",
|
||
ShareQuality::High => "Sharper preset: up to 1080p, about 4 Mbps.",
|
||
ShareQuality::Source => "Native source resolution, about 6 Mbps.",
|
||
}
|
||
}
|
||
|
||
fn share_player_hint(player: SharePlayer) -> &'static str {
|
||
match player {
|
||
SharePlayer::Mpv => "Try mpv first, then VLC if mpv is unavailable.",
|
||
SharePlayer::Vlc => "Try VLC first, then mpv if VLC is unavailable.",
|
||
}
|
||
}
|
||
|
||
fn share_buffering_hint(buffering: ShareBuffering) -> &'static str {
|
||
match buffering {
|
||
ShareBuffering::LowLatency => "Small buffers for interactive screen sharing.",
|
||
ShareBuffering::Smooth => "Larger cache/readahead for steadier playback.",
|
||
}
|
||
}
|
||
|
||
/// Formats a call duration as `m:ss` (or `h:mm:ss` past an hour).
|
||
fn format_duration(total_secs: u64) -> String {
|
||
let h = total_secs / 3600;
|
||
let m = (total_secs % 3600) / 60;
|
||
let s = total_secs % 60;
|
||
if h > 0 {
|
||
format!("{h}:{m:02}:{s:02}")
|
||
} else {
|
||
format!("{m}:{s:02}")
|
||
}
|
||
}
|
||
|
||
/// A compact relative-time label for the friends "last scanned" indicator, e.g.
|
||
/// `just now`, `2m ago`, `1h 2m ago`, `3d ago`. Minute granularity (the indicator
|
||
/// re-renders on a slow tick), so anything under a minute reads "just now". Pure
|
||
/// so the formatting is unit-testable without a clock.
|
||
fn format_relative_ago(elapsed: std::time::Duration) -> String {
|
||
let secs = elapsed.as_secs();
|
||
if secs < 60 {
|
||
return "just now".to_string();
|
||
}
|
||
let mins = secs / 60;
|
||
if mins < 60 {
|
||
return format!("{mins}m ago");
|
||
}
|
||
let hours = mins / 60;
|
||
if hours < 24 {
|
||
let rem_m = mins % 60;
|
||
return if rem_m == 0 {
|
||
format!("{hours}h ago")
|
||
} else {
|
||
format!("{hours}h {rem_m}m ago")
|
||
};
|
||
}
|
||
let days = hours / 24;
|
||
let rem_h = hours % 24;
|
||
if rem_h == 0 {
|
||
format!("{days}d ago")
|
||
} else {
|
||
format!("{days}d {rem_h}h ago")
|
||
}
|
||
}
|
||
|
||
fn format_clock_skew_duration(skew_secs: u64) -> String {
|
||
let minutes = skew_secs.max(1).saturating_add(59) / 60;
|
||
if minutes == 1 {
|
||
"1 minute".to_string()
|
||
} else {
|
||
format!("{minutes} minutes")
|
||
}
|
||
}
|
||
|
||
fn show_clock_skew_warning(
|
||
state: &mut AppState,
|
||
skew_secs: u64,
|
||
peer_ahead: bool,
|
||
now: std::time::Instant,
|
||
) {
|
||
state.clock_skew_warning = Some(ClockSkewBanner {
|
||
skew_secs,
|
||
peer_ahead,
|
||
expires_at: now + std::time::Duration::from_secs(CLOCK_SKEW_WARNING_VISIBLE_SECS),
|
||
});
|
||
}
|
||
|
||
fn clear_expired_clock_skew_warning(state: &mut AppState, now: std::time::Instant) {
|
||
if state
|
||
.clock_skew_warning
|
||
.is_some_and(|warning| now >= warning.expires_at)
|
||
{
|
||
state.clock_skew_warning = None;
|
||
}
|
||
}
|
||
|
||
/// First 8 characters of an id string for compact display. Panic-free: takes
|
||
/// chars (not a byte slice), so a short or non-ASCII id can never panic the
|
||
/// render (security finding S1) — ids are long ASCII hex today, but this guards
|
||
/// the slice regardless.
|
||
fn short_id(id: &str) -> String {
|
||
id.chars().take(8).collect()
|
||
}
|
||
|
||
/// Max characters kept for a single chat message after sanitizing.
|
||
const CHAT_MSG_MAX_CHARS: usize = 2000;
|
||
|
||
/// Sanitize a chat string for display, applied to BOTH our outgoing text and
|
||
/// incoming text from peers (peer input is untrusted — a buggy/malicious sender
|
||
/// could include control characters or an enormous payload). Drops control
|
||
/// characters (ANSI escapes, NUL, stray CR/LF/TAB), collapses any whitespace run
|
||
/// to a single space, trims the ends, and caps the length. Returns "" for input
|
||
/// that is empty after cleaning (the caller skips empty messages).
|
||
fn sanitize_chat(input: &str) -> String {
|
||
let no_control: String = input
|
||
.chars()
|
||
.map(|c| if c.is_control() { ' ' } else { c })
|
||
.collect();
|
||
let collapsed = no_control.split_whitespace().collect::<Vec<_>>().join(" ");
|
||
collapsed.chars().take(CHAT_MSG_MAX_CHARS).collect()
|
||
}
|
||
|
||
/// Append a chat line, trimming the oldest once history exceeds the cap so a long
|
||
/// call can't grow the buffer without bound.
|
||
fn push_chat(messages: &mut Vec<ChatEntry>, entry: ChatEntry) {
|
||
messages.push(entry);
|
||
if messages.len() > CHAT_HISTORY_MAX {
|
||
let overflow = messages.len() - CHAT_HISTORY_MAX;
|
||
messages.drain(..overflow);
|
||
}
|
||
}
|
||
|
||
/// Pick the default save-dialog filename for an attachment, matched by the FULL
|
||
/// `(author, id)` key — not the bare id — so a peer reusing another sender's id
|
||
/// can't supply the filename (and extension) for a different line (Tier C F-12
|
||
/// metadata residual). Falls back to "download" if the line is gone. Matches own
|
||
/// and received lines alike (our own `from` = `self_id` parses to the key author).
|
||
fn attachment_default_name(messages: &[ChatEntry], key: AttachmentKey) -> String {
|
||
messages
|
||
.iter()
|
||
.find_map(|m| {
|
||
let att = m.attachment.as_ref()?;
|
||
let from = m.from.as_ref()?.parse::<EndpointId>().ok()?;
|
||
(att.id == key.1 && from == key.0).then(|| att.name.clone())
|
||
})
|
||
.unwrap_or_else(|| "download".to_string())
|
||
}
|
||
|
||
/// Find the chat-attachment descriptor for an exact `(author, id)` key among
|
||
/// received (non-own) messages. Matching on the author too — not just the id —
|
||
/// means a peer reusing another sender's id can't redirect the fetch to the
|
||
/// wrong line (Tier C F-12).
|
||
fn find_attachment_source(
|
||
state: &AppState,
|
||
key: AttachmentKey,
|
||
) -> Option<crate::files::ChatAttachment> {
|
||
state.chat_messages.iter().find_map(|m| {
|
||
let att = m.attachment.as_ref()?;
|
||
let from = m.from.as_ref()?;
|
||
if att.id == key.1 && !m.mine && from.parse::<EndpointId>().ok() == Some(key.0) {
|
||
Some(att.clone())
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
}
|
||
|
||
/// Validate cached bytes and hand them to the independent clip player. A false
|
||
/// filename hint falls back to the generic file chip without reaching rodio.
|
||
/// Resolve the gain to use for clip `id`: the shared universal level, or the
|
||
/// clip's own stored level (defaulting to unity) when universal mode is off.
|
||
fn effective_clip_volume(state: &AppState, id: crate::files::AttachmentId) -> f32 {
|
||
if state.config.clip_volume_universal {
|
||
state.config.clip_volume
|
||
} else {
|
||
state.clip_volumes.get(&id).copied().unwrap_or(1.0)
|
||
}
|
||
}
|
||
|
||
fn play_ready_audio(state: &mut AppState, key: AttachmentKey) {
|
||
let Some(AttachmentState::Ready(data)) = state.attachments.get(&key) else {
|
||
return;
|
||
};
|
||
let id = key.1;
|
||
if crate::files::is_probably_audio(data) {
|
||
let bytes = data.clone();
|
||
state.invalid_audio.remove(&id);
|
||
state.clip_player.play(id, bytes);
|
||
// Apply this clip's effective gain; the command lands after Play so it
|
||
// takes effect on the freshly connected player.
|
||
state
|
||
.clip_player
|
||
.set_volume(effective_clip_volume(state, id));
|
||
} else {
|
||
state.invalid_audio.insert(id);
|
||
state.status_message = "This attachment is not valid supported audio.".to_string();
|
||
}
|
||
}
|
||
|
||
/// Synthesize a stable per-slot AttachmentId for the music player's status
|
||
/// bookkeeping (music tracks are local files, not real attachments). Encoding the
|
||
/// playlist index keeps ids distinct per slot; only used internally by the player.
|
||
fn music_slot_id(index: usize) -> crate::files::AttachmentId {
|
||
let mut id = [0u8; 32];
|
||
id[..8].copy_from_slice(&(index as u64).to_le_bytes());
|
||
id
|
||
}
|
||
|
||
/// Compute the next playlist index in `dir` (+1/-1), wrapping, or None if empty.
|
||
fn music_step(state: &AppState, dir: isize) -> Option<usize> {
|
||
let len = state.music_playlist.len();
|
||
if len == 0 {
|
||
return None;
|
||
}
|
||
let cur = state.music_current.unwrap_or(0) as isize;
|
||
Some((cur + dir).rem_euclid(len as isize) as usize)
|
||
}
|
||
|
||
fn persist_music_playlist(state: &mut AppState) {
|
||
state.config.music_playlist = state
|
||
.music_playlist
|
||
.iter()
|
||
.map(|t| t.path.to_string_lossy().into_owned())
|
||
.collect();
|
||
state.config.save();
|
||
}
|
||
|
||
fn music_swap(state: &mut AppState, a: usize, b: usize) {
|
||
if a == b || a >= state.music_playlist.len() || b >= state.music_playlist.len() {
|
||
return;
|
||
}
|
||
state.music_playlist.swap(a, b);
|
||
if state.music_current == Some(a) {
|
||
state.music_current = Some(b);
|
||
} else if state.music_current == Some(b) {
|
||
state.music_current = Some(a);
|
||
}
|
||
persist_music_playlist(state);
|
||
}
|
||
|
||
fn effective_music_volume(state: &AppState) -> f32 {
|
||
if let Some(peer) = state.music_listening_to {
|
||
state
|
||
.config
|
||
.music_source_volume
|
||
.get(&peer.to_string())
|
||
.copied()
|
||
.unwrap_or(state.config.music_volume)
|
||
} else {
|
||
state.config.music_volume
|
||
}
|
||
}
|
||
|
||
fn can_broadcast_music(state: &AppState) -> bool {
|
||
state.music_broadcasting && state.music_listening_to.is_none()
|
||
}
|
||
|
||
/// Text shown on the now-playing player bar. Pure for testing.
|
||
fn now_playing_label(listening_to: Option<&str>, current_track: Option<&str>) -> String {
|
||
match (listening_to, current_track) {
|
||
(Some(peer), _) => format!("Tuned in to {peer}"),
|
||
(None, Some(track)) => track.to_string(),
|
||
(None, None) => "Nothing playing".to_string(),
|
||
}
|
||
}
|
||
|
||
fn stop_music_broadcast(state: &mut AppState) {
|
||
let old_current = state.music_broadcast_id.take();
|
||
let old_next = state.music_broadcast_next.take().map(|(_, id, _)| id);
|
||
forget_stale_music_tracks(state, old_current, old_next, None, None);
|
||
state.music_broadcast_id = None;
|
||
state.music_broadcast_next = None;
|
||
let _ = state.controller.send(CoreCommand::SetMusicPresence(None));
|
||
}
|
||
|
||
fn forget_stale_music_tracks(
|
||
state: &AppState,
|
||
old_current: Option<crate::files::AttachmentId>,
|
||
old_next: Option<crate::files::AttachmentId>,
|
||
new_current: Option<crate::files::AttachmentId>,
|
||
new_next: Option<crate::files::AttachmentId>,
|
||
) {
|
||
for id in [old_current, old_next].into_iter().flatten() {
|
||
if Some(id) != new_current && Some(id) != new_next {
|
||
let _ = state.controller.send(CoreCommand::ForgetMusicTrack(id));
|
||
}
|
||
}
|
||
}
|
||
|
||
fn music_presence_for(
|
||
id: crate::files::AttachmentId,
|
||
name: String,
|
||
size: u64,
|
||
paused: bool,
|
||
position: std::time::Duration,
|
||
next_id: Option<crate::files::AttachmentId>,
|
||
next_size: Option<u64>,
|
||
) -> crate::network::MusicPresence {
|
||
crate::network::MusicPresence {
|
||
id,
|
||
name: crate::files::sanitize_filename(&name),
|
||
size,
|
||
paused,
|
||
anchor_ms: now_ms(),
|
||
position_ms: position.as_millis() as u64,
|
||
next_id,
|
||
next_size,
|
||
}
|
||
}
|
||
|
||
struct MusicPresenceUpdate {
|
||
id: crate::files::AttachmentId,
|
||
name: String,
|
||
size: u64,
|
||
paused: bool,
|
||
position: std::time::Duration,
|
||
next_id: Option<crate::files::AttachmentId>,
|
||
next_size: Option<u64>,
|
||
}
|
||
|
||
fn send_music_presence(state: &AppState, update: MusicPresenceUpdate) {
|
||
let _ = state
|
||
.controller
|
||
.send(CoreCommand::SetMusicPresence(Some(music_presence_for(
|
||
update.id,
|
||
update.name,
|
||
update.size,
|
||
update.paused,
|
||
update.position,
|
||
update.next_id,
|
||
update.next_size,
|
||
))));
|
||
}
|
||
|
||
fn broadcast_track(
|
||
state: &mut AppState,
|
||
current_index: usize,
|
||
name: String,
|
||
bytes: &[u8],
|
||
paused: bool,
|
||
position: std::time::Duration,
|
||
) {
|
||
if !can_broadcast_music(state) {
|
||
return;
|
||
}
|
||
let old_current = state.music_broadcast_id;
|
||
let old_next = state.music_broadcast_next;
|
||
let reuse_current = old_next
|
||
.filter(|(idx, _, _)| *idx == current_index)
|
||
.map(|(_, id, _)| id);
|
||
let id = reuse_current.unwrap_or_else(rand::random);
|
||
if reuse_current.is_none() {
|
||
let _ = state.controller.send(CoreCommand::ServeMusicTrack {
|
||
id,
|
||
data: Arc::new(bytes.to_vec()),
|
||
});
|
||
}
|
||
state.music_broadcast_id = Some(id);
|
||
|
||
let mut next_id = None;
|
||
let mut next_size = None;
|
||
state.music_broadcast_next = None;
|
||
if state.music_playlist.len() >= 2
|
||
&& let Some(next_index) = music_step(state, 1)
|
||
&& next_index != current_index
|
||
&& let Some(next_track) = state.music_playlist.get(next_index)
|
||
&& let Ok(next_bytes) = std::fs::read(&next_track.path)
|
||
&& crate::files::is_probably_audio(&next_bytes)
|
||
{
|
||
let id: crate::files::AttachmentId = rand::random();
|
||
let size = next_bytes.len() as u64;
|
||
let _ = state.controller.send(CoreCommand::ServeMusicTrack {
|
||
id,
|
||
data: Arc::new(next_bytes),
|
||
});
|
||
next_id = Some(id);
|
||
next_size = Some(size);
|
||
state.music_broadcast_next = Some((next_index, id, size));
|
||
}
|
||
|
||
forget_stale_music_tracks(
|
||
state,
|
||
old_current,
|
||
old_next.map(|(_, id, _)| id),
|
||
Some(id),
|
||
next_id,
|
||
);
|
||
send_music_presence(
|
||
state,
|
||
MusicPresenceUpdate {
|
||
id,
|
||
name,
|
||
size: bytes.len() as u64,
|
||
paused,
|
||
position,
|
||
next_id,
|
||
next_size,
|
||
},
|
||
);
|
||
}
|
||
|
||
fn broadcast_current_music_file(state: &mut AppState, paused: bool, position: std::time::Duration) {
|
||
let Some(index) = state.music_current else {
|
||
return;
|
||
};
|
||
let Some(track) = state.music_playlist.get(index) else {
|
||
return;
|
||
};
|
||
match std::fs::read(&track.path) {
|
||
Ok(bytes) if crate::files::is_probably_audio(&bytes) => {
|
||
broadcast_track(state, index, track.name.clone(), &bytes, paused, position);
|
||
}
|
||
Ok(_) | Err(_) => stop_music_broadcast(state),
|
||
}
|
||
}
|
||
|
||
fn broadcast_music_timeline_at(state: &AppState, paused: bool, position: std::time::Duration) {
|
||
if !can_broadcast_music(state) {
|
||
return;
|
||
}
|
||
let Some(id) = state.music_broadcast_id else {
|
||
return;
|
||
};
|
||
let Some(index) = state.music_current else {
|
||
return;
|
||
};
|
||
let Some(track) = state.music_playlist.get(index) else {
|
||
return;
|
||
};
|
||
let Ok(meta) = std::fs::metadata(&track.path) else {
|
||
return;
|
||
};
|
||
let (next_id, next_size) = state
|
||
.music_broadcast_next
|
||
.map(|(_, id, size)| (Some(id), Some(size)))
|
||
.unwrap_or((None, None));
|
||
send_music_presence(
|
||
state,
|
||
MusicPresenceUpdate {
|
||
id,
|
||
name: track.name.clone(),
|
||
size: meta.len(),
|
||
paused,
|
||
position,
|
||
next_id,
|
||
next_size,
|
||
},
|
||
);
|
||
}
|
||
|
||
/// Read a playlist track's bytes from disk and start the music player on it.
|
||
fn play_music_index(state: &mut AppState, index: usize) {
|
||
let Some(track) = state.music_playlist.get(index) else {
|
||
return;
|
||
};
|
||
let path = track.path.clone();
|
||
let name = track.name.clone();
|
||
match std::fs::read(&path) {
|
||
Ok(bytes) if crate::files::is_probably_audio(&bytes) => {
|
||
let broadcast_bytes = bytes.clone();
|
||
state.music_current = Some(index);
|
||
state.music_active = true;
|
||
state.music_player.play(music_slot_id(index), bytes);
|
||
state.music_player.set_volume(effective_music_volume(state));
|
||
broadcast_track(
|
||
state,
|
||
index,
|
||
name,
|
||
&broadcast_bytes,
|
||
false,
|
||
std::time::Duration::ZERO,
|
||
);
|
||
}
|
||
Ok(_) => {
|
||
state.music_player.stop();
|
||
state.music_current = Some(index);
|
||
state.music_active = false;
|
||
stop_music_broadcast(state);
|
||
state.status_message = format!("'{}' is not a supported audio file.", name);
|
||
}
|
||
Err(e) => {
|
||
state.music_player.stop();
|
||
state.music_current = Some(index);
|
||
state.music_active = false;
|
||
stop_music_broadcast(state);
|
||
state.status_message = format!("Couldn't read '{}': {e}", name);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn reconcile_listen(state: &mut AppState) {
|
||
let Some(peer) = state.music_listening_to else {
|
||
return;
|
||
};
|
||
let Some(music) = state.peers.get(&peer).and_then(|p| p.music.clone()) else {
|
||
if state.music_listen_loaded.is_some()
|
||
|| state.music_listen_inflight.is_some()
|
||
|| state.music_prefetch.is_some()
|
||
|| state.music_prefetch_inflight.is_some()
|
||
{
|
||
state.music_player.stop();
|
||
state.music_listen_loaded = None;
|
||
state.music_listen_inflight = None;
|
||
state.music_prefetch = None;
|
||
state.music_prefetch_inflight = None;
|
||
state.status_message = "Source stopped broadcasting".to_string();
|
||
}
|
||
return;
|
||
};
|
||
|
||
if Some(music.id) != state.music_listen_loaded {
|
||
if let Some((id, data)) = state
|
||
.music_prefetch
|
||
.take()
|
||
.filter(|(id, _)| *id == music.id)
|
||
{
|
||
state.music_player.play(id, data);
|
||
state.music_player.set_volume(effective_music_volume(state));
|
||
state.music_listen_loaded = Some(id);
|
||
state.music_listen_inflight = None;
|
||
} else if Some(music.id) != state.music_listen_inflight {
|
||
state.music_player.stop();
|
||
state.music_listen_loaded = None;
|
||
state.music_listen_inflight = Some(music.id);
|
||
state.status_message = format!("Loading {}…", music.name);
|
||
let _ = state.controller.send(CoreCommand::FetchMusic {
|
||
from: peer,
|
||
id: music.id,
|
||
size: music.size,
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
|
||
if Some(music.id) != state.music_listen_loaded {
|
||
return;
|
||
}
|
||
|
||
let snap = status_snapshot(&state.music_status);
|
||
let mut expected = if music.paused {
|
||
music.position_ms
|
||
} else {
|
||
music
|
||
.position_ms
|
||
.saturating_add(now_ms().saturating_sub(music.anchor_ms))
|
||
};
|
||
if let Some(total) = snap.total {
|
||
expected = expected.min(total.as_millis() as u64);
|
||
}
|
||
let expected_duration = std::time::Duration::from_millis(expected);
|
||
|
||
if music.paused && !snap.paused {
|
||
state.music_player.pause();
|
||
} else if !music.paused && snap.paused {
|
||
state.music_player.resume();
|
||
state.music_player.seek(expected_duration);
|
||
}
|
||
|
||
let local_ms = snap.position.as_millis() as u64;
|
||
if !music.paused && local_ms.abs_diff(expected) > 300 {
|
||
state.music_player.seek(expected_duration);
|
||
}
|
||
|
||
if let (Some(next_id), Some(next_size)) = (music.next_id, music.next_size) {
|
||
let cached = state
|
||
.music_prefetch
|
||
.as_ref()
|
||
.is_some_and(|(id, _)| *id == next_id);
|
||
if Some(next_id) != state.music_listen_loaded
|
||
&& !cached
|
||
&& Some(next_id) != state.music_prefetch_inflight
|
||
{
|
||
state.music_prefetch_inflight = Some(next_id);
|
||
let _ = state.controller.send(CoreCommand::PrefetchMusic {
|
||
from: peer,
|
||
id: next_id,
|
||
size: next_size,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Write a ready attachment's bytes to a user-chosen location via a native save
|
||
/// dialog. The default filename comes from the (already-sanitized) descriptor.
|
||
/// Build a Task that opens the native save dialog off the UI thread and writes
|
||
/// the (already-fetched) attachment bytes to the chosen path. No-op task if the
|
||
/// bytes aren't ready.
|
||
///
|
||
/// MUST be async (`rfd::AsyncFileDialog`): the blocking `rfd::FileDialog` spins
|
||
/// its own GTK loop, and invoking it from inside iced's running event loop with
|
||
/// the Linux `xdg-desktop-portal`/GTK backend wedges the dialog — Save/Cancel
|
||
/// stop responding. The file *picker* paths already use the async variant; this
|
||
/// is the one save path that must match.
|
||
fn save_attachment_task(state: &AppState, key: AttachmentKey) -> Task<AppMessage> {
|
||
let Some(AttachmentState::Ready(data)) = state.attachments.get(&key) else {
|
||
return Task::none();
|
||
};
|
||
let data = data.clone();
|
||
let default_name = attachment_default_name(&state.chat_messages, key);
|
||
Task::perform(
|
||
async move {
|
||
let handle = rfd::AsyncFileDialog::new()
|
||
.set_file_name(default_name)
|
||
.set_title("Save attachment")
|
||
.save_file()
|
||
.await;
|
||
match handle {
|
||
Some(h) => match std::fs::write(h.path(), &data) {
|
||
Ok(()) => Some(format!("Saved {}", h.path().display())),
|
||
Err(e) => Some(format!("Save failed: {e}")),
|
||
},
|
||
None => None,
|
||
}
|
||
},
|
||
AppMessage::AttachmentSaved,
|
||
)
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
/// The Friends panel (W7) — moved from Settings to the home screen. One card with
|
||
/// the live friends list (presence status + one-click Join + inline rename/remove),
|
||
/// the add-by-node-ID form, and the presence posture selector. Self-contained:
|
||
/// recomputes the palette + the few style helpers it needs so it doesn't depend on
|
||
/// `view`'s locals.
|
||
// The home-screen "Connect" card (left panel): create / join a room. Extracted
|
||
// into its own free fn (mirroring `friends_panel`) so the responsive home layout
|
||
// can rebuild it per layout pass and place it in either a row or a column.
|
||
fn connect_card(state: &AppState) -> Element<'_, AppMessage> {
|
||
let pal = state.config.theme.palette();
|
||
let color_crust = pal.crust;
|
||
let color_mantle = pal.mantle;
|
||
let color_surface = pal.surface;
|
||
let color_text = pal.text;
|
||
let color_subtext = pal.subtext;
|
||
let color_blue = pal.blue;
|
||
let color_lavender = pal.lavender;
|
||
|
||
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: pal.overlay,
|
||
value: color_text,
|
||
selection: color_blue,
|
||
};
|
||
|
||
let logo = text("PEERSPEAK").size(38).color(color_blue);
|
||
let subtitle = text("NAT-traversing full-mesh voice chat")
|
||
.size(16)
|
||
.color(color_subtext);
|
||
let build_label = text(app_build_label()).size(11).color(color_subtext);
|
||
|
||
let nickname_input = column![
|
||
text("Nickname").size(14).color(color_subtext),
|
||
vertical_space(4.0),
|
||
context_input("Enter nickname...", &state.name)
|
||
.on_input(AppMessage::NicknameChanged)
|
||
.style(t_style)
|
||
.padding(10)
|
||
];
|
||
|
||
// Optional cosmetic room label (W7) above the Create button: it rides in the
|
||
// minted ticket so everyone who joins inherits "in <name>". Enter also creates.
|
||
let create_group = column![
|
||
context_input("Room name (optional)", &state.room_name_input)
|
||
.on_input(AppMessage::RoomNameChanged)
|
||
.on_submit(AppMessage::CreatePressed)
|
||
.style(t_style)
|
||
.padding(10),
|
||
vertical_space(8.0),
|
||
button(btn_content(
|
||
IconKind::Create,
|
||
"Create New Room",
|
||
color_crust
|
||
))
|
||
.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),
|
||
context_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);
|
||
|
||
container(
|
||
column![
|
||
logo,
|
||
subtitle,
|
||
build_label,
|
||
vertical_space(20.0),
|
||
nickname_input,
|
||
vertical_space(16.0),
|
||
create_group,
|
||
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(10.0),
|
||
status
|
||
]
|
||
.spacing(10)
|
||
.align_x(iced::alignment::Horizontal::Center),
|
||
)
|
||
.style(c_style(color_mantle, color_surface, 12.0))
|
||
.padding(32)
|
||
.width(420)
|
||
.into()
|
||
}
|
||
|
||
/// The "Recent rooms" card (W7 P5): a one-click rejoin list, in its own card so a
|
||
/// growing history never reflows the Connect card's Create/Join controls. Always
|
||
/// shown (with an empty-state hint when there's no history yet), mirroring the
|
||
/// Friends card so the feature is discoverable on a fresh install.
|
||
fn recents_card(state: &AppState) -> Element<'_, AppMessage> {
|
||
let pal = state.config.theme.palette();
|
||
let color_crust = pal.crust;
|
||
let color_mantle = pal.mantle;
|
||
let color_surface = pal.surface;
|
||
let color_text = pal.text;
|
||
let color_subtext = pal.subtext;
|
||
let color_maroon = pal.maroon;
|
||
|
||
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 empty = state.config.recents.is_empty();
|
||
let content: Element<'_, AppMessage> = if empty {
|
||
column![
|
||
text("RECENT ROOMS").size(14).color(color_subtext),
|
||
text("No recent rooms yet.").size(12).color(color_subtext),
|
||
]
|
||
.spacing(4)
|
||
.into()
|
||
} else {
|
||
let now = std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.map(|d| d.as_secs())
|
||
.unwrap_or(0);
|
||
let mut rows = column![].spacing(6).width(iced::Length::Fill);
|
||
for r in &state.config.recents {
|
||
let label = {
|
||
let n = crate::sanitize::sanitize_name(&r.name);
|
||
if n.is_empty() {
|
||
"Untitled room".to_string()
|
||
} else {
|
||
n
|
||
}
|
||
};
|
||
let when = crate::recents::relative_time(now, r.joined_at);
|
||
let entry = button(
|
||
row![
|
||
text(label).size(14).color(color_text),
|
||
horizontal_space(),
|
||
text(when).size(11).color(color_subtext),
|
||
]
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
)
|
||
.on_press(AppMessage::JoinRecent(r.ticket.clone()))
|
||
.style(b_style(color_crust, color_surface, color_text, 6.0))
|
||
.padding(8)
|
||
.width(iced::Length::Fill);
|
||
rows = rows.push(
|
||
row![
|
||
entry,
|
||
button(text("✕").size(12))
|
||
.on_press(AppMessage::RemoveRecent(r.ticket.clone()))
|
||
.style(b_style(color_surface, color_maroon, color_text, 6.0))
|
||
.padding(8),
|
||
]
|
||
.spacing(6)
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
);
|
||
}
|
||
|
||
column![
|
||
text("RECENT ROOMS").size(18).color(color_text),
|
||
text("Rooms you've been in — click to hop back. Best-effort: only works while someone's still there.")
|
||
.size(11)
|
||
.color(color_subtext),
|
||
vertical_space(10.0),
|
||
rows,
|
||
]
|
||
.spacing(6)
|
||
.into()
|
||
};
|
||
|
||
container(content)
|
||
.style(c_style(
|
||
if empty { color_crust } else { color_mantle },
|
||
color_surface,
|
||
8.0,
|
||
))
|
||
.padding(if empty { 16 } else { 24 })
|
||
.width(if empty { 340 } else { 380 })
|
||
.into()
|
||
}
|
||
|
||
fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
|
||
let pal = state.config.theme.palette();
|
||
let color_crust = pal.crust;
|
||
let color_mantle = pal.mantle;
|
||
let color_surface = pal.surface;
|
||
let color_overlay = pal.overlay;
|
||
let color_text = pal.text;
|
||
let color_subtext = pal.subtext;
|
||
let color_blue = pal.blue;
|
||
let color_red = pal.red;
|
||
let color_maroon = pal.maroon;
|
||
let color_green = pal.green;
|
||
let has_friends = !state.friends.list().is_empty();
|
||
|
||
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_overlay,
|
||
value: color_text,
|
||
selection: color_blue,
|
||
};
|
||
|
||
// The live friends list: status dot, inline rename, short id, remove.
|
||
let mut friend_rows = column![].spacing(6).width(iced::Length::Fill);
|
||
if !has_friends {
|
||
friend_rows = friend_rows.push(text("No friends yet.").size(12).color(color_subtext));
|
||
} else {
|
||
for f in state.friends.list() {
|
||
let fid = f.id;
|
||
let id_short = format!("{}…", short_id(&f.id.to_string()));
|
||
// Live presence (W7 B2); a missing entry = offline. In-room → Join.
|
||
let status: Element<AppMessage> = match state.friend_presence.get(&fid) {
|
||
Some(crate::presence::FriendPresence::InRoom { name, ticket }) => {
|
||
let label = if name.is_empty() {
|
||
"in a room".to_string()
|
||
} else {
|
||
format!("in {name}")
|
||
};
|
||
row![
|
||
text(label).size(11).color(color_green),
|
||
button(text("Join").size(12))
|
||
.on_press(AppMessage::JoinFriendRoom(ticket.clone()))
|
||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||
.padding(6),
|
||
]
|
||
.spacing(6)
|
||
.align_y(iced::alignment::Vertical::Center)
|
||
.into()
|
||
}
|
||
Some(crate::presence::FriendPresence::Online) => {
|
||
text("● online").size(11).color(color_green).into()
|
||
}
|
||
// An explicit Offline (probe failed / no address) and a missing
|
||
// entry (not yet probed) both render as offline.
|
||
Some(crate::presence::FriendPresence::Offline) | None => {
|
||
text("○ offline").size(11).color(color_subtext).into()
|
||
}
|
||
};
|
||
friend_rows = friend_rows.push(
|
||
row![
|
||
context_input("name", &f.name)
|
||
.on_input(move |v| AppMessage::RenameFriend(fid, v))
|
||
.style(t_style)
|
||
.padding(6)
|
||
.width(iced::Length::Fixed(140.0)),
|
||
status,
|
||
horizontal_space(),
|
||
text(id_short).size(11).color(color_subtext),
|
||
button(text("✕").size(12))
|
||
.on_press(AppMessage::RemoveFriend(fid))
|
||
.style(b_style(color_surface, color_maroon, color_text, 6.0))
|
||
.padding(6),
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
);
|
||
}
|
||
}
|
||
|
||
let friend_add_error: Element<AppMessage> = match &state.friend_add_error {
|
||
Some(e) => text(e).size(11).color(color_red).into(),
|
||
None => column![].into(),
|
||
};
|
||
// A16: read-only when core couldn't load friends.json (never overwrite it).
|
||
let readonly_warning: Element<AppMessage> = if state.friends_read_only {
|
||
text("⚠ friends.json couldn't load — read-only so it isn't overwritten. Fix or remove it, then restart.")
|
||
.size(11)
|
||
.color(color_red)
|
||
.into()
|
||
} else {
|
||
column![].into()
|
||
};
|
||
|
||
let add_form = column![
|
||
context_input("Friend's node ID", &state.friend_add_id)
|
||
.on_input(AppMessage::FriendAddIdChanged)
|
||
.style(t_style)
|
||
.padding(6),
|
||
vertical_space(6.0),
|
||
row![
|
||
context_input("Name (optional)", &state.friend_add_name)
|
||
.on_input(AppMessage::FriendAddNameChanged)
|
||
.style(t_style)
|
||
.padding(6)
|
||
.width(iced::Length::Fill),
|
||
button(text("Add").size(13))
|
||
.on_press(AppMessage::AddFriend)
|
||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||
.padding(8),
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
friend_add_error,
|
||
]
|
||
.spacing(0)
|
||
.width(iced::Length::Fill);
|
||
|
||
// Presence posture: a compact dropdown (mirrors the Settings NetworkMode
|
||
// picker) with a one-line explainer for the current choice below it.
|
||
let presence_picker = column![
|
||
pick_list(
|
||
&PresenceMode::ALL[..],
|
||
Some(state.config.presence_mode),
|
||
AppMessage::PresenceModeSelected,
|
||
)
|
||
.width(iced::Length::Fill),
|
||
text(presence_mode_hint(state.config.presence_mode))
|
||
.size(11)
|
||
.color(color_subtext),
|
||
]
|
||
.spacing(4)
|
||
.width(iced::Length::Fill);
|
||
let intro: Element<'_, AppMessage> = if has_friends {
|
||
text("Who's online — click Join to hop into a friend's room.")
|
||
.size(11)
|
||
.color(color_subtext)
|
||
.into()
|
||
} else {
|
||
column![].into()
|
||
};
|
||
// Header: title + a "scanned Nm ago" indicator + a manual "Rescan" button.
|
||
// Presence is also auto-refreshed every 15s, but the button forces an immediate
|
||
// pass for instant feedback. Only shown when there are friends to scan. The
|
||
// indicator advances live via the RescanLabelTick subscription.
|
||
let scanned_label: Element<'_, AppMessage> = match state.last_rescan {
|
||
Some(t) => text(format!("scanned {}", format_relative_ago(t.elapsed())))
|
||
.size(11)
|
||
.color(color_subtext)
|
||
.into(),
|
||
None => column![].into(),
|
||
};
|
||
let title_row: Element<'_, AppMessage> = if has_friends {
|
||
row![
|
||
text("FRIENDS").size(18).color(color_text),
|
||
horizontal_space(),
|
||
scanned_label,
|
||
button(text("⟳ Rescan").size(12))
|
||
.on_press(AppMessage::RefreshFriends)
|
||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||
.padding(6),
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::alignment::Vertical::Center)
|
||
.into()
|
||
} else {
|
||
text("FRIENDS").size(14).color(color_text).into()
|
||
};
|
||
|
||
container(
|
||
column![
|
||
title_row,
|
||
intro,
|
||
vertical_space(if has_friends { 10.0 } else { 4.0 }),
|
||
readonly_warning,
|
||
friend_rows,
|
||
vertical_space(if has_friends { 12.0 } else { 8.0 }),
|
||
text("Add a friend").size(13).color(color_subtext),
|
||
add_form,
|
||
vertical_space(if has_friends { 14.0 } else { 10.0 }),
|
||
text("Your presence").size(13).color(color_subtext),
|
||
presence_picker,
|
||
]
|
||
.spacing(6),
|
||
)
|
||
.style(c_style(color_mantle, color_surface, 12.0))
|
||
.padding(if has_friends { 24 } else { 18 })
|
||
.width(if has_friends { 460 } else { 360 })
|
||
.into()
|
||
}
|
||
|
||
/// Wrap the main [`view`] with the custom background layer (W16). When a
|
||
/// background image is set, render `stack![ image(Cover), scrim, ui ]` so the
|
||
/// photo sits behind the whole UI with a legibility scrim (the theme base colour
|
||
/// at `background_dim` alpha) between them; otherwise return the UI untouched. The
|
||
/// three screen roots go transparent (`root_bg` in `view`) so the image shows
|
||
/// through the gaps between panels. This is the registered top-level view.
|
||
fn view_with_background(state: &AppState) -> Element<'_, AppMessage> {
|
||
let content = view(state);
|
||
// Compose the base UI (optionally over a background image), then route the
|
||
// whole thing through the lightbox overlay so a click-to-enlarge image paints
|
||
// on top of everything (background + content).
|
||
let composed: Element<'_, AppMessage> = if let Some(bytes) = state.background_image.clone() {
|
||
let pal = state.config.theme.palette();
|
||
let dim = state.config.background_dim;
|
||
let image_layer = iced::widget::image(cached_image_handle(bytes))
|
||
.content_fit(iced::ContentFit::Cover)
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fill);
|
||
let scrim = container(
|
||
iced::widget::Space::new()
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fill),
|
||
)
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fill)
|
||
.style(move |_: &Theme| container::Style {
|
||
background: Some(Background::Color(crate::background::scrim_color(
|
||
pal.base, dim,
|
||
))),
|
||
..Default::default()
|
||
});
|
||
iced::widget::stack![image_layer, scrim, content]
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fill)
|
||
.into()
|
||
} else {
|
||
content
|
||
};
|
||
with_image_lightbox(composed, state)
|
||
}
|
||
|
||
fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||
// Theme colours — sourced from the active palette (see `src/theme.rs`), so
|
||
// all styling below re-themes when the user picks a different theme.
|
||
let pal = state.config.theme.palette();
|
||
let color_crust = pal.crust;
|
||
let color_mantle = pal.mantle;
|
||
let color_base = pal.base;
|
||
let color_surface = pal.surface;
|
||
let color_overlay = pal.overlay;
|
||
let color_text = pal.text;
|
||
let color_subtext = pal.subtext;
|
||
let color_blue = pal.blue;
|
||
let color_lavender = pal.lavender;
|
||
let color_red = pal.red;
|
||
let color_maroon = pal.maroon;
|
||
let color_green = pal.green;
|
||
let color_yellow = pal.yellow;
|
||
|
||
// The window backdrop fill for the three screen roots. When a custom
|
||
// background image is set (W16), the root goes transparent so the image +
|
||
// scrim layered behind by `view_with_background` shows through the gaps
|
||
// between panels; otherwise it's the usual opaque `crust`.
|
||
let root_bg = if state.background_image.is_some() {
|
||
Color::TRANSPARENT
|
||
} else {
|
||
color_crust
|
||
};
|
||
|
||
// 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_overlay,
|
||
value: color_text,
|
||
selection: color_blue,
|
||
};
|
||
|
||
// The Hotkeys info button is always available (hotkeys are app-wide). The
|
||
// room-only player button is hidden off-room; the layout button is hidden on Home.
|
||
let info_button = tooltip(
|
||
button(icon(IconKind::Info, 18.0, color_text))
|
||
.on_press(AppMessage::OpenHotkeyInfo)
|
||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||
.padding(8),
|
||
container(text("Hotkeys").size(11).color(color_text))
|
||
.padding(8)
|
||
.style(c_style(color_crust, color_surface, 6.0)),
|
||
iced::widget::tooltip::Position::Bottom,
|
||
)
|
||
.gap(8);
|
||
|
||
let player_bar_button: Element<'_, AppMessage> = if state.current_screen == Screen::Room {
|
||
let active = state.config.show_player_bar;
|
||
let bg = if active { color_blue } else { color_surface };
|
||
let fg = if active { color_crust } else { color_text };
|
||
tooltip(
|
||
button(text("♪").size(15))
|
||
.on_press(AppMessage::TogglePlayerBar)
|
||
.style(b_style(bg, color_blue, fg, 6.0))
|
||
.padding(8),
|
||
container(text("Player bar").size(11).color(color_text))
|
||
.padding(8)
|
||
.style(c_style(color_crust, color_surface, 6.0)),
|
||
iced::widget::tooltip::Position::Bottom,
|
||
)
|
||
.gap(8)
|
||
.into()
|
||
} else {
|
||
iced::widget::Space::new().width(0.0).height(0.0).into()
|
||
};
|
||
|
||
let layout_button: Element<'_, AppMessage> = if state.current_screen == Screen::Home {
|
||
iced::widget::Space::new().width(0.0).height(0.0).into()
|
||
} else {
|
||
tooltip(
|
||
button(
|
||
Canvas::new(LayoutIcon { fg: color_text })
|
||
.width(iced::Length::Fixed(18.0))
|
||
.height(iced::Length::Fixed(18.0)),
|
||
)
|
||
.on_press(AppMessage::OpenLayoutPicker)
|
||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||
.padding(8),
|
||
container(text("Room layout").size(11).color(color_text))
|
||
.padding(8)
|
||
.style(c_style(color_crust, color_surface, 6.0)),
|
||
iced::widget::tooltip::Position::Bottom,
|
||
)
|
||
.gap(8)
|
||
.into()
|
||
};
|
||
|
||
let top_bar = row![
|
||
horizontal_space(),
|
||
player_bar_button,
|
||
info_button,
|
||
layout_button,
|
||
button(
|
||
row![
|
||
icon(IconKind::Settings, 15.0, color_text),
|
||
text("Settings").size(14),
|
||
]
|
||
.spacing(6)
|
||
.align_y(iced::alignment::Vertical::Center)
|
||
)
|
||
.on_press(AppMessage::NavigateToSettings)
|
||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||
.padding(8)
|
||
]
|
||
.width(iced::Length::Fill)
|
||
.padding(10)
|
||
.spacing(8);
|
||
|
||
if state.current_screen == Screen::Settings {
|
||
let path_field = |label: &'static str, sound: Sound| {
|
||
let path = state.custom_sound_path(sound);
|
||
let validation_widget = match notify::validate_custom_path(path) {
|
||
None => text(""),
|
||
Some(true) => text("✓ File found").size(10).color(color_green),
|
||
Some(false) => text("✗ File not found").size(10).color(color_red),
|
||
};
|
||
|
||
// Per-sound enable checkbox (W6). Interactive only while the master
|
||
// toggle is on — without it every chime is muted anyway, so we drop
|
||
// `on_toggle` to render it greyed out and inert.
|
||
let mut enable = checkbox(state.config.sound_enabled(sound)).size(16);
|
||
if state.config.notifications_enabled {
|
||
enable = enable.on_toggle(move |on| AppMessage::ToggleSoundEnabled(sound, on));
|
||
}
|
||
|
||
column![
|
||
row![
|
||
enable,
|
||
text(label).size(12).color(color_subtext),
|
||
horizontal_space(),
|
||
validation_widget,
|
||
]
|
||
.spacing(6)
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
context_input("Default (embedded)...", path)
|
||
.on_input(move |val| AppMessage::CustomSoundPathChanged(sound, val))
|
||
.style(t_style)
|
||
.padding(8)
|
||
]
|
||
.spacing(4)
|
||
.width(iced::Length::Fill)
|
||
};
|
||
|
||
// Live mic level meter for gate calibration. Shares the gate slider's
|
||
// 0..0.1 scale so you can read your voice against the threshold directly.
|
||
// During a call the in-call meter feeds it; otherwise a "Test mic" toggle
|
||
// spins up a standalone capture stream.
|
||
let in_call = !state.ticket.is_empty();
|
||
let mic_test_control: Element<'_, AppMessage> = if in_call {
|
||
text("Live (in call)").size(11).color(color_green).into()
|
||
} else {
|
||
let (mic_kind, label, bg) = if state.mic_test_active {
|
||
(IconKind::Stop, "Stop mic test", color_red)
|
||
} else {
|
||
(IconKind::Mic, "Test mic", color_surface)
|
||
};
|
||
button(
|
||
row![icon(mic_kind, 13.0, color_text), text(label).size(12),]
|
||
.spacing(5)
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
)
|
||
.on_press(AppMessage::ToggleMicTest(!state.mic_test_active))
|
||
.style(b_style(bg, color_blue, color_text, 6.0))
|
||
.padding(6)
|
||
.into()
|
||
};
|
||
// Unified meter + draggable gate (Discord/OBS-style): the live mic level
|
||
// fills the bar and the yellow handle is the gate threshold, dragged
|
||
// directly on the same axis. Green fill = above the gate (transmitting),
|
||
// dim = below it (muted). Live status word reinforces the colour.
|
||
let gate_thresh = state.config.noise_gate_threshold;
|
||
let speaking = state.mic_level >= 0.001;
|
||
let passing = speaking && state.mic_level >= gate_thresh;
|
||
let (status_label, status_color) = if !speaking {
|
||
("○ Idle", color_subtext)
|
||
} else if passing {
|
||
("● Transmitting", color_green)
|
||
} else {
|
||
("● Muted by gate", color_red)
|
||
};
|
||
let gate_meter = Canvas::new(GateMeter {
|
||
level: state.mic_level,
|
||
threshold: gate_thresh,
|
||
track: color_crust,
|
||
border: color_surface,
|
||
fill_on: color_green,
|
||
fill_off: color_surface,
|
||
handle: Color::from_rgb8(255, 40, 40),
|
||
handle_edge: color_crust,
|
||
})
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fixed(20.0));
|
||
let mic_meter = column![
|
||
gate_meter,
|
||
row![
|
||
text(status_label).size(12).color(status_color),
|
||
horizontal_space(),
|
||
text(format!("gate {:.1}%", gate_thresh * 100.0))
|
||
.size(11)
|
||
.color(color_subtext),
|
||
horizontal_space(),
|
||
mic_test_control,
|
||
]
|
||
.align_y(iced::alignment::Vertical::Center)
|
||
.spacing(8),
|
||
]
|
||
.spacing(6)
|
||
.width(iced::Length::Fill);
|
||
|
||
// Inline room-layout chooser (Settings shows the thumbnails outright, no
|
||
// popup button). Same SelectRoomLayout message, applied live + persisted.
|
||
let layout_choice = |layout: RoomLayout, label: &'static str| -> Element<'_, AppMessage> {
|
||
let selected = state.config.room_layout == layout;
|
||
let tile = Canvas::new(LayoutThumb {
|
||
layout,
|
||
selected,
|
||
base: color_base,
|
||
surface: color_surface,
|
||
overlay: color_overlay,
|
||
border: if selected { color_blue } else { color_surface },
|
||
})
|
||
.width(iced::Length::Fixed(132.0))
|
||
.height(iced::Length::Fixed(86.0));
|
||
column![
|
||
button(tile)
|
||
.on_press(AppMessage::SelectRoomLayout(layout))
|
||
.padding(2)
|
||
.style(b_style(Color::TRANSPARENT, color_surface, color_text, 8.0)),
|
||
text(label)
|
||
.size(11)
|
||
.color(if selected { color_blue } else { color_subtext }),
|
||
]
|
||
.spacing(4)
|
||
.align_x(iced::alignment::Horizontal::Center)
|
||
.into()
|
||
};
|
||
|
||
// Inline theme chooser — a clickable palette-preview swatch per theme.
|
||
// Same SelectTheme message, applied live + persisted.
|
||
let theme_choice = |t: AppTheme| -> Element<'_, AppMessage> {
|
||
let selected = state.config.theme == t;
|
||
let tile = Canvas::new(ThemeSwatch {
|
||
palette: t.palette(),
|
||
selected,
|
||
border: if selected { color_blue } else { color_surface },
|
||
})
|
||
.width(iced::Length::Fixed(120.0))
|
||
.height(iced::Length::Fixed(64.0));
|
||
column![
|
||
button(tile)
|
||
.on_press(AppMessage::SelectTheme(t))
|
||
.padding(2)
|
||
.style(b_style(Color::TRANSPARENT, color_surface, color_text, 8.0)),
|
||
text(t.label())
|
||
.size(11)
|
||
.color(if selected { color_blue } else { color_subtext }),
|
||
]
|
||
.spacing(4)
|
||
.align_x(iced::alignment::Horizontal::Center)
|
||
.into()
|
||
};
|
||
// 10 themes laid out as two rows of five (no flex-wrap in iced 0.14).
|
||
let theme_row1: Vec<Element<'_, AppMessage>> = AppTheme::ALL[0..5]
|
||
.iter()
|
||
.map(|&t| theme_choice(t))
|
||
.collect();
|
||
let theme_row2: Vec<Element<'_, AppMessage>> = AppTheme::ALL[5..]
|
||
.iter()
|
||
.map(|&t| theme_choice(t))
|
||
.collect();
|
||
// Title comes from the "Theme" section header (added below), so this body
|
||
// is just the swatch rows + hint.
|
||
let theme_section = column![
|
||
iced::widget::Row::with_children(theme_row1).spacing(12),
|
||
iced::widget::Row::with_children(theme_row2).spacing(12),
|
||
text("Colour theme for the whole UI. Applies live.")
|
||
.size(11)
|
||
.color(color_subtext),
|
||
]
|
||
.spacing(10)
|
||
.width(iced::Length::Fill);
|
||
|
||
let remove_background: Element<'_, AppMessage> = if state.config.background.is_some() {
|
||
button(text("Remove background").size(13))
|
||
.on_press(AppMessage::RemoveBackground)
|
||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||
.padding(8)
|
||
.into()
|
||
} else {
|
||
iced::widget::Space::new().width(0.0).height(0.0).into()
|
||
};
|
||
let background_section = column![
|
||
row![
|
||
button(text("Choose image…").size(13))
|
||
.on_press(AppMessage::PickBackgroundFile)
|
||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||
.padding(8),
|
||
remove_background,
|
||
].spacing(8),
|
||
text(format!("Background dimming: {:.0}%", state.config.background_dim * 100.0))
|
||
.size(11)
|
||
.color(color_subtext),
|
||
slider(0.0..=1.0, state.config.background_dim, AppMessage::SetBackgroundDim)
|
||
.step(0.05),
|
||
text("Set a picture from your computer as the app background. Auto-resized; a dimming overlay keeps text readable. Applies live.")
|
||
.size(11)
|
||
.color(color_subtext),
|
||
]
|
||
.spacing(10)
|
||
.width(iced::Length::Fill);
|
||
|
||
// Inline avatar chooser (W4): the monogram fallback plus the bundled
|
||
// presets, each a clickable tile. Same SelectAvatar message, applied live
|
||
// + persisted (and re-announced to the room).
|
||
let avatar_choice = |a: crate::avatar::Avatar, label: String| -> Element<'_, AppMessage> {
|
||
let selected = state.config.avatar == a;
|
||
let preview = avatar_view(&a, &state.name, &state.self_id, 52.0);
|
||
column![
|
||
button(
|
||
container(preview)
|
||
.center_x(iced::Length::Fixed(60.0))
|
||
.center_y(iced::Length::Fixed(60.0))
|
||
)
|
||
.on_press(AppMessage::SelectAvatar(a))
|
||
.padding(2)
|
||
.style(b_style(
|
||
if selected {
|
||
color_surface
|
||
} else {
|
||
Color::TRANSPARENT
|
||
},
|
||
color_surface,
|
||
color_text,
|
||
8.0,
|
||
)),
|
||
text(label)
|
||
.size(11)
|
||
.color(if selected { color_blue } else { color_subtext }),
|
||
]
|
||
.spacing(4)
|
||
.align_x(iced::alignment::Horizontal::Center)
|
||
.into()
|
||
};
|
||
let mut avatar_tiles: Vec<Element<'_, AppMessage>> = Vec::new();
|
||
// Show the current custom avatar (if any) as the first, selected tile.
|
||
if matches!(state.config.avatar, crate::avatar::Avatar::Custom(_)) {
|
||
avatar_tiles.push(avatar_choice(
|
||
state.config.avatar.clone(),
|
||
"Custom".to_string(),
|
||
));
|
||
}
|
||
avatar_tiles.push(avatar_choice(
|
||
crate::avatar::Avatar::Monogram,
|
||
"Monogram".to_string(),
|
||
));
|
||
for i in 0..crate::avatar::PRESET_COUNT {
|
||
avatar_tiles.push(avatar_choice(
|
||
crate::avatar::Avatar::Preset(i),
|
||
format!("Preset {}", i + 1),
|
||
));
|
||
}
|
||
// Wrap into rows of four (no flex-wrap in iced 0.14) so the tiles don't
|
||
// run off a narrow Settings panel.
|
||
let mut tile_rows: Vec<Element<'_, AppMessage>> = Vec::new();
|
||
let mut tiles_iter = avatar_tiles.into_iter();
|
||
loop {
|
||
let chunk: Vec<Element<'_, AppMessage>> = tiles_iter.by_ref().take(4).collect();
|
||
if chunk.is_empty() {
|
||
break;
|
||
}
|
||
tile_rows.push(iced::widget::Row::with_children(chunk).spacing(12).into());
|
||
}
|
||
let upload_btn = button(text("Upload image…").size(13))
|
||
.on_press(AppMessage::PickAvatarFile)
|
||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||
.padding(8);
|
||
let avatar_section = column![
|
||
iced::widget::Column::with_children(tile_rows).spacing(12),
|
||
upload_btn,
|
||
text("Shown next to your name in the room and chat. Custom images are PNG/JPEG, auto-resized. Applies live.")
|
||
.size(11)
|
||
.color(color_subtext),
|
||
]
|
||
.spacing(10)
|
||
.width(iced::Length::Fill);
|
||
|
||
// Reusable category header: a coloured title with a thin full-width
|
||
// divider beneath, so each class of settings reads as its own section.
|
||
let section_header = |title: &'static str| -> Element<'_, AppMessage> {
|
||
column![
|
||
text(title).size(16).color(color_blue),
|
||
container(text(""))
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fixed(1.0))
|
||
.style(c_style(color_surface, Color::TRANSPARENT, 0.0)),
|
||
]
|
||
.spacing(6)
|
||
.width(iced::Length::Fill)
|
||
.into()
|
||
};
|
||
|
||
// Spacing between one category and the next.
|
||
let section_gap = 18.0;
|
||
|
||
let screen_share = &state.config.screen_share;
|
||
let screen_share_bitrate = screen_share
|
||
.bitrate_mbps
|
||
.map(|v| v.to_string())
|
||
.unwrap_or_default();
|
||
let screen_share_section = column![
|
||
column![
|
||
text("Host encoding").size(13).color(color_subtext),
|
||
row![
|
||
column![
|
||
text("Quality").size(12).color(color_subtext),
|
||
pick_list(
|
||
&ShareQuality::ALL[..],
|
||
Some(screen_share.quality),
|
||
AppMessage::ScreenShareQualitySelected,
|
||
).width(iced::Length::Fill),
|
||
text(share_quality_hint(screen_share.quality)).size(11).color(color_subtext),
|
||
].spacing(4).width(iced::Length::Fill),
|
||
column![
|
||
text("Max resolution").size(12).color(color_subtext),
|
||
pick_list(
|
||
&ShareMaxHeightChoice::ALL[..],
|
||
Some(ShareMaxHeightChoice::from_config(screen_share.max_height)),
|
||
AppMessage::ScreenShareMaxHeightSelected,
|
||
).width(iced::Length::Fill),
|
||
text("Source keeps the captured display height.").size(11).color(color_subtext),
|
||
].spacing(4).width(iced::Length::Fill),
|
||
].spacing(16).width(iced::Length::Fill),
|
||
row![
|
||
column![
|
||
text("Framerate").size(12).color(color_subtext),
|
||
pick_list(
|
||
&ShareFramerateChoice::ALL[..],
|
||
Some(ShareFramerateChoice::from_config(screen_share.framerate)),
|
||
AppMessage::ScreenShareFramerateSelected,
|
||
).width(iced::Length::Fill),
|
||
text("Preset default lets pixelpass choose.").size(11).color(color_subtext),
|
||
].spacing(4).width(iced::Length::Fill),
|
||
column![
|
||
text("Bitrate Mbps").size(12).color(color_subtext),
|
||
context_input("preset default", &screen_share_bitrate)
|
||
.on_input(AppMessage::ScreenShareBitrateChanged)
|
||
.style(t_style)
|
||
.padding(8)
|
||
.width(iced::Length::Fill),
|
||
text("Blank uses the quality preset.").size(11).color(color_subtext),
|
||
].spacing(4).width(iced::Length::Fill),
|
||
].spacing(16).width(iced::Length::Fill),
|
||
row![
|
||
column![
|
||
text("Max viewers").size(12).color(color_subtext),
|
||
pick_list(
|
||
&ShareMaxViewersChoice::ALL[..],
|
||
Some(ShareMaxViewersChoice::from_config(screen_share.max_viewers)),
|
||
AppMessage::ScreenShareMaxViewersSelected,
|
||
).width(iced::Length::Fill),
|
||
text("Auto uses pixelpass' connection-aware recommendation.").size(11).color(color_subtext),
|
||
].spacing(4).width(iced::Length::Fill),
|
||
column![
|
||
checkbox(screen_share.force_software_encode)
|
||
.label("Force software encode")
|
||
.on_toggle(AppMessage::ToggleScreenShareForceSoftwareEncode),
|
||
text("Passes --no-hwencode to pixelpass.").size(11).color(color_subtext),
|
||
].spacing(8).width(iced::Length::Fill),
|
||
].spacing(16).width(iced::Length::Fill),
|
||
].spacing(10).width(iced::Length::Fill),
|
||
vertical_space(section_gap),
|
||
column![
|
||
text("Viewer playback").size(13).color(color_subtext),
|
||
row![
|
||
column![
|
||
text("Player").size(12).color(color_subtext),
|
||
pick_list(
|
||
&SharePlayer::ALL[..],
|
||
Some(screen_share.player),
|
||
AppMessage::ScreenSharePlayerSelected,
|
||
).width(iced::Length::Fill),
|
||
text(share_player_hint(screen_share.player)).size(11).color(color_subtext),
|
||
].spacing(4).width(iced::Length::Fill),
|
||
column![
|
||
text("Buffering").size(12).color(color_subtext),
|
||
pick_list(
|
||
&ShareBuffering::ALL[..],
|
||
Some(screen_share.buffering),
|
||
AppMessage::ScreenShareBufferingSelected,
|
||
).width(iced::Length::Fill),
|
||
text(share_buffering_hint(screen_share.buffering)).size(11).color(color_subtext),
|
||
].spacing(4).width(iced::Length::Fill),
|
||
].spacing(16).width(iced::Length::Fill),
|
||
row![
|
||
column![
|
||
text("Cache MB").size(12).color(color_subtext),
|
||
pick_list(
|
||
&SHARE_CACHE_MB_OPTIONS[..],
|
||
Some(screen_share.cache_mb),
|
||
AppMessage::ScreenShareCacheMbSelected,
|
||
).width(iced::Length::Fill),
|
||
text("mpv demuxer cache size (mpv only).").size(11).color(color_subtext),
|
||
].spacing(4).width(iced::Length::Fill),
|
||
column![
|
||
checkbox(screen_share.hardware_decode)
|
||
.label("Hardware video decode")
|
||
.on_toggle(AppMessage::ToggleScreenShareHardwareDecode),
|
||
text("GPU decode (mpv --hwdec=auto / VLC hardware decode). Off avoids the known frame-freeze bug.").size(11).color(color_subtext),
|
||
].spacing(8).width(iced::Length::Fill),
|
||
].spacing(16).width(iced::Length::Fill),
|
||
].spacing(10).width(iced::Length::Fill),
|
||
vertical_space(section_gap),
|
||
column![
|
||
text("Extra mpv args").size(12).color(color_subtext),
|
||
context_input("--no-osc --vd-lavc-threads=2", &screen_share.extra_mpv_args)
|
||
.on_input(AppMessage::ScreenShareExtraMpvArgsChanged)
|
||
.style(t_style)
|
||
.padding(8)
|
||
.width(iced::Length::Fill),
|
||
text("⚠ Advanced — may break playback").size(11).color(color_yellow),
|
||
text("Extra pixelpass args").size(12).color(color_subtext),
|
||
context_input("--relay https://relay.example/", &screen_share.extra_host_args)
|
||
.on_input(AppMessage::ScreenShareExtraHostArgsChanged)
|
||
.style(t_style)
|
||
.padding(8)
|
||
.width(iced::Length::Fill),
|
||
text("⚠ Advanced — may break playback").size(11).color(color_yellow),
|
||
].spacing(6).width(iced::Length::Fill),
|
||
text("Applies the next time you start or watch a screen share. These are local preferences only.").size(11).color(color_subtext),
|
||
]
|
||
.spacing(10)
|
||
.width(iced::Length::Fill);
|
||
|
||
// One recording-mode radio with a hover tooltip explaining it. (iced's
|
||
// pick_list can't host per-option tooltips, so the modes are radios.)
|
||
let mode_radio = |mode: RecordingMode, label: &'static str| -> Element<'_, AppMessage> {
|
||
tooltip(
|
||
radio(
|
||
label,
|
||
mode,
|
||
Some(state.config.recording_mode),
|
||
AppMessage::RecordingModeSelected,
|
||
),
|
||
container(text(recording_mode_hint(mode)).size(11).color(color_text))
|
||
.padding(8)
|
||
.max_width(300.0)
|
||
.style(c_style(color_crust, color_surface, 6.0)),
|
||
iced::widget::tooltip::Position::Right,
|
||
)
|
||
.gap(8)
|
||
.into()
|
||
};
|
||
|
||
let mut hotkey_rows = Column::new().spacing(8).width(iced::Length::Fill);
|
||
for action in HotkeyAction::ALL {
|
||
let capturing = state.hotkey_capture == Some(action);
|
||
let binding = if capturing {
|
||
"Press a key...".to_string()
|
||
} else {
|
||
format_binding(state.config.hotkeys.binding(action))
|
||
};
|
||
hotkey_rows = hotkey_rows.push(
|
||
row![
|
||
column![
|
||
text(action.label()).size(13).color(color_text),
|
||
text(match action.tier() {
|
||
crate::hotkeys::HotkeyTier::AppWide => "App-wide",
|
||
crate::hotkeys::HotkeyTier::RoomOnly => "Room-only",
|
||
})
|
||
.size(10)
|
||
.color(color_subtext),
|
||
]
|
||
.spacing(2)
|
||
.width(iced::Length::Fill),
|
||
container(text(binding).size(12).color(if capturing {
|
||
color_yellow
|
||
} else {
|
||
color_subtext
|
||
}))
|
||
.width(iced::Length::Fixed(110.0))
|
||
.align_x(iced::alignment::Horizontal::Right),
|
||
button(text("Set").size(12))
|
||
.on_press(AppMessage::StartHotkeyCapture(action))
|
||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||
.padding(6),
|
||
button(text("Clear").size(12))
|
||
.on_press(AppMessage::ClearHotkey(action))
|
||
.style(b_style(color_surface, color_red, color_text, 6.0))
|
||
.padding(6),
|
||
]
|
||
.spacing(10)
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
);
|
||
}
|
||
let hotkey_conflicts = state.config.hotkeys.conflicts();
|
||
let conflict_block: Element<'_, AppMessage> = if hotkey_conflicts.is_empty() {
|
||
vertical_space(0.0).into()
|
||
} else {
|
||
let mut lines = Column::new().spacing(4);
|
||
for conflict in hotkey_conflicts {
|
||
lines = lines.push(
|
||
text(format!(
|
||
"Conflict: {} is assigned to {} and {}.",
|
||
conflict.binding.label(),
|
||
conflict.first.label(),
|
||
conflict.second.label()
|
||
))
|
||
.size(11)
|
||
.color(color_red),
|
||
);
|
||
}
|
||
lines.into()
|
||
};
|
||
let hotkey_section = column![
|
||
hotkey_rows,
|
||
conflict_block,
|
||
text("Shortcuts work only while the PeerSpeak window has focus. Unset actions are ignored.")
|
||
.size(11)
|
||
.color(color_subtext),
|
||
]
|
||
.spacing(8)
|
||
.width(iced::Length::Fill);
|
||
|
||
// --- Identity (W7) ---
|
||
// Your persistent node id + a Regenerate control. When the key isn't
|
||
// persisted (disk/permission failure → ephemeral fallback) we show a
|
||
// standing red warning, because the id won't survive the next launch and
|
||
// friends will stop recognising you.
|
||
let id_display = state
|
||
.self_node_id
|
||
.as_deref()
|
||
.map(|id| format!("{}…", short_id(id)))
|
||
.unwrap_or_else(|| "(starting…)".to_string());
|
||
let identity_warning: Element<AppMessage> = if state.identity_persisted {
|
||
column![].into()
|
||
} else {
|
||
let reason = state
|
||
.identity_error
|
||
.as_deref()
|
||
.unwrap_or("the key file could not be read or written");
|
||
container(
|
||
column![
|
||
text("⚠ Identity not saved").size(13).color(color_red),
|
||
text(format!(
|
||
"Your identity couldn't be saved to disk ({reason}). It won't \
|
||
survive the next launch, so your friends will stop recognising \
|
||
you. Check free space and permissions on ~/.config/peerspeak/."
|
||
))
|
||
.size(12)
|
||
.color(color_subtext),
|
||
]
|
||
.spacing(4),
|
||
)
|
||
.padding(10)
|
||
.width(iced::Length::Fill)
|
||
.style(move |_t: &Theme| container::Style {
|
||
background: Some(Background::Color(Color {
|
||
a: 0.12,
|
||
..color_red
|
||
})),
|
||
border: Border {
|
||
color: color_red,
|
||
width: 1.0,
|
||
radius: 8.0.into(),
|
||
},
|
||
..Default::default()
|
||
})
|
||
.into()
|
||
};
|
||
// The ID line exposes the full value in a locked selectable field while
|
||
// keeping the one-click Copy button for fast whole-ID copy.
|
||
let id_row: Element<AppMessage> = match state.self_node_id.clone() {
|
||
Some(full) => row![
|
||
text("ID:").size(13).color(color_text),
|
||
locked_value(&full, AppMessage::Noop)
|
||
.width(iced::Length::Fixed(260.0))
|
||
.size(13)
|
||
.padding(4),
|
||
button(
|
||
row![
|
||
icon(IconKind::Copy, 13.0, color_text),
|
||
text("Copy").size(12),
|
||
]
|
||
.spacing(5)
|
||
.align_y(iced::alignment::Vertical::Center)
|
||
)
|
||
.on_press(AppMessage::CopyText(full.clone()))
|
||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||
.padding(6),
|
||
]
|
||
.spacing(10)
|
||
.align_y(iced::alignment::Vertical::Center)
|
||
.into(),
|
||
None => text(format!("ID: {id_display}"))
|
||
.size(13)
|
||
.color(color_text)
|
||
.into(),
|
||
};
|
||
let identity_section = column![
|
||
text(
|
||
"Your permanent ID — friends recognise you by this. It stays the \
|
||
same across launches; regenerate only to start fresh as a new \
|
||
identity (friends who saved the old one will no longer reach you)."
|
||
)
|
||
.size(12)
|
||
.color(color_subtext),
|
||
id_row,
|
||
button(text("Regenerate identity").size(13))
|
||
.on_press(AppMessage::OpenRegenerateIdentityConfirm)
|
||
.style(b_style(color_surface, color_maroon, color_text, 6.0))
|
||
.padding(8),
|
||
identity_warning,
|
||
]
|
||
.spacing(8)
|
||
.width(iced::Length::Fill);
|
||
|
||
// Presence + Friends moved to the home screen (see `friends_panel`).
|
||
|
||
let settings_body: Element<'_, AppMessage> = match state.settings_category {
|
||
SettingsCategory::Audio => column![
|
||
section_header("Audio Devices"),
|
||
row![
|
||
column![
|
||
text("Input Device").size(12).color(color_subtext),
|
||
pick_list(
|
||
&state.input_devices[..],
|
||
state.selected_input.as_ref(),
|
||
AppMessage::InputDeviceSelected,
|
||
).width(iced::Length::Fill),
|
||
text(format!("Input Volume (mic): {:.0}%", state.config.input_volume * 100.0)).size(11).color(color_subtext),
|
||
slider(0.0..=2.0, state.config.input_volume, AppMessage::InputVolumeChanged)
|
||
.step(0.05)
|
||
.on_release(AppMessage::PersistConfig),
|
||
].spacing(8).width(iced::Length::Fill),
|
||
column![
|
||
text("Output Device").size(12).color(color_subtext),
|
||
pick_list(
|
||
&state.output_devices[..],
|
||
state.selected_output.as_ref(),
|
||
AppMessage::OutputDeviceSelected,
|
||
).width(iced::Length::Fill),
|
||
text(format!("Output Volume: {:.0}%", state.config.output_volume * 100.0)).size(11).color(color_subtext),
|
||
slider(0.0..=2.0, state.config.output_volume, AppMessage::OutputVolumeChanged)
|
||
.step(0.05)
|
||
.on_release(AppMessage::PersistConfig),
|
||
].spacing(8).width(iced::Length::Fill),
|
||
].spacing(20).align_y(iced::alignment::Vertical::Top).width(iced::Length::Fill),
|
||
vertical_space(section_gap),
|
||
section_header("Microphone"),
|
||
column![
|
||
mic_meter,
|
||
text("Drag the yellow handle to set the gate. While talking, place it just above your quiet-room level so silence is muted but your voice passes through.").size(11).color(color_subtext),
|
||
vertical_space(4.0),
|
||
{
|
||
let control: Element<'_, AppMessage> = {
|
||
#[cfg(target_os = "linux")]
|
||
{
|
||
column![
|
||
checkbox(state.config.echo_cancellation_enabled)
|
||
.label("Echo cancellation")
|
||
.on_toggle(AppMessage::ToggleEchoCancellation),
|
||
text("Cancels speaker echo + suppresses noise (PipeWire). Takes effect on your next room join.").size(11).color(color_subtext),
|
||
].spacing(8).into()
|
||
}
|
||
#[cfg(not(target_os = "linux"))]
|
||
{
|
||
column![
|
||
checkbox(false)
|
||
.label("Echo cancellation"),
|
||
text("Echo cancellation is not available on Windows yet.").size(11).color(color_subtext),
|
||
].spacing(8).into()
|
||
}
|
||
};
|
||
control
|
||
},
|
||
].spacing(8).width(iced::Length::Fill),
|
||
vertical_space(section_gap),
|
||
section_header("Connection quality"),
|
||
column![
|
||
pick_list(
|
||
&AudioProfile::ALL[..],
|
||
Some(state.config.audio_profile),
|
||
AppMessage::AudioProfileSelected,
|
||
).width(iced::Length::Fill),
|
||
text(audio_profile_hint(state.config.audio_profile)).size(11).color(color_subtext),
|
||
text("Applies immediately, even mid-call.").size(11).color(color_subtext),
|
||
].spacing(4).width(iced::Length::Fill),
|
||
]
|
||
.spacing(10)
|
||
.width(iced::Length::Fill)
|
||
.into(),
|
||
SettingsCategory::Hotkeys => column![
|
||
section_header("Hotkeys"),
|
||
hotkey_section,
|
||
]
|
||
.spacing(10)
|
||
.width(iced::Length::Fill)
|
||
.into(),
|
||
SettingsCategory::Recording => column![
|
||
section_header("Recording"),
|
||
column![
|
||
mode_radio(RecordingMode::Mixed, "Mixed (single file)"),
|
||
mode_radio(RecordingMode::Multitrack, "Multitrack (per-peer stems)"),
|
||
mode_radio(RecordingMode::Both, "Both (stems + mixed)"),
|
||
vertical_space(2.0),
|
||
text("Hover an option for what it does. Saved to ~/peerspeak-recordings/ — Multitrack/Both as a timestamped folder of tracks, Mixed as a single file. Applies to your next recording.").size(11).color(color_subtext),
|
||
].spacing(8).width(iced::Length::Fill),
|
||
]
|
||
.spacing(10)
|
||
.width(iced::Length::Fill)
|
||
.into(),
|
||
SettingsCategory::Profile => column![
|
||
section_header("Avatar"),
|
||
avatar_section,
|
||
vertical_space(section_gap),
|
||
section_header("Identity"),
|
||
identity_section,
|
||
]
|
||
.spacing(10)
|
||
.width(iced::Length::Fill)
|
||
.into(),
|
||
SettingsCategory::Appearance => column![
|
||
section_header("Room Layout"),
|
||
column![
|
||
row![
|
||
layout_choice(RoomLayout::ThreeColumn, "3-Column"),
|
||
layout_choice(RoomLayout::BottomDock, "Bottom Dock"),
|
||
layout_choice(RoomLayout::Drawer, "Drawer"),
|
||
].spacing(16),
|
||
text("How the in-call room is arranged. Applies live.").size(11).color(color_subtext),
|
||
].spacing(8).width(iced::Length::Fill),
|
||
vertical_space(section_gap),
|
||
section_header("Theme"),
|
||
theme_section,
|
||
vertical_space(section_gap),
|
||
section_header("Background"),
|
||
background_section,
|
||
]
|
||
.spacing(10)
|
||
.width(iced::Length::Fill)
|
||
.into(),
|
||
SettingsCategory::Network => column![
|
||
section_header("Network & Privacy"),
|
||
column![
|
||
pick_list(
|
||
&NetworkMode::ALL[..],
|
||
Some(state.config.network_mode),
|
||
AppMessage::NetworkModeSelected,
|
||
).width(iced::Length::Fill),
|
||
text(network_mode_hint(state.config.network_mode)).size(11).color(color_subtext),
|
||
text("Takes effect on your next room join.").size(11).color(color_subtext),
|
||
].spacing(4).width(iced::Length::Fill),
|
||
]
|
||
.spacing(10)
|
||
.width(iced::Length::Fill)
|
||
.into(),
|
||
SettingsCategory::Advanced => column![
|
||
section_header("Screen sharing"),
|
||
screen_share_section,
|
||
]
|
||
.spacing(10)
|
||
.width(iced::Length::Fill)
|
||
.into(),
|
||
SettingsCategory::Notifications => column![
|
||
section_header("Notifications & Sounds"),
|
||
column![
|
||
checkbox(state.config.notifications_enabled)
|
||
.label("Enable sound notifications")
|
||
.on_toggle(AppMessage::ToggleNotifications),
|
||
vertical_space(6.0),
|
||
text("Tick a sound to enable its chime; untick to silence just that one. Optional WAV path overrides the built-in sound (blank = built-in).").size(12).color(color_subtext),
|
||
row![
|
||
path_field("Self Join", Sound::SelfJoin),
|
||
path_field("Peer Join", Sound::PeerJoin),
|
||
].spacing(20).width(iced::Length::Fill),
|
||
row![
|
||
path_field("Self Leave", Sound::SelfLeave),
|
||
path_field("Peer Leave", Sound::PeerLeave),
|
||
].spacing(20).width(iced::Length::Fill),
|
||
row![
|
||
path_field("Reconnect Attempt", Sound::ReconnectAttempt),
|
||
path_field("Reconnected", Sound::Reconnected),
|
||
].spacing(20).width(iced::Length::Fill),
|
||
row![
|
||
path_field("Mic Toggle", Sound::MicToggle),
|
||
path_field("Reconnect Failed", Sound::ReconnectFailed),
|
||
].spacing(20).width(iced::Length::Fill),
|
||
].spacing(8).width(iced::Length::Fill),
|
||
]
|
||
.spacing(10)
|
||
.width(iced::Length::Fill)
|
||
.into(),
|
||
SettingsCategory::Games => {
|
||
// What's running right now (local detection), for context.
|
||
let detected_label = match &state.current_game {
|
||
Some(g) => match &g.name {
|
||
Some(name) => format!("Currently detected: {name}"),
|
||
None => "Currently detected: (a game, no name available)".to_string(),
|
||
},
|
||
None => "Currently detected: nothing".to_string(),
|
||
};
|
||
|
||
// Manual override picker: Auto / None, plus "Pin current" when a
|
||
// game is detected.
|
||
let mut override_opts = vec![GameOverrideChoice::Auto, GameOverrideChoice::None];
|
||
if state.current_game.is_some() {
|
||
override_opts.push(GameOverrideChoice::Current);
|
||
}
|
||
|
||
// Per-game background row for the running game (if any).
|
||
let game_bg_section: Element<'_, AppMessage> = match &state.current_game {
|
||
Some(g) => {
|
||
let has_bg = state.config.game_backgrounds.contains_key(&g.id);
|
||
let id_for_pick = g.id.clone();
|
||
let mut controls = row![
|
||
button(text("Set background for this game").size(13))
|
||
.on_press(AppMessage::PickGameBackground(id_for_pick)),
|
||
]
|
||
.spacing(8);
|
||
if has_bg {
|
||
let id_for_remove = g.id.clone();
|
||
controls = controls.push(
|
||
button(text("Remove").size(13))
|
||
.on_press(AppMessage::RemoveGameBackground(id_for_remove)),
|
||
);
|
||
}
|
||
controls.into()
|
||
}
|
||
None => text("Start a game to set its background.")
|
||
.size(12)
|
||
.color(color_subtext)
|
||
.into(),
|
||
};
|
||
|
||
// The list of configured per-game backgrounds (by stable id).
|
||
let mut bg_list = column![].spacing(4).width(iced::Length::Fill);
|
||
for id in state.config.game_backgrounds.keys() {
|
||
let id_owned = id.clone();
|
||
bg_list = bg_list.push(
|
||
row![
|
||
text(id.clone()).size(12).width(iced::Length::Fill),
|
||
button(text("Remove").size(12))
|
||
.on_press(AppMessage::RemoveGameBackground(id_owned)),
|
||
]
|
||
.spacing(8)
|
||
.width(iced::Length::Fill),
|
||
);
|
||
}
|
||
|
||
// The process→name mappings (non-Steam games), with an add form.
|
||
let mut map_list = column![].spacing(4).width(iced::Length::Fill);
|
||
for (exe, name) in &state.config.game_process_map {
|
||
let exe_owned = exe.clone();
|
||
map_list = map_list.push(
|
||
row![
|
||
text(format!("{exe} → {name}")).size(12).width(iced::Length::Fill),
|
||
button(text("Remove").size(12))
|
||
.on_press(AppMessage::RemoveGameMapping(exe_owned)),
|
||
]
|
||
.spacing(8)
|
||
.width(iced::Length::Fill),
|
||
);
|
||
}
|
||
|
||
column![
|
||
section_header("Game presence"),
|
||
column![
|
||
checkbox(state.config.game_presence_enabled)
|
||
.label("Show the game I'm playing to people in the call")
|
||
.on_toggle(AppMessage::ToggleGamePresence),
|
||
text("Off by default. When on, your detected game appears next to your avatar for everyone in the room.")
|
||
.size(11).color(color_subtext),
|
||
vertical_space(6.0),
|
||
text(detected_label).size(12).color(color_subtext),
|
||
row![
|
||
text("Override:").size(13),
|
||
pick_list(
|
||
override_opts,
|
||
Some(state.game_override),
|
||
AppMessage::GameOverrideSelected,
|
||
),
|
||
].spacing(8),
|
||
].spacing(6).width(iced::Length::Fill),
|
||
vertical_space(section_gap),
|
||
section_header("Per-game background"),
|
||
column![
|
||
text("Give a game its own UI background; it switches automatically while you play. Falls back to your custom background (Appearance) otherwise.")
|
||
.size(11).color(color_subtext),
|
||
game_bg_section,
|
||
bg_list,
|
||
].spacing(8).width(iced::Length::Fill),
|
||
vertical_space(section_gap),
|
||
section_header("Non-Steam games"),
|
||
column![
|
||
text("Steam games are detected automatically. For other launchers, map an executable name to a display name.")
|
||
.size(11).color(color_subtext),
|
||
row![
|
||
context_input("executable (e.g. hl2_linux)", &state.game_map_exe_input)
|
||
.on_input(AppMessage::GameMapExeChanged)
|
||
.width(iced::Length::Fill),
|
||
context_input("shown name (e.g. Half-Life 2)", &state.game_map_name_input)
|
||
.on_input(AppMessage::GameMapNameChanged)
|
||
.width(iced::Length::Fill),
|
||
button(text("Add").size(13)).on_press(AppMessage::AddGameMapping),
|
||
].spacing(8).width(iced::Length::Fill),
|
||
map_list,
|
||
].spacing(8).width(iced::Length::Fill),
|
||
]
|
||
.spacing(10)
|
||
.width(iced::Length::Fill)
|
||
.into()
|
||
}
|
||
};
|
||
|
||
let category_button = |category: SettingsCategory| -> Element<'_, AppMessage> {
|
||
let selected = state.settings_category == category;
|
||
let label_color = if selected { color_blue } else { color_text };
|
||
let border_color = if selected {
|
||
color_blue
|
||
} else {
|
||
Color::TRANSPARENT
|
||
};
|
||
let bg = if selected {
|
||
color_surface
|
||
} else {
|
||
Color::TRANSPARENT
|
||
};
|
||
button(
|
||
container(
|
||
column![
|
||
text(category.label()).size(14).color(label_color),
|
||
text(category.hint()).size(11).color(color_subtext),
|
||
]
|
||
.spacing(2)
|
||
.width(iced::Length::Fill),
|
||
)
|
||
.width(iced::Length::Fill),
|
||
)
|
||
.on_press(AppMessage::SelectSettingsCategory(category))
|
||
.style(move |_theme: &Theme, status: button::Status| {
|
||
let active_bg = match status {
|
||
button::Status::Hovered if selected => color_surface,
|
||
button::Status::Hovered => color_crust,
|
||
_ => bg,
|
||
};
|
||
button::Style {
|
||
background: Some(Background::Color(active_bg)),
|
||
text_color: label_color,
|
||
border: Border {
|
||
color: border_color,
|
||
width: if selected { 1.0 } else { 0.0 },
|
||
radius: 8.0.into(),
|
||
},
|
||
..Default::default()
|
||
}
|
||
})
|
||
.padding(10)
|
||
.width(iced::Length::Fill)
|
||
.into()
|
||
};
|
||
|
||
let mut settings_nav = column![text("SETTINGS").size(11).color(color_subtext),]
|
||
.spacing(8)
|
||
.width(iced::Length::Fill);
|
||
for category in SettingsCategory::ALL {
|
||
settings_nav = settings_nav.push(category_button(category));
|
||
}
|
||
settings_nav = settings_nav
|
||
.push(iced::widget::Space::new().height(iced::Length::Fill))
|
||
.push(text(app_build_label()).size(11).color(color_subtext));
|
||
let settings_nav = container(settings_nav)
|
||
.padding(12)
|
||
.width(iced::Length::Fixed(220.0))
|
||
.height(iced::Length::Fill)
|
||
.style(c_style(color_crust, color_surface, 8.0));
|
||
|
||
let settings_content: Element<'_, AppMessage> = if state.window_size.width < 820.0 {
|
||
scrollable(
|
||
column![
|
||
text("Category").size(12).color(color_subtext),
|
||
pick_list(
|
||
&SettingsCategory::ALL[..],
|
||
Some(state.settings_category),
|
||
AppMessage::SelectSettingsCategory,
|
||
)
|
||
.width(iced::Length::Fill),
|
||
vertical_space(10.0),
|
||
settings_body,
|
||
vertical_space(10.0),
|
||
text(app_build_label()).size(11).color(color_subtext),
|
||
]
|
||
.spacing(8)
|
||
.width(iced::Length::Fill),
|
||
)
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fill)
|
||
.into()
|
||
} else {
|
||
row![
|
||
settings_nav,
|
||
scrollable(settings_body)
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fill),
|
||
]
|
||
.spacing(16)
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fill)
|
||
.into()
|
||
};
|
||
|
||
// Sticky header bar: stays fixed above the scrollable content so the Back
|
||
// button is always reachable. The "Settings" title is centered by flanking
|
||
// it with two equal-width Fill segments — the Back button lives in the left
|
||
// one (left-aligned) and the right one is an empty balance, so the title is
|
||
// mathematically centered regardless of the Back button's rendered width.
|
||
let settings_header = container(
|
||
row![
|
||
container(
|
||
button(
|
||
row![text("←").size(16), text("Back").size(14),]
|
||
.spacing(6)
|
||
.align_y(iced::alignment::Vertical::Center)
|
||
)
|
||
.on_press(AppMessage::NavigateBack)
|
||
.style(b_style(color_surface, color_blue, color_text, 8.0))
|
||
.padding([8, 14])
|
||
)
|
||
.width(iced::Length::Fill),
|
||
text("Settings").size(20).color(color_blue),
|
||
// Equal-width balance spacer keeps the title centered.
|
||
container(text("")).width(iced::Length::Fill),
|
||
]
|
||
.align_y(iced::alignment::Vertical::Center)
|
||
.width(iced::Length::Fill),
|
||
)
|
||
.padding([12, 16])
|
||
.width(iced::Length::Fill)
|
||
.style(c_style(color_crust, color_surface, 8.0));
|
||
|
||
let settings_box = container(
|
||
column![settings_header, vertical_space(12.0), settings_content,]
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fill),
|
||
)
|
||
.style(c_style(color_mantle, color_surface, 12.0))
|
||
.padding(24)
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fill);
|
||
|
||
let settings_screen = container(settings_box)
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fill)
|
||
.padding(24)
|
||
.center_x(iced::Length::Fill)
|
||
.style(c_style(root_bg, Color::TRANSPARENT, 0.0));
|
||
|
||
return with_regenerate_confirm(settings_screen.into(), state);
|
||
}
|
||
|
||
if state.current_screen == Screen::Home {
|
||
// --- HOME SCREEN ---
|
||
// Keep Create/Join dominant on a fresh install. Once Recents or Friends
|
||
// has real content, the wider three-card layout returns.
|
||
let has_recents = !state.config.recents.is_empty();
|
||
let has_friends = !state.friends.list().is_empty();
|
||
let body = responsive(move |size| {
|
||
let cards: Element<AppMessage> =
|
||
match home_layout_mode(size.width, has_recents, has_friends) {
|
||
HomeLayoutMode::FocusedEmpty => row![
|
||
connect_card(state),
|
||
column![friends_panel(state), recents_card(state)]
|
||
.spacing(16)
|
||
.width(iced::Length::Fixed(360.0)),
|
||
]
|
||
.spacing(22)
|
||
.align_y(iced::alignment::Vertical::Top)
|
||
.into(),
|
||
HomeLayoutMode::ThreeColumn => row![
|
||
recents_card(state),
|
||
connect_card(state),
|
||
friends_panel(state),
|
||
]
|
||
.spacing(20)
|
||
.align_y(iced::alignment::Vertical::Top)
|
||
.into(),
|
||
HomeLayoutMode::Stacked => {
|
||
let mut stack = column![connect_card(state)]
|
||
.spacing(20)
|
||
.align_x(iced::alignment::Horizontal::Center);
|
||
if has_recents {
|
||
stack = stack.push(recents_card(state));
|
||
}
|
||
stack = stack.push(friends_panel(state));
|
||
if !has_recents {
|
||
stack = stack.push(recents_card(state));
|
||
}
|
||
stack.into()
|
||
}
|
||
};
|
||
scrollable(container(cards).center_x(iced::Length::Fill))
|
||
.width(iced::Length::Fill)
|
||
.into()
|
||
});
|
||
|
||
let home = container(
|
||
column![top_bar, vertical_space(20.0), body]
|
||
.align_x(iced::alignment::Horizontal::Center),
|
||
)
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fill)
|
||
.style(c_style(root_bg, Color::TRANSPARENT, 0.0));
|
||
|
||
with_hotkey_info(with_layout_picker(home.into(), state), state)
|
||
} else {
|
||
// --- ROOM SCREEN ---
|
||
let participant_count = state.peers.len() + 1; // peers + you
|
||
let call_secs = state
|
||
.call_started
|
||
.map(|t| t.elapsed().as_secs())
|
||
.unwrap_or(0);
|
||
// The room's cosmetic label (W7) rides in our share ticket; show it under
|
||
// the wordmark when the room was named. Sanitized since a joined ticket is
|
||
// peer-supplied.
|
||
let room_label = crate::sanitize::sanitize_name(
|
||
&crate::network::PeerSpeakTicket::label_of(&state.ticket),
|
||
);
|
||
let title: Element<'_, AppMessage> = if room_label.is_empty() {
|
||
text("PEERSPEAK").size(20).color(color_blue).into()
|
||
} else {
|
||
column![
|
||
text("PEERSPEAK").size(20).color(color_blue),
|
||
text(room_label).size(13).color(color_subtext),
|
||
]
|
||
.into()
|
||
};
|
||
let header = row![
|
||
title,
|
||
horizontal_space(),
|
||
row![
|
||
icon(IconKind::People, 15.0, color_subtext),
|
||
text(format!("{participant_count} in room"))
|
||
.size(14)
|
||
.color(color_subtext),
|
||
]
|
||
.spacing(5)
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
row![
|
||
icon(IconKind::Clock, 15.0, color_subtext),
|
||
text(format_duration(call_secs))
|
||
.size(14)
|
||
.color(color_subtext),
|
||
]
|
||
.spacing(5)
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
if state.recording {
|
||
let rec_secs = state
|
||
.recording_started
|
||
.map(|t| t.elapsed().as_secs())
|
||
.unwrap_or(0);
|
||
container(
|
||
row![
|
||
icon(IconKind::Record, 12.0, color_red),
|
||
text(format!("REC {}", format_duration(rec_secs)))
|
||
.size(13)
|
||
.color(color_red),
|
||
]
|
||
.spacing(5)
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
)
|
||
.style(c_style(color_crust, color_red, 6.0))
|
||
.padding(6)
|
||
} else {
|
||
container(text("")).padding(0)
|
||
},
|
||
horizontal_space(),
|
||
text(format!("My ID: {}", short_id(&state.self_id)))
|
||
.size(14)
|
||
.color(color_subtext),
|
||
row![
|
||
text("Ticket:").size(12).color(color_subtext),
|
||
locked_value(&state.ticket, AppMessage::Noop)
|
||
.width(iced::Length::Fixed(260.0))
|
||
.size(12)
|
||
.padding(4),
|
||
]
|
||
.spacing(6)
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
button(
|
||
row![
|
||
icon(IconKind::Copy, 14.0, color_text),
|
||
text("Copy Ticket").size(12),
|
||
]
|
||
.spacing(5)
|
||
.align_y(iced::alignment::Vertical::Center)
|
||
)
|
||
.on_press(AppMessage::CopyToClipboard)
|
||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||
.padding(6),
|
||
// Drawer layout: a chat toggle (the drawer is collapsed by default).
|
||
{
|
||
let el: Element<'_, AppMessage> = if state.config.room_layout == RoomLayout::Drawer
|
||
{
|
||
let (lbl, bg, fg) = if state.drawer_chat_open {
|
||
("Hide chat", color_blue, color_crust)
|
||
} else {
|
||
("Chat", color_surface, color_text)
|
||
};
|
||
button(
|
||
row![icon(IconKind::Chat, 14.0, fg), text(lbl).size(12),]
|
||
.spacing(5)
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
)
|
||
.on_press(AppMessage::ToggleDrawerChat)
|
||
.style(b_style(bg, color_blue, fg, 6.0))
|
||
.padding(6)
|
||
.into()
|
||
} else {
|
||
iced::widget::Space::new().width(0.0).height(0.0).into()
|
||
};
|
||
el
|
||
}
|
||
]
|
||
.spacing(16)
|
||
.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 — name/status row plus a live mic meter so you can
|
||
// confirm you're being picked up (and see mute / PTT / gate at work).
|
||
let transmitting = !state.is_muted && (!state.ptt_enabled || state.ptt_active);
|
||
let self_mic_color = if transmitting {
|
||
color_green
|
||
} else {
|
||
color_subtext
|
||
};
|
||
let self_card = container(
|
||
column![
|
||
row![
|
||
avatar_view(&state.config.avatar, &state.name, &state.self_id, 34.0),
|
||
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)
|
||
}
|
||
]
|
||
.spacing(10)
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
// Our own detected game (W17/W18). When we're broadcasting it, it
|
||
// matches what peers see; otherwise it's marked "not shared".
|
||
{
|
||
let el: Element<'_, AppMessage> =
|
||
match state.current_game.as_ref().and_then(|g| g.name.as_deref()) {
|
||
Some(name) if state.config.game_presence_enabled => {
|
||
text(format!("Playing {name}")).size(11).color(color_blue).into()
|
||
}
|
||
Some(name) => text(format!("Playing {name} (not shared)"))
|
||
.size(11)
|
||
.color(color_subtext)
|
||
.into(),
|
||
None => text("").into(),
|
||
};
|
||
el
|
||
},
|
||
// Live "you're sharing" badge — only present while sharing. When
|
||
// sharing a specific app whose audio has dropped (A23 strict
|
||
// mode), a warning line is added: viewers hear silence, not the
|
||
// call, until that app plays again.
|
||
{
|
||
let el: Element<'_, AppMessage> = if state.self_sharing {
|
||
let badge = row![
|
||
icon(IconKind::Live, 14.0, color_red),
|
||
text("Sharing your screen").size(13).color(color_red),
|
||
]
|
||
.spacing(6)
|
||
.align_y(iced::alignment::Vertical::Center);
|
||
if state.share_audio_dropped {
|
||
column![
|
||
badge,
|
||
text(
|
||
"⚠ Shared app isn't sending audio — viewers hear silence until it plays"
|
||
)
|
||
.size(11)
|
||
.color(color_yellow),
|
||
]
|
||
.spacing(3)
|
||
.into()
|
||
} else {
|
||
badge.into()
|
||
}
|
||
} else {
|
||
iced::widget::Space::new().width(0.0).height(0.0).into()
|
||
};
|
||
el
|
||
},
|
||
progress_bar(0.0..=0.3, state.mic_level)
|
||
.girth(8.0)
|
||
.style(move |_t: &Theme| iced::widget::progress_bar::Style {
|
||
background: Background::Color(color_crust),
|
||
bar: Background::Color(self_mic_color),
|
||
border: Border { color: color_surface, width: 1.0, radius: 4.0.into() },
|
||
}),
|
||
].spacing(8)
|
||
)
|
||
.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_connecting = state.connecting.contains(peer_id);
|
||
let is_speaking = !is_connecting && level > 0.01;
|
||
|
||
let (ind_label, ind_color): (&str, Color) = if is_connecting {
|
||
let label = if state.ever_connected.contains(peer_id) {
|
||
"[Reconnecting…]"
|
||
} else {
|
||
"[Connecting…]"
|
||
};
|
||
(label, color_yellow)
|
||
} else if peer.is_muted {
|
||
("[Muted]", color_red)
|
||
} else if is_speaking {
|
||
("[Speaking]", color_green)
|
||
} else {
|
||
("[Idle]", color_subtext)
|
||
};
|
||
// Fixed-width, right-aligned slot so the label changing (e.g. Idle→
|
||
// Speaking) doesn't reflow the row and shift the mute button (A10).
|
||
// Width covers the longest label, "[Reconnecting…]".
|
||
let indicator = container(text(ind_label).size(14).color(ind_color))
|
||
.width(iced::Length::Fixed(124.0))
|
||
.align_x(iced::alignment::Horizontal::Right);
|
||
|
||
let peer_id_clone = *peer_id;
|
||
let is_locally_muted = state.locally_muted.contains(peer_id);
|
||
|
||
// Local-mute toggle (silences this peer for us only).
|
||
let (mute_kind, mute_bg, mute_fg) = if is_locally_muted {
|
||
(IconKind::SpeakerOff, color_red, color_crust)
|
||
} else {
|
||
(IconKind::Speaker, color_surface, color_text)
|
||
};
|
||
let mute_btn = button(icon(mute_kind, 16.0, mute_fg))
|
||
.on_press(AppMessage::TogglePeerMute(peer_id_clone))
|
||
.style(b_style(mute_bg, color_blue, mute_fg, 6.0))
|
||
.padding(6);
|
||
|
||
// Screen-share "Live" badge + Watch button when this peer is sharing.
|
||
// Watch is enabled only if pixelpass is installed locally.
|
||
let share_el: Element<'_, AppMessage> = if let Some(ticket) = peer.sharing.clone() {
|
||
let mut watch_btn = button(
|
||
row![
|
||
icon(IconKind::Eye, 14.0, color_crust),
|
||
text("Watch").size(13),
|
||
]
|
||
.spacing(5)
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
)
|
||
.style(b_style(color_blue, color_lavender, color_crust, 6.0))
|
||
.padding(6);
|
||
// Always actionable: opens the pixelpass explainer if it's missing,
|
||
// otherwise launches the viewer (A11 — consistent with Share Screen).
|
||
watch_btn = watch_btn.on_press(if state.pixelpass_available {
|
||
AppMessage::WatchShare(ticket)
|
||
} else {
|
||
AppMessage::OpenPixelpassHelp
|
||
});
|
||
row![
|
||
row![
|
||
icon(IconKind::Live, 13.0, color_red),
|
||
text("Live").size(13).color(color_red),
|
||
]
|
||
.spacing(5)
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
watch_btn,
|
||
]
|
||
.spacing(6)
|
||
.align_y(iced::alignment::Vertical::Center)
|
||
.into()
|
||
} else {
|
||
iced::widget::Space::new().width(0.0).height(0.0).into()
|
||
};
|
||
|
||
// VU meter colour: dim when locally muted (you don't hear them),
|
||
// green while speaking, faint otherwise.
|
||
let vu_color = if is_locally_muted {
|
||
color_subtext
|
||
} else if is_speaking {
|
||
color_green
|
||
} else {
|
||
color_surface
|
||
};
|
||
|
||
// Add-friend affordance (W7): a star you can click to friend this
|
||
// co-participant; a filled gold star (non-interactive) once they're a
|
||
// friend. Hidden while their friend state is read-only-degraded.
|
||
let add_friend_el: Element<'_, AppMessage> = if state.friends_read_only {
|
||
iced::widget::Space::new().width(0.0).height(0.0).into()
|
||
} else if state.friends.contains(peer_id) {
|
||
tooltip(
|
||
text("★").size(16).color(color_yellow),
|
||
container(text("In your friends list").size(11).color(color_text))
|
||
.padding(8)
|
||
.style(c_style(color_crust, color_surface, 6.0)),
|
||
iced::widget::tooltip::Position::Bottom,
|
||
)
|
||
.gap(6)
|
||
.into()
|
||
} else {
|
||
tooltip(
|
||
button(text("☆").size(16).color(color_subtext))
|
||
.on_press(AppMessage::AddFriendFromRoom(peer_id_clone))
|
||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||
.padding([2, 6]),
|
||
container(text("Add to your friends list").size(11).color(color_text))
|
||
.padding(8)
|
||
.style(c_style(color_crust, color_surface, 6.0)),
|
||
iced::widget::tooltip::Position::Bottom,
|
||
)
|
||
.gap(6)
|
||
.into()
|
||
};
|
||
|
||
let mut card_content = column![
|
||
row![
|
||
avatar_view(&peer.avatar, &peer.name, &peer_id.to_string(), 38.0),
|
||
{
|
||
// Name + id, plus a "Playing <game>" line when the peer is
|
||
// broadcasting a game (game presence, W17).
|
||
let mut name_col = column![
|
||
text(&peer.name).size(16).color(color_text),
|
||
text(format!("ID: {}", short_id(&peer_id.to_string())))
|
||
.size(11)
|
||
.color(color_subtext),
|
||
];
|
||
if let Some(game) = &peer.game {
|
||
name_col = name_col
|
||
.push(text(format!("Playing {game}")).size(11).color(color_blue));
|
||
}
|
||
name_col
|
||
},
|
||
add_friend_el,
|
||
horizontal_space(),
|
||
share_el,
|
||
mute_btn,
|
||
indicator
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
progress_bar(0.0..=0.3, level)
|
||
.girth(8.0)
|
||
.style(move |_t: &Theme| iced::widget::progress_bar::Style {
|
||
background: Background::Color(color_crust),
|
||
bar: Background::Color(vu_color),
|
||
border: Border {
|
||
color: color_surface,
|
||
width: 1.0,
|
||
radius: 4.0.into()
|
||
},
|
||
}),
|
||
]
|
||
.spacing(8);
|
||
|
||
// Peer volume slider
|
||
let current_vol = state
|
||
.config
|
||
.peer_volume
|
||
.get(&peer_id.to_string())
|
||
.copied()
|
||
.unwrap_or(1.0);
|
||
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)
|
||
})
|
||
.step(0.01)
|
||
.on_release(AppMessage::PersistConfig)
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
);
|
||
|
||
let peer_key = peer_id.to_string();
|
||
let current_pan = state.config.peer_pan.get(&peer_key).copied().unwrap_or(0.0);
|
||
card_content = card_content.push(
|
||
row![
|
||
text("Pan:").size(12).color(color_subtext),
|
||
container(text(pan_label(current_pan)).size(11).color(color_subtext))
|
||
.width(iced::Length::Fixed(58.0)),
|
||
slider(
|
||
-1.0..=1.0,
|
||
current_pan,
|
||
move |v| AppMessage::PeerPanChanged(peer_id_clone, v)
|
||
)
|
||
.step(0.05)
|
||
.on_release(AppMessage::PersistConfig),
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
);
|
||
|
||
// Peer noise gate: suppress this peer's background noise on our end.
|
||
// Threshold is normalized RMS on the same 0..METER_MAX scale as the
|
||
// mic gate; 0 = off.
|
||
let current_gate = state
|
||
.config
|
||
.peer_gate
|
||
.get(&peer_key)
|
||
.copied()
|
||
.unwrap_or(0.0);
|
||
let gate_label = if current_gate <= 0.0 {
|
||
"Off".to_string()
|
||
} else {
|
||
format!(
|
||
"{:.0}%",
|
||
(current_gate / METER_MAX * 100.0).clamp(0.0, 100.0)
|
||
)
|
||
};
|
||
card_content = card_content.push(
|
||
row![
|
||
text("Gate:").size(12).color(color_subtext),
|
||
container(text(gate_label).size(11).color(color_subtext))
|
||
.width(iced::Length::Fixed(58.0)),
|
||
slider(0.0..=METER_MAX, current_gate, move |v| {
|
||
AppMessage::PeerGateChanged(peer_id_clone, v)
|
||
})
|
||
.step(0.001)
|
||
.on_release(AppMessage::PersistConfig),
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
);
|
||
|
||
let eq = peer_eq_settings(&state.config, peer_id);
|
||
let eq_row =
|
||
|label: &'static str, band: EqBand, value: f32| -> Element<'_, AppMessage> {
|
||
row![
|
||
container(
|
||
text(format!("{label} {value:+.1} dB"))
|
||
.size(11)
|
||
.color(color_subtext)
|
||
)
|
||
.width(iced::Length::Fixed(86.0)),
|
||
slider(EQ_GAIN_DB_MIN..=EQ_GAIN_DB_MAX, value, move |v| {
|
||
AppMessage::PeerEqChanged(peer_id_clone, band, v)
|
||
})
|
||
.step(0.5)
|
||
.on_release(AppMessage::PersistConfig),
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::alignment::Vertical::Center)
|
||
.into()
|
||
};
|
||
card_content = card_content.push(
|
||
column![
|
||
text("EQ").size(11).color(color_subtext),
|
||
eq_row("Low", EqBand::Low, eq.low_gain_db),
|
||
eq_row("Mid", EqBand::Mid, eq.mid_gain_db),
|
||
eq_row("High", EqBand::High, eq.high_gain_db),
|
||
]
|
||
.spacing(4),
|
||
);
|
||
|
||
let card = container(card_content)
|
||
.style(c_style(
|
||
if is_speaking {
|
||
color_base
|
||
} else {
|
||
color_mantle
|
||
},
|
||
if is_connecting {
|
||
color_yellow
|
||
} else 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)
|
||
.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 mute_kind = if state.is_muted {
|
||
IconKind::MicOff
|
||
} else {
|
||
IconKind::Mic
|
||
};
|
||
let deafen_kind = if state.is_deafened {
|
||
IconKind::Deafen
|
||
} else {
|
||
IconKind::Headphones
|
||
};
|
||
let music_panel: Element<'_, AppMessage> = {
|
||
let music_status = status_snapshot(&state.music_status);
|
||
let music_playing = music_status.playing_id.is_some();
|
||
let play_label = if music_playing && music_status.paused {
|
||
"▶"
|
||
} else if music_playing {
|
||
"⏸"
|
||
} else {
|
||
"▶"
|
||
};
|
||
let personal_selected = state.music_tab == MusicTab::Personal;
|
||
let public_selected = state.music_tab == MusicTab::Public;
|
||
let tab_row = row![
|
||
button(
|
||
text("Personal")
|
||
.size(12)
|
||
.align_x(iced::alignment::Horizontal::Center)
|
||
)
|
||
.on_press(AppMessage::MusicSelectTab(MusicTab::Personal))
|
||
.style(b_style(
|
||
if personal_selected {
|
||
color_blue
|
||
} else {
|
||
color_surface
|
||
},
|
||
color_blue,
|
||
if personal_selected {
|
||
color_crust
|
||
} else {
|
||
color_text
|
||
},
|
||
6.0,
|
||
))
|
||
.padding(8)
|
||
.width(iced::Length::Fill),
|
||
button(
|
||
text("Public")
|
||
.size(12)
|
||
.align_x(iced::alignment::Horizontal::Center)
|
||
)
|
||
.on_press(AppMessage::MusicSelectTab(MusicTab::Public))
|
||
.style(b_style(
|
||
if public_selected {
|
||
color_blue
|
||
} else {
|
||
color_surface
|
||
},
|
||
color_blue,
|
||
if public_selected {
|
||
color_crust
|
||
} else {
|
||
color_text
|
||
},
|
||
6.0,
|
||
))
|
||
.padding(8)
|
||
.width(iced::Length::Fill),
|
||
]
|
||
.spacing(8);
|
||
|
||
let content: Element<'_, AppMessage> = match state.music_tab {
|
||
MusicTab::Personal => {
|
||
let mut tracks = Column::new().spacing(6);
|
||
if state.music_playlist.is_empty() {
|
||
tracks = tracks.push(
|
||
text("No tracks yet — Browse to add audio.")
|
||
.size(12)
|
||
.color(color_subtext),
|
||
);
|
||
} else {
|
||
let last = state.music_playlist.len().saturating_sub(1);
|
||
for (i, track) in state.music_playlist.iter().enumerate() {
|
||
let selected = state.music_current == Some(i);
|
||
let up = {
|
||
let btn = button(text("▲").size(13))
|
||
.style(b_style(color_surface, color_overlay, color_text, 6.0))
|
||
.padding(6);
|
||
if i > 0 {
|
||
btn.on_press(AppMessage::MusicMoveUp(i))
|
||
} else {
|
||
btn
|
||
}
|
||
};
|
||
let down = {
|
||
let btn = button(text("▼").size(13))
|
||
.style(b_style(color_surface, color_overlay, color_text, 6.0))
|
||
.padding(6);
|
||
if i < last {
|
||
btn.on_press(AppMessage::MusicMoveDown(i))
|
||
} else {
|
||
btn
|
||
}
|
||
};
|
||
tracks = tracks.push(
|
||
row![
|
||
button(text(&track.name).size(13))
|
||
.on_press(AppMessage::MusicPlayIndex(i))
|
||
.style(b_style(
|
||
if selected { color_blue } else { color_surface },
|
||
color_blue,
|
||
if selected { color_crust } else { color_text },
|
||
6.0,
|
||
))
|
||
.padding(6)
|
||
.width(iced::Length::Fill),
|
||
up,
|
||
down,
|
||
button(text("×").size(13))
|
||
.on_press(AppMessage::MusicRemove(i))
|
||
.style(b_style(
|
||
color_surface,
|
||
color_overlay,
|
||
color_text,
|
||
6.0
|
||
))
|
||
.padding(6),
|
||
]
|
||
.spacing(6)
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
);
|
||
}
|
||
}
|
||
let elapsed = format_clip_time(music_status.position);
|
||
let duration = music_status
|
||
.total
|
||
.map(format_clip_time)
|
||
.unwrap_or_else(|| "--:--".to_string());
|
||
column![
|
||
row![
|
||
button(text("|◀").size(13))
|
||
.on_press(AppMessage::MusicPrev)
|
||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||
.padding(7),
|
||
button(text(play_label).size(13))
|
||
.on_press(AppMessage::MusicPlayPause)
|
||
.style(b_style(color_blue, color_lavender, color_crust, 6.0))
|
||
.padding(7),
|
||
button(text("▶|").size(13))
|
||
.on_press(AppMessage::MusicNext)
|
||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||
.padding(7),
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
slider(
|
||
0.0..=1.0,
|
||
clip_progress(music_status.position, music_status.total),
|
||
AppMessage::MusicSeek,
|
||
)
|
||
.step(0.001),
|
||
text(format!("{elapsed} / {duration}"))
|
||
.size(11)
|
||
.color(color_subtext),
|
||
text("Music volume").size(11).color(color_subtext),
|
||
slider(
|
||
0.0..=2.0,
|
||
state.config.music_volume,
|
||
AppMessage::MusicSetVolume
|
||
)
|
||
.step(0.01),
|
||
button(
|
||
text("Browse")
|
||
.size(12)
|
||
.align_x(iced::alignment::Horizontal::Center)
|
||
)
|
||
.on_press(AppMessage::MusicBrowse)
|
||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||
.padding(8)
|
||
.width(iced::Length::Fill),
|
||
checkbox(state.music_broadcasting)
|
||
.label("Let others tune in")
|
||
.text_size(12)
|
||
.on_toggle(AppMessage::MusicToggleBroadcast),
|
||
scrollable(tracks)
|
||
.height(iced::Length::Fixed(160.0))
|
||
.width(iced::Length::Fill),
|
||
]
|
||
.spacing(8)
|
||
.into()
|
||
}
|
||
MusicTab::Public => {
|
||
let mut broadcast_rows = Column::new().spacing(6);
|
||
let self_id = state.self_id.parse::<EndpointId>().ok();
|
||
let mut count = 0usize;
|
||
for (id, peer) in &state.peers {
|
||
if Some(*id) == self_id {
|
||
continue;
|
||
}
|
||
let Some(music) = &peer.music else {
|
||
continue;
|
||
};
|
||
count += 1;
|
||
broadcast_rows = broadcast_rows.push(
|
||
row![
|
||
column![
|
||
text(&peer.name).size(12).color(color_text),
|
||
text(&music.name).size(11).color(color_subtext),
|
||
]
|
||
.spacing(2)
|
||
.width(iced::Length::Fill),
|
||
button(text("Listen").size(12))
|
||
.on_press(AppMessage::MusicListen(*id))
|
||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||
.padding(6),
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
);
|
||
}
|
||
if count == 0 {
|
||
broadcast_rows = broadcast_rows.push(
|
||
text("No one is broadcasting.")
|
||
.size(12)
|
||
.color(color_subtext),
|
||
);
|
||
}
|
||
|
||
let listen_block: Element<'_, AppMessage> = if let Some(peer) =
|
||
state.music_listening_to
|
||
{
|
||
let name = state
|
||
.peers
|
||
.get(&peer)
|
||
.map(|p| p.name.clone())
|
||
.unwrap_or_else(|| "source".to_string());
|
||
let elapsed = format_clip_time(music_status.position);
|
||
let duration = music_status
|
||
.total
|
||
.map(format_clip_time)
|
||
.unwrap_or_else(|| "--:--".to_string());
|
||
column![
|
||
row![
|
||
text(format!("▶ Listening to {name}"))
|
||
.size(12)
|
||
.color(color_blue)
|
||
.width(iced::Length::Fill),
|
||
button(text("Stop").size(12))
|
||
.on_press(AppMessage::MusicStopListen)
|
||
.style(b_style(color_surface, color_maroon, color_text, 6.0))
|
||
.padding(6),
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
text(format!("{elapsed} / {duration}"))
|
||
.size(11)
|
||
.color(color_subtext),
|
||
text("Listen volume").size(11).color(color_subtext),
|
||
slider(
|
||
0.0..=2.0,
|
||
effective_music_volume(state),
|
||
AppMessage::MusicSetSourceVolume
|
||
)
|
||
.step(0.01),
|
||
]
|
||
.spacing(8)
|
||
.into()
|
||
} else {
|
||
column![].into()
|
||
};
|
||
|
||
column![
|
||
listen_block,
|
||
text("Broadcasting now").size(12).color(color_blue),
|
||
scrollable(broadcast_rows)
|
||
.height(iced::Length::Fixed(160.0))
|
||
.width(iced::Length::Fill),
|
||
]
|
||
.spacing(8)
|
||
.into()
|
||
}
|
||
};
|
||
|
||
container(column![tab_row, content,].spacing(10))
|
||
.style(c_style(color_mantle, color_surface, 6.0))
|
||
.padding(10)
|
||
.width(iced::Length::Fill)
|
||
.into()
|
||
};
|
||
let music_bar_status = status_snapshot(&state.music_status);
|
||
let music_bar_playing = music_bar_status.playing_id.is_some();
|
||
let music_bar_play_label = if music_bar_playing && music_bar_status.paused {
|
||
"▶"
|
||
} else if music_bar_playing {
|
||
"⏸"
|
||
} else {
|
||
"▶"
|
||
};
|
||
let listening_peer_name = state
|
||
.music_listening_to
|
||
.and_then(|peer| state.peers.get(&peer).map(|p| p.name.as_str()));
|
||
let current_track_name = state
|
||
.music_current
|
||
.and_then(|i| state.music_playlist.get(i).map(|track| track.name.as_str()));
|
||
let elapsed = format_clip_time(music_bar_status.position);
|
||
let duration = music_bar_status
|
||
.total
|
||
.map(format_clip_time)
|
||
.unwrap_or_else(|| "--:--".to_string());
|
||
let player_bar = container(
|
||
row![
|
||
container(
|
||
row![
|
||
text("♪").size(18).color(color_blue),
|
||
text(now_playing_label(listening_peer_name, current_track_name))
|
||
.size(13)
|
||
.color(color_text),
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::alignment::Vertical::Center)
|
||
)
|
||
.width(iced::Length::Fill),
|
||
row![
|
||
button(text("|◀").size(13))
|
||
.on_press(AppMessage::MusicPrev)
|
||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||
.padding(7),
|
||
button(text(music_bar_play_label).size(13))
|
||
.on_press(AppMessage::MusicPlayPause)
|
||
.style(b_style(color_blue, color_lavender, color_crust, 6.0))
|
||
.padding(7),
|
||
button(text("▶|").size(13))
|
||
.on_press(AppMessage::MusicNext)
|
||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||
.padding(7),
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
container(
|
||
row![
|
||
text(format!("{elapsed} / {duration}"))
|
||
.size(11)
|
||
.color(color_subtext),
|
||
button(
|
||
text(if state.playlist_drawer_open {
|
||
"⤡"
|
||
} else {
|
||
"⤢"
|
||
})
|
||
.size(13)
|
||
)
|
||
.on_press(AppMessage::TogglePlaylistDrawer)
|
||
.style(b_style(
|
||
if state.playlist_drawer_open {
|
||
color_blue
|
||
} else {
|
||
color_surface
|
||
},
|
||
color_blue,
|
||
if state.playlist_drawer_open {
|
||
color_crust
|
||
} else {
|
||
color_text
|
||
},
|
||
6.0,
|
||
))
|
||
.padding(7),
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::alignment::Vertical::Center)
|
||
)
|
||
.width(iced::Length::Fill)
|
||
.align_x(iced::alignment::Horizontal::Right),
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
)
|
||
.style(c_style(color_mantle, color_surface, 8.0))
|
||
.padding(10)
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fixed(56.0));
|
||
let ctrl_buttons = column![
|
||
button(btn_content(mute_kind, mute_text, mute_fg))
|
||
.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(btn_content(deafen_kind, deafen_text, deafen_fg))
|
||
.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 {
|
||
let ptt_binding = if state.hotkey_capture == Some(HotkeyAction::PushToTalk) {
|
||
"Press a key...".to_string()
|
||
} else {
|
||
format_binding(state.config.hotkeys.binding(HotkeyAction::PushToTalk))
|
||
};
|
||
column![
|
||
text(format!("PTT key: {ptt_binding}"))
|
||
.size(14)
|
||
.color(color_subtext),
|
||
button(
|
||
text("Set PTT Key")
|
||
.size(12)
|
||
.align_x(iced::alignment::Horizontal::Center)
|
||
)
|
||
.on_press(AppMessage::StartHotkeyCapture(HotkeyAction::PushToTalk))
|
||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||
.padding(8)
|
||
.width(iced::Length::Fill)
|
||
]
|
||
.spacing(8)
|
||
} else {
|
||
column![]
|
||
},
|
||
vertical_space(20.0),
|
||
{
|
||
// Echo cancellation is wired at join time on Linux; other
|
||
// targets show an inert status row instead of a dead toggle.
|
||
let control: Element<'_, AppMessage> = {
|
||
#[cfg(target_os = "linux")]
|
||
{
|
||
tooltip(
|
||
checkbox(state.config.echo_cancellation_enabled)
|
||
.label("Echo cancellation")
|
||
.on_toggle(AppMessage::ToggleEchoCancellation),
|
||
container(
|
||
text("Cancels speaker echo + suppresses noise. Applies on your next room join.")
|
||
.size(11)
|
||
.color(color_text),
|
||
)
|
||
.padding(8)
|
||
.max_width(260.0)
|
||
.style(c_style(color_crust, color_surface, 6.0)),
|
||
iced::widget::tooltip::Position::Top,
|
||
)
|
||
.gap(8)
|
||
.into()
|
||
}
|
||
#[cfg(not(target_os = "linux"))]
|
||
{
|
||
column![
|
||
checkbox(false).label("Echo cancellation"),
|
||
text("Not available on Windows yet.")
|
||
.size(11)
|
||
.color(color_subtext),
|
||
]
|
||
.spacing(4)
|
||
.into()
|
||
}
|
||
};
|
||
control
|
||
},
|
||
vertical_space(20.0),
|
||
{
|
||
let (rec_kind, rec_label, rec_bg, rec_hover, rec_fg) = if state.recording {
|
||
(
|
||
IconKind::Stop,
|
||
"Stop Recording",
|
||
color_red,
|
||
color_maroon,
|
||
color_crust,
|
||
)
|
||
} else {
|
||
(
|
||
IconKind::Record,
|
||
"Record Call",
|
||
color_surface,
|
||
color_blue,
|
||
color_text,
|
||
)
|
||
};
|
||
button(btn_content(rec_kind, rec_label, rec_fg))
|
||
.on_press(AppMessage::ToggleRecording)
|
||
.style(b_style(rec_bg, rec_hover, rec_fg, 8.0))
|
||
.padding(14)
|
||
.width(iced::Length::Fill)
|
||
},
|
||
vertical_space(20.0),
|
||
{
|
||
// Screen share. Always enabled and labelled "Share Screen": if the
|
||
// optional pixelpass companion isn't installed, clicking opens a
|
||
// short how-to-install explainer instead of being a dead disabled
|
||
// button (A11) — so it never reads as a broken in-app feature.
|
||
let (share_kind, share_label, share_bg, share_hover, share_fg) =
|
||
if state.self_sharing {
|
||
(
|
||
IconKind::Stop,
|
||
"Stop Sharing",
|
||
color_red,
|
||
color_maroon,
|
||
color_crust,
|
||
)
|
||
} else {
|
||
(
|
||
IconKind::Monitor,
|
||
"Share Screen",
|
||
color_surface,
|
||
color_blue,
|
||
color_text,
|
||
)
|
||
};
|
||
let share_press = if state.pixelpass_available {
|
||
AppMessage::ToggleScreenShare
|
||
} else {
|
||
AppMessage::OpenPixelpassHelp
|
||
};
|
||
let share_button = button(btn_content(share_kind, share_label, share_fg))
|
||
.on_press(share_press)
|
||
.style(b_style(share_bg, share_hover, share_fg, 8.0))
|
||
.padding(14)
|
||
.width(iced::Length::Fill);
|
||
let share_control: Element<'_, AppMessage> = if state.self_sharing {
|
||
share_button.into()
|
||
} else {
|
||
row![
|
||
share_button,
|
||
pick_list(
|
||
&ShareQuality::ALL[..],
|
||
Some(state.share_quality_selection),
|
||
AppMessage::SelectShareQualityOverride,
|
||
)
|
||
.width(iced::Length::Fixed(112.0)),
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::alignment::Vertical::Center)
|
||
.width(iced::Length::Fill)
|
||
.into()
|
||
};
|
||
share_control
|
||
},
|
||
];
|
||
|
||
// Leave is the exit control, so it's pinned below the scrolling controls
|
||
// (built separately, outside `ctrl_buttons`) — see the panel assembly (A12).
|
||
let leave_btn = button(btn_content(IconKind::Leave, "Leave Room", color_crust))
|
||
.on_press(AppMessage::LeavePressed)
|
||
.style(b_style(color_red, color_maroon, color_crust, 8.0))
|
||
.padding(14)
|
||
.width(iced::Length::Fill);
|
||
|
||
// Controls panel — width is set per layout below. The controls SCROLL when
|
||
// the window is too short, and Leave stays pinned at the bottom so the exit
|
||
// control is always reachable instead of being clipped off-screen (A12).
|
||
let control_panel = container(column![
|
||
text("Controls").size(18).color(color_blue),
|
||
vertical_space(15.0),
|
||
scrollable(ctrl_buttons)
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fill),
|
||
vertical_space(12.0),
|
||
leave_btn,
|
||
vertical_space(8.0),
|
||
text(&state.status_message).size(12).color(color_subtext)
|
||
])
|
||
.style(c_style(color_mantle, Color::TRANSPARENT, 0.0))
|
||
.padding(15)
|
||
.height(iced::Length::Fill);
|
||
|
||
// Reusable chat body (title + bottom-anchored scrollback + input row),
|
||
// wrapped differently by each layout.
|
||
let mut chat_col = Column::new().spacing(4).width(iced::Length::Fill);
|
||
if state.chat_messages.is_empty() {
|
||
chat_col = chat_col.push(
|
||
text("No messages yet — say hi to the room.")
|
||
.size(12)
|
||
.color(color_subtext),
|
||
);
|
||
} else {
|
||
let clip_status = status_snapshot(&state.clip_status);
|
||
for m in &state.chat_messages {
|
||
let name_color = if m.mine { color_green } else { color_lavender };
|
||
// Split the (already-sanitized) message into text + URL spans so
|
||
// links render clickable and open in the system browser (A13).
|
||
let spans: Vec<_> = crate::sanitize::linkify(&m.text)
|
||
.into_iter()
|
||
.map(|seg| match seg {
|
||
crate::sanitize::Segment::Text(t) => span(t).size(13).color(color_text),
|
||
crate::sanitize::Segment::Link(u) => {
|
||
span(u.clone()).size(13).color(color_blue).link(u)
|
||
}
|
||
})
|
||
.collect();
|
||
let body = selectable_rich_text(spans)
|
||
.on_link_click(AppMessage::OpenUrl)
|
||
.selection_color(color_blue)
|
||
.width(iced::Length::Fill);
|
||
// Small avatar keyed on the sender's id (falls back to name); the
|
||
// " (You)" suffix on our own echoes is stripped for clean initials.
|
||
// Resolve the sender's chosen avatar: our own from config, a peer's
|
||
// from their current presence (looked up by id), else monogram.
|
||
let av_key = m.from.as_deref().unwrap_or(m.name.as_str());
|
||
let av_name = m.name.split(" (").next().unwrap_or(m.name.as_str());
|
||
let av = if m.mine {
|
||
state.config.avatar.clone()
|
||
} else {
|
||
m.from
|
||
.as_deref()
|
||
.and_then(|f| {
|
||
state
|
||
.peers
|
||
.iter()
|
||
.find(|(k, _)| k.to_string() == f)
|
||
.map(|(_, v)| v.avatar.clone())
|
||
})
|
||
.unwrap_or_default()
|
||
};
|
||
chat_col = chat_col.push(
|
||
row![
|
||
avatar_view(&av, av_name, av_key, 22.0),
|
||
text(format!("{}:", m.name)).size(12).color(name_color),
|
||
body,
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::alignment::Vertical::Top),
|
||
);
|
||
// Attachment row (indented under the message), if any.
|
||
if let Some(att) = &m.attachment {
|
||
// This line's cache key is (its author, the attachment id).
|
||
// `None` only for a system line or an unparseable author.
|
||
let key: Option<AttachmentKey> = m
|
||
.from
|
||
.as_ref()
|
||
.and_then(|f| f.parse::<EndpointId>().ok())
|
||
.map(|eid| (eid, att.id));
|
||
let data = key.as_ref().and_then(|k| state.attachments.get(k));
|
||
let elem: Element<'_, AppMessage> = if let Some(AttachmentState::Failed(e)) =
|
||
data
|
||
{
|
||
text(format!("⚠ {} — {e}", att.name))
|
||
.size(12)
|
||
.color(color_red)
|
||
.into()
|
||
} else if att.kind == crate::files::AttachmentKind::Image {
|
||
match key.as_ref().and_then(|k| state.attachments.handle(k)) {
|
||
Some(handle) => {
|
||
// Click an inline image to open it enlarged in the
|
||
// lightbox overlay (pointer cursor signals it's
|
||
// interactive). Only wired when we have a valid
|
||
// (author, id) key to look the handle back up.
|
||
let img = iced::widget::image(handle.clone())
|
||
.width(iced::Length::Fixed(260.0));
|
||
match key {
|
||
Some(k) => mouse_area(img)
|
||
.interaction(mouse::Interaction::Pointer)
|
||
.on_press(AppMessage::OpenImageLightbox(k))
|
||
.into(),
|
||
None => img.into(),
|
||
}
|
||
}
|
||
None => text(format!("🖼 {} — loading…", att.name))
|
||
.size(12)
|
||
.color(color_subtext)
|
||
.into(),
|
||
}
|
||
} else if crate::files::looks_like_audio_name(&att.name)
|
||
&& !state.invalid_audio.contains(&att.id)
|
||
{
|
||
let active = clip_status.playing_id == Some(att.id);
|
||
let loading = state.pending_plays.contains(&att.id)
|
||
&& !matches!(data, Some(AttachmentState::Ready(_)));
|
||
let position = if active {
|
||
clip_status.position
|
||
} else {
|
||
std::time::Duration::ZERO
|
||
};
|
||
let total = active.then_some(clip_status.total).flatten();
|
||
let play_button = if loading {
|
||
button(text("Loading…").size(12))
|
||
} else if active && clip_status.paused {
|
||
button(text("Play").size(12)).on_press(AppMessage::ResumeAudio)
|
||
} else if active {
|
||
button(text("Pause").size(12)).on_press(AppMessage::PauseAudio)
|
||
} else {
|
||
button(text("Play").size(12))
|
||
.on_press_maybe(key.map(AppMessage::PlayAudio))
|
||
}
|
||
.style(b_style(color_blue, color_lavender, color_crust, 6.0))
|
||
.padding(6);
|
||
let elapsed = format_clip_time(position);
|
||
let duration = total
|
||
.map(format_clip_time)
|
||
.unwrap_or_else(|| "--:--".to_string());
|
||
column![
|
||
row![
|
||
text(format!(
|
||
"{} ({})",
|
||
att.name,
|
||
crate::files::human_size(att.size)
|
||
))
|
||
.size(12)
|
||
.color(color_text),
|
||
button(
|
||
text(if matches!(data, Some(AttachmentState::Ready(_))) {
|
||
"Save"
|
||
} else {
|
||
"Download"
|
||
})
|
||
.size(12)
|
||
)
|
||
.on_press_maybe(key.map(AppMessage::SaveAttachment))
|
||
.style(b_style(color_surface, color_overlay, color_text, 6.0,))
|
||
.padding(6),
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
row![
|
||
play_button,
|
||
slider(
|
||
0.0..=1.0,
|
||
if active {
|
||
clip_progress(position, total)
|
||
} else {
|
||
0.0
|
||
},
|
||
move |fraction| AppMessage::SeekAudio(att.id, fraction),
|
||
)
|
||
.step(0.001)
|
||
.width(iced::Length::Fixed(180.0)),
|
||
text(format!("{elapsed} / {duration}"))
|
||
.size(11)
|
||
.color(color_subtext),
|
||
// Per-clip volume. In universal mode this shows
|
||
// and drives the shared level; otherwise it is
|
||
// this clip's own remembered level.
|
||
text("🔊").size(12).color(color_subtext),
|
||
slider(0.0..=2.0, effective_clip_volume(state, att.id), move |v| {
|
||
AppMessage::SetClipVolumeFor(att.id, v)
|
||
},)
|
||
.step(0.01)
|
||
.width(iced::Length::Fixed(80.0)),
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
]
|
||
.spacing(4)
|
||
.into()
|
||
} else {
|
||
let ready = matches!(data, Some(AttachmentState::Ready(_)));
|
||
let btn_label = if ready { "Save" } else { "Download" };
|
||
row![
|
||
text(format!(
|
||
"📎 {} ({})",
|
||
att.name,
|
||
crate::files::human_size(att.size)
|
||
))
|
||
.size(12)
|
||
.color(color_text),
|
||
button(text(btn_label).size(12))
|
||
.on_press_maybe(key.map(AppMessage::SaveAttachment))
|
||
.style(b_style(color_blue, color_lavender, color_crust, 6.0,))
|
||
.padding(6),
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::alignment::Vertical::Center)
|
||
.into()
|
||
};
|
||
chat_col = chat_col.push(
|
||
row![
|
||
iced::widget::Space::new().width(iced::Length::Fixed(30.0)),
|
||
elem
|
||
]
|
||
.spacing(0),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
let chat_scroll = scrollable(chat_col)
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fill)
|
||
.anchor_bottom();
|
||
let chat_input_row = row![
|
||
button(text("📎").size(15))
|
||
.on_press(AppMessage::PickAttachmentFile)
|
||
.style(b_style(color_surface, color_overlay, color_text, 6.0))
|
||
.padding(8),
|
||
context_input("Message the room…", &state.chat_input)
|
||
.on_input(AppMessage::ChatInputChanged)
|
||
.on_submit(AppMessage::ChatSubmit)
|
||
.style(t_style)
|
||
.padding(8),
|
||
button(text("Send").size(13))
|
||
.on_press(AppMessage::ChatSubmit)
|
||
.style(b_style(color_blue, color_lavender, color_crust, 6.0))
|
||
.padding(8),
|
||
]
|
||
.spacing(8)
|
||
.align_y(iced::alignment::Vertical::Center);
|
||
// Chat header: title on the left, the universal-volume control on the
|
||
// right. The master slider drives every clip when "Universal" is checked;
|
||
// when unchecked each clip keeps its own level and this slider is inert.
|
||
let universal = state.config.clip_volume_universal;
|
||
let chat_header = row![
|
||
text("Chat").size(16).color(color_blue),
|
||
horizontal_space(),
|
||
checkbox(universal)
|
||
.label("Universal volume")
|
||
.text_size(12)
|
||
.on_toggle(AppMessage::ToggleUniversalClipVolume),
|
||
text("🔊").size(13).color(color_subtext),
|
||
slider(
|
||
0.0..=2.0,
|
||
state.config.clip_volume,
|
||
AppMessage::SetClipVolume
|
||
)
|
||
.step(0.01)
|
||
.width(iced::Length::Fixed(110.0)),
|
||
]
|
||
.spacing(10)
|
||
.align_y(iced::alignment::Vertical::Center);
|
||
let chat_inner = column![chat_header, chat_scroll, chat_input_row,].spacing(8);
|
||
|
||
// Divider constructors (fresh widget per call).
|
||
let vdiv = |kind| {
|
||
Canvas::new(Divider {
|
||
kind,
|
||
vertical: true,
|
||
line: color_surface,
|
||
grip: color_lavender,
|
||
})
|
||
.width(iced::Length::Fixed(DIVIDER_THICKNESS))
|
||
.height(iced::Length::Fill)
|
||
};
|
||
let hdiv = |kind| {
|
||
Canvas::new(Divider {
|
||
kind,
|
||
vertical: false,
|
||
line: color_surface,
|
||
grip: color_lavender,
|
||
})
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fixed(DIVIDER_THICKNESS))
|
||
};
|
||
|
||
let playlist_drawer = container(
|
||
column![
|
||
text("Playlist").size(18).color(color_blue),
|
||
vertical_space(8.0),
|
||
scrollable(music_panel)
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fill),
|
||
]
|
||
.spacing(0),
|
||
)
|
||
.style(c_style(color_mantle, color_surface, 8.0))
|
||
.padding(12)
|
||
.width(iced::Length::Fixed(state.config.playlist_drawer_width))
|
||
.height(iced::Length::Fill);
|
||
|
||
let drawer_open = state.playlist_drawer_open && state.config.show_player_bar;
|
||
let body_w = if drawer_open {
|
||
state.window_size.width - state.config.playlist_drawer_width - DIVIDER_THICKNESS
|
||
} else {
|
||
state.window_size.width
|
||
};
|
||
|
||
// Assemble the body per the chosen room layout. `chat_inner` is moved into
|
||
// exactly one arm (allowed across mutually-exclusive match arms).
|
||
let pw = state.config.participants_width;
|
||
let body: Element<'_, AppMessage> = match state.config.room_layout {
|
||
RoomLayout::BottomDock => {
|
||
// Cap Participants so the Fill Controls panel keeps its minimum.
|
||
let avail = body_w - 30.0;
|
||
let pwb =
|
||
pw.min((avail - CONTROLS_MIN_W - DIVIDER_THICKNESS).max(PARTICIPANTS_MIN_W));
|
||
let main = row![
|
||
peers_panel.width(iced::Length::Fixed(pwb)),
|
||
vdiv(DividerKind::Panels),
|
||
control_panel.width(iced::Length::Fill),
|
||
]
|
||
.spacing(0)
|
||
.height(iced::Length::Fill);
|
||
let chat = container(chat_inner)
|
||
.style(c_style(color_mantle, color_surface, 8.0))
|
||
.padding(12)
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fixed(state.config.chat_height));
|
||
column![main, hdiv(DividerKind::Chat), chat].into()
|
||
}
|
||
RoomLayout::ThreeColumn => {
|
||
let avail = body_w - 30.0; // outer padding
|
||
let pw3 = pw.min(
|
||
(avail - state.config.controls_width - CHAT_MIN_W - 2.0 * DIVIDER_THICKNESS)
|
||
.max(PARTICIPANTS_MIN_W),
|
||
);
|
||
let chat = container(chat_inner)
|
||
.style(c_style(color_mantle, color_surface, 8.0))
|
||
.padding(12)
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fill);
|
||
row![
|
||
peers_panel.width(iced::Length::Fixed(pw3)),
|
||
vdiv(DividerKind::Panels),
|
||
chat,
|
||
vdiv(DividerKind::Controls),
|
||
control_panel.width(iced::Length::Fixed(state.config.controls_width)),
|
||
]
|
||
.spacing(0)
|
||
.height(iced::Length::Fill)
|
||
.into()
|
||
}
|
||
RoomLayout::Drawer => {
|
||
let avail = body_w - 30.0;
|
||
if state.drawer_chat_open {
|
||
// Participants + Chat drawer are both fixed; cap Participants so
|
||
// the Fill Controls panel between them keeps its minimum.
|
||
let pwd = pw.min(
|
||
(avail
|
||
- state.config.chat_drawer_width
|
||
- CONTROLS_MIN_W
|
||
- 2.0 * DIVIDER_THICKNESS)
|
||
.max(PARTICIPANTS_MIN_W),
|
||
);
|
||
let chat = container(chat_inner)
|
||
.style(c_style(color_mantle, color_surface, 8.0))
|
||
.padding(12)
|
||
.width(iced::Length::Fixed(state.config.chat_drawer_width))
|
||
.height(iced::Length::Fill);
|
||
row![
|
||
peers_panel.width(iced::Length::Fixed(pwd)),
|
||
vdiv(DividerKind::Panels),
|
||
control_panel.width(iced::Length::Fill),
|
||
vdiv(DividerKind::ChatDrawer),
|
||
chat,
|
||
]
|
||
.spacing(0)
|
||
.height(iced::Length::Fill)
|
||
.into()
|
||
} else {
|
||
let pwd = pw
|
||
.min((avail - CONTROLS_MIN_W - DIVIDER_THICKNESS).max(PARTICIPANTS_MIN_W));
|
||
row![
|
||
peers_panel.width(iced::Length::Fixed(pwd)),
|
||
vdiv(DividerKind::Panels),
|
||
control_panel.width(iced::Length::Fill),
|
||
]
|
||
.spacing(0)
|
||
.height(iced::Length::Fill)
|
||
.into()
|
||
}
|
||
}
|
||
};
|
||
|
||
let clock_skew_banner: Element<'_, AppMessage> = if let Some(warning) =
|
||
state.clock_skew_warning
|
||
{
|
||
let direction = if warning.peer_ahead {
|
||
"ahead"
|
||
} else {
|
||
"behind"
|
||
};
|
||
let skew = format_clock_skew_duration(warning.skew_secs);
|
||
let copy = format!(
|
||
"A peer couldn't be seen - clocks are out of sync by ~{skew} (peer clock looks {direction}). Check your system clock (turn on automatic time sync)."
|
||
);
|
||
column![
|
||
vertical_space(10.0),
|
||
container(
|
||
row![
|
||
icon(IconKind::Clock, 16.0, color_yellow),
|
||
text(copy)
|
||
.size(12)
|
||
.color(color_text)
|
||
.width(iced::Length::Fill),
|
||
button(text("Dismiss").size(12))
|
||
.on_press(AppMessage::DismissClockSkewWarning)
|
||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||
.padding(6),
|
||
]
|
||
.spacing(10)
|
||
.align_y(iced::alignment::Vertical::Center)
|
||
)
|
||
.padding(10)
|
||
.width(iced::Length::Fill)
|
||
.style(move |_theme: &Theme| container::Style {
|
||
text_color: Some(color_text),
|
||
background: Some(Background::Color(Color {
|
||
a: 0.14,
|
||
..color_yellow
|
||
})),
|
||
border: Border {
|
||
color: color_yellow,
|
||
width: 1.0,
|
||
radius: 8.0.into()
|
||
},
|
||
..Default::default()
|
||
})
|
||
]
|
||
.into()
|
||
} else {
|
||
iced::widget::Space::new().width(0.0).height(0.0).into()
|
||
};
|
||
|
||
let main_area = column![
|
||
header_container,
|
||
clock_skew_banner,
|
||
vertical_space(12.0),
|
||
body,
|
||
];
|
||
let main_with_bar: Element<'_, AppMessage> = if state.config.show_player_bar {
|
||
column![main_area.height(iced::Length::Fill), player_bar].into()
|
||
} else {
|
||
main_area.into()
|
||
};
|
||
let inner: Element<'_, AppMessage> = if drawer_open {
|
||
row![
|
||
column![main_with_bar].width(iced::Length::Fill),
|
||
vdiv(DividerKind::PlaylistDrawer),
|
||
playlist_drawer,
|
||
]
|
||
.height(iced::Length::Fill)
|
||
.into()
|
||
} else {
|
||
main_with_bar
|
||
};
|
||
|
||
let room = container(column![top_bar, inner])
|
||
.padding(15)
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fill)
|
||
.style(c_style(root_bg, Color::TRANSPARENT, 0.0));
|
||
|
||
with_hotkey_info(
|
||
with_share_picker(
|
||
with_pixelpass_help(with_layout_picker(room.into(), state), state),
|
||
state,
|
||
),
|
||
state,
|
||
)
|
||
}
|
||
}
|
||
|
||
/// Full-scale of the meter's RMS axis. Speech RMS runs to ~0.3 normalized, so
|
||
/// this keeps a normal voice off the ceiling while leaving the gate threshold
|
||
/// (usually a few percent) draggable across the lower part of the bar.
|
||
const METER_MAX: f32 = 0.3;
|
||
|
||
/// A unified mic-level meter with a draggable noise-gate handle (Discord/OBS
|
||
/// style). The bar fills to the live mic level; the yellow handle marks the gate
|
||
/// threshold on the same axis and can be dragged to set it. The fill turns green
|
||
/// when the level is above the gate (transmitting), dim when below it (muted).
|
||
struct GateMeter {
|
||
level: f32,
|
||
threshold: f32,
|
||
track: Color,
|
||
border: Color,
|
||
fill_on: Color,
|
||
fill_off: Color,
|
||
/// Bright core of the gate handle.
|
||
handle: Color,
|
||
/// Dark outline behind the handle, so it stays visible over the green fill.
|
||
handle_edge: Color,
|
||
}
|
||
|
||
impl GateMeter {
|
||
/// Maps a cursor x (relative to the bar) to a gate threshold on the meter axis.
|
||
fn x_to_threshold(x: f32, width: f32) -> f32 {
|
||
(x / width.max(1.0)).clamp(0.0, 1.0) * METER_MAX
|
||
}
|
||
}
|
||
|
||
#[derive(Default)]
|
||
struct GateMeterState {
|
||
dragging: bool,
|
||
}
|
||
|
||
impl Program<AppMessage> for GateMeter {
|
||
type State = GateMeterState;
|
||
|
||
fn update(
|
||
&self,
|
||
state: &mut Self::State,
|
||
event: &Event,
|
||
bounds: Rectangle,
|
||
cursor: mouse::Cursor,
|
||
) -> Option<Action<AppMessage>> {
|
||
match event {
|
||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
|
||
if let Some(p) = cursor.position_in(bounds) {
|
||
state.dragging = true;
|
||
let t = Self::x_to_threshold(p.x, bounds.width);
|
||
return Some(Action::publish(AppMessage::NoiseGateDragging(t)).and_capture());
|
||
}
|
||
}
|
||
// Track moves anywhere on screen so the drag survives leaving the bar.
|
||
Event::Mouse(mouse::Event::CursorMoved { .. }) if state.dragging => {
|
||
if let Some(p) = cursor.position() {
|
||
let t = Self::x_to_threshold(p.x - bounds.x, bounds.width);
|
||
return Some(Action::publish(AppMessage::NoiseGateDragging(t)).and_capture());
|
||
}
|
||
}
|
||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) if state.dragging => {
|
||
state.dragging = false;
|
||
let x = cursor.position().map(|p| p.x - bounds.x).unwrap_or(0.0);
|
||
let t = Self::x_to_threshold(x, bounds.width);
|
||
// Persist the final value on release.
|
||
return Some(Action::publish(AppMessage::NoiseGateChanged(t)).and_capture());
|
||
}
|
||
_ => {}
|
||
}
|
||
None
|
||
}
|
||
|
||
fn draw(
|
||
&self,
|
||
_state: &Self::State,
|
||
renderer: &Renderer,
|
||
_theme: &Theme,
|
||
bounds: Rectangle,
|
||
_cursor: mouse::Cursor,
|
||
) -> Vec<Geometry> {
|
||
let mut frame = Frame::new(renderer, bounds.size());
|
||
let w = bounds.width;
|
||
let h = bounds.height;
|
||
|
||
// Track.
|
||
frame.fill_rectangle(Point::ORIGIN, Size::new(w, h), self.track);
|
||
|
||
// Level fill, coloured by whether we're above the gate.
|
||
let level_frac = (self.level / METER_MAX).clamp(0.0, 1.0);
|
||
let fill = if self.level >= self.threshold {
|
||
self.fill_on
|
||
} else {
|
||
self.fill_off
|
||
};
|
||
if level_frac > 0.0 {
|
||
frame.fill_rectangle(Point::ORIGIN, Size::new(w * level_frac, h), fill);
|
||
}
|
||
|
||
// Gate handle: a bright vertical line + grip caps, each backed by a dark
|
||
// edge so the handle stays legible even when the green level sweeps past it.
|
||
let thr_frac = (self.threshold / METER_MAX).clamp(0.0, 1.0);
|
||
let x = (w * thr_frac).clamp(3.0, (w - 3.0).max(3.0));
|
||
// Dark edge (slightly larger), then bright core.
|
||
frame.fill(
|
||
&Path::rectangle(Point::new(x - 3.0, 0.0), Size::new(6.0, h)),
|
||
self.handle_edge,
|
||
);
|
||
frame.fill(
|
||
&Path::rectangle(Point::new(x - 1.0, 0.0), Size::new(2.0, h)),
|
||
self.handle,
|
||
);
|
||
// Grip caps top and bottom.
|
||
frame.fill(
|
||
&Path::rectangle(Point::new(x - 5.5, 0.0), Size::new(11.0, 6.0)),
|
||
self.handle_edge,
|
||
);
|
||
frame.fill(
|
||
&Path::rectangle(Point::new(x - 4.0, 1.0), Size::new(8.0, 4.0)),
|
||
self.handle,
|
||
);
|
||
frame.fill(
|
||
&Path::rectangle(Point::new(x - 5.5, h - 6.0), Size::new(11.0, 6.0)),
|
||
self.handle_edge,
|
||
);
|
||
frame.fill(
|
||
&Path::rectangle(Point::new(x - 4.0, h - 5.0), Size::new(8.0, 4.0)),
|
||
self.handle,
|
||
);
|
||
|
||
// Border.
|
||
frame.stroke(
|
||
&Path::rectangle(Point::ORIGIN, Size::new(w, h)),
|
||
canvas::Stroke::default()
|
||
.with_color(self.border)
|
||
.with_width(1.0),
|
||
);
|
||
|
||
vec![frame.into_geometry()]
|
||
}
|
||
|
||
fn mouse_interaction(
|
||
&self,
|
||
state: &Self::State,
|
||
bounds: Rectangle,
|
||
cursor: mouse::Cursor,
|
||
) -> mouse::Interaction {
|
||
if state.dragging || cursor.is_over(bounds) {
|
||
mouse::Interaction::ResizingHorizontally
|
||
} else {
|
||
mouse::Interaction::default()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A draggable splitter between two panels. Reports drag motion along its axis as
|
||
/// `AppMessage::DividerDragged(kind, delta_px)`; the parent applies + clamps it.
|
||
/// `vertical` = a vertical bar dragged horizontally (resizes width); otherwise a
|
||
/// horizontal bar dragged vertically (resizes height). Modeled on [`GateMeter`]'s
|
||
/// drag handling: the drag is tracked off the global cursor so it survives the
|
||
/// pointer leaving the thin divider strip.
|
||
struct Divider {
|
||
kind: DividerKind,
|
||
/// True = vertical bar (horizontal drag); false = horizontal bar (vertical drag).
|
||
vertical: bool,
|
||
/// Centre line colour.
|
||
line: Color,
|
||
/// Grip-dot colour.
|
||
grip: Color,
|
||
}
|
||
|
||
/// Drag state: the last cursor coordinate along the drag axis while dragging.
|
||
#[derive(Default)]
|
||
struct DividerState {
|
||
last: Option<f32>,
|
||
}
|
||
|
||
impl Program<AppMessage> for Divider {
|
||
type State = DividerState;
|
||
|
||
fn update(
|
||
&self,
|
||
state: &mut Self::State,
|
||
event: &Event,
|
||
bounds: Rectangle,
|
||
cursor: mouse::Cursor,
|
||
) -> Option<Action<AppMessage>> {
|
||
// Cursor coordinate along the drag axis (x for a vertical bar, else y).
|
||
let axis = |p: Point| if self.vertical { p.x } else { p.y };
|
||
match event {
|
||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
|
||
if cursor.is_over(bounds)
|
||
&& let Some(p) = cursor.position()
|
||
{
|
||
// Record the absolute start coordinate; subsequent moves yield
|
||
// deltas. Capture with a zero-delta no-op.
|
||
state.last = Some(axis(p));
|
||
return Some(
|
||
Action::publish(AppMessage::DividerDragged(self.kind, 0.0)).and_capture(),
|
||
);
|
||
}
|
||
}
|
||
// Track globally so the drag continues past the thin strip's bounds.
|
||
Event::Mouse(mouse::Event::CursorMoved { .. }) if state.last.is_some() => {
|
||
if let Some(p) = cursor.position() {
|
||
let cur = axis(p);
|
||
let last = state.last.unwrap();
|
||
state.last = Some(cur);
|
||
return Some(
|
||
Action::publish(AppMessage::DividerDragged(self.kind, cur - last))
|
||
.and_capture(),
|
||
);
|
||
}
|
||
}
|
||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
|
||
if state.last.is_some() =>
|
||
{
|
||
state.last = None;
|
||
// Persist the final position once, on release (not per pixel).
|
||
return Some(Action::publish(AppMessage::PersistConfig).and_capture());
|
||
}
|
||
_ => {}
|
||
}
|
||
None
|
||
}
|
||
|
||
fn draw(
|
||
&self,
|
||
_state: &Self::State,
|
||
renderer: &Renderer,
|
||
_theme: &Theme,
|
||
bounds: Rectangle,
|
||
_cursor: mouse::Cursor,
|
||
) -> Vec<Geometry> {
|
||
let mut frame = Frame::new(renderer, bounds.size());
|
||
let w = bounds.width;
|
||
let h = bounds.height;
|
||
if self.vertical {
|
||
let x = w / 2.0;
|
||
frame.fill(
|
||
&Path::rectangle(Point::new(x - 1.0, 0.0), Size::new(2.0, h)),
|
||
self.line,
|
||
);
|
||
let cy = h / 2.0;
|
||
for i in -1..=1 {
|
||
let dy = cy + i as f32 * 6.0;
|
||
frame.fill(
|
||
&Path::rectangle(Point::new(x - 1.5, dy - 1.5), Size::new(3.0, 3.0)),
|
||
self.grip,
|
||
);
|
||
}
|
||
} else {
|
||
let y = h / 2.0;
|
||
frame.fill(
|
||
&Path::rectangle(Point::new(0.0, y - 1.0), Size::new(w, 2.0)),
|
||
self.line,
|
||
);
|
||
let cx = w / 2.0;
|
||
for i in -1..=1 {
|
||
let dx = cx + i as f32 * 6.0;
|
||
frame.fill(
|
||
&Path::rectangle(Point::new(dx - 1.5, y - 1.5), Size::new(3.0, 3.0)),
|
||
self.grip,
|
||
);
|
||
}
|
||
}
|
||
vec![frame.into_geometry()]
|
||
}
|
||
|
||
fn mouse_interaction(
|
||
&self,
|
||
state: &Self::State,
|
||
bounds: Rectangle,
|
||
cursor: mouse::Cursor,
|
||
) -> mouse::Interaction {
|
||
if state.last.is_some() || cursor.is_over(bounds) {
|
||
if self.vertical {
|
||
mouse::Interaction::ResizingHorizontally
|
||
} else {
|
||
mouse::Interaction::ResizingVertically
|
||
}
|
||
} else {
|
||
mouse::Interaction::default()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Wrap a base screen with the room-layout picker popup when it's open: a dimmed,
|
||
/// click-to-dismiss backdrop plus a centered gallery of clickable layout
|
||
/// thumbnails. Returns the base unchanged when the picker is closed. Used by both
|
||
/// the launch and in-call screens (the Settings screen shows thumbnails inline).
|
||
fn with_layout_picker<'a>(
|
||
base: Element<'a, AppMessage>,
|
||
state: &'a AppState,
|
||
) -> Element<'a, AppMessage> {
|
||
if !state.layout_picker_open {
|
||
return base;
|
||
}
|
||
let pal = state.config.theme.palette();
|
||
let crust = pal.crust;
|
||
let mantle = pal.mantle;
|
||
let base_c = pal.base;
|
||
let surface = pal.surface;
|
||
let overlay = pal.overlay;
|
||
let text_c = pal.text;
|
||
let subtext = pal.subtext;
|
||
let blue = pal.blue;
|
||
let green = pal.green;
|
||
|
||
let backdrop = mouse_area(
|
||
container(horizontal_space())
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fill)
|
||
.style(move |_t: &Theme| container::Style {
|
||
background: Some(Background::Color(Color { a: 0.55, ..crust })),
|
||
..Default::default()
|
||
}),
|
||
)
|
||
.on_press(AppMessage::CloseLayoutPicker);
|
||
|
||
let thumb = |layout: RoomLayout, label: &'static str| -> Element<'a, AppMessage> {
|
||
let selected = state.config.room_layout == layout;
|
||
let tile = Canvas::new(LayoutThumb {
|
||
layout,
|
||
selected,
|
||
base: base_c,
|
||
surface,
|
||
overlay,
|
||
border: if selected { blue } else { surface },
|
||
})
|
||
.width(iced::Length::Fixed(168.0))
|
||
.height(iced::Length::Fixed(112.0));
|
||
let btn = button(tile)
|
||
.on_press(AppMessage::SelectRoomLayout(layout))
|
||
.padding(2)
|
||
.style(move |_t: &Theme, status: button::Status| button::Style {
|
||
background: Some(Background::Color(match status {
|
||
button::Status::Hovered => surface,
|
||
_ => Color::TRANSPARENT,
|
||
})),
|
||
text_color: text_c,
|
||
border: Border {
|
||
color: Color::TRANSPARENT,
|
||
width: 0.0,
|
||
radius: 8.0.into(),
|
||
},
|
||
..Default::default()
|
||
});
|
||
let (lbl_color, marker): (Color, Element<'a, AppMessage>) = if selected {
|
||
(blue, text("● current").size(10).color(green).into())
|
||
} else {
|
||
(text_c, vertical_space(0.0).into())
|
||
};
|
||
column![btn, text(label).size(13).color(lbl_color), marker]
|
||
.spacing(4)
|
||
.align_x(iced::alignment::Horizontal::Center)
|
||
.into()
|
||
};
|
||
|
||
let gallery = container(
|
||
column![
|
||
row![
|
||
text("Choose room layout").size(16).color(blue),
|
||
horizontal_space(),
|
||
button(text("✕").size(16).color(subtext))
|
||
.on_press(AppMessage::CloseLayoutPicker)
|
||
.style(|_t: &Theme, _s: button::Status| button::Style {
|
||
background: None,
|
||
..Default::default()
|
||
})
|
||
.padding(2),
|
||
]
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
row![
|
||
thumb(RoomLayout::ThreeColumn, "3-Column"),
|
||
thumb(RoomLayout::BottomDock, "Bottom Dock"),
|
||
thumb(RoomLayout::Drawer, "Drawer"),
|
||
]
|
||
.spacing(20),
|
||
text("Click a layout to apply it instantly.")
|
||
.size(11)
|
||
.color(subtext),
|
||
]
|
||
.spacing(16),
|
||
)
|
||
.style(move |_t: &Theme| container::Style {
|
||
text_color: Some(text_c),
|
||
background: Some(Background::Color(mantle)),
|
||
border: Border {
|
||
color: surface,
|
||
width: 1.0,
|
||
radius: 12.0.into(),
|
||
},
|
||
..Default::default()
|
||
})
|
||
.padding(20)
|
||
.width(iced::Length::Fixed(600.0));
|
||
|
||
stack![
|
||
base,
|
||
backdrop,
|
||
container(gallery)
|
||
.center_x(iced::Length::Fill)
|
||
.center_y(iced::Length::Fill),
|
||
]
|
||
.into()
|
||
}
|
||
|
||
/// Overlay the live hotkey reference from the top-right info button. It reads
|
||
/// directly from config, so Settings edits are reflected immediately.
|
||
fn with_hotkey_info<'a>(
|
||
base: Element<'a, AppMessage>,
|
||
state: &'a AppState,
|
||
) -> Element<'a, AppMessage> {
|
||
if !state.hotkey_info_open {
|
||
return base;
|
||
}
|
||
let pal = state.config.theme.palette();
|
||
let crust = pal.crust;
|
||
let mantle = pal.mantle;
|
||
let surface = pal.surface;
|
||
let text_c = pal.text;
|
||
let subtext = pal.subtext;
|
||
let blue = pal.blue;
|
||
|
||
let backdrop = mouse_area(
|
||
container(horizontal_space())
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fill)
|
||
.style(move |_t: &Theme| container::Style {
|
||
background: Some(Background::Color(Color { a: 0.25, ..crust })),
|
||
..Default::default()
|
||
}),
|
||
)
|
||
.on_press(AppMessage::CloseHotkeyInfo);
|
||
|
||
let mut rows = Column::new().spacing(8).width(iced::Length::Fill);
|
||
for action in HotkeyAction::ALL {
|
||
rows = rows.push(
|
||
row![
|
||
text(action.label()).size(12).color(text_c),
|
||
horizontal_space(),
|
||
text(format_binding(state.config.hotkeys.binding(action)))
|
||
.size(12)
|
||
.color(subtext),
|
||
]
|
||
.spacing(12)
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
);
|
||
}
|
||
|
||
let dialog = container(
|
||
column![
|
||
row![
|
||
text("Hotkeys").size(16).color(blue),
|
||
horizontal_space(),
|
||
button(text("✕").size(16).color(subtext))
|
||
.on_press(AppMessage::CloseHotkeyInfo)
|
||
.style(|_t: &Theme, _s: button::Status| button::Style {
|
||
background: None,
|
||
..Default::default()
|
||
})
|
||
.padding(2),
|
||
]
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
rows,
|
||
]
|
||
.spacing(14),
|
||
)
|
||
.style(move |_t: &Theme| container::Style {
|
||
text_color: Some(text_c),
|
||
background: Some(Background::Color(mantle)),
|
||
border: Border {
|
||
color: surface,
|
||
width: 1.0,
|
||
radius: 8.0.into(),
|
||
},
|
||
..Default::default()
|
||
})
|
||
.padding(16)
|
||
.width(iced::Length::Fixed(320.0));
|
||
|
||
stack![
|
||
base,
|
||
backdrop,
|
||
container(column![
|
||
vertical_space(48.0),
|
||
row![horizontal_space(), dialog].width(iced::Length::Fill),
|
||
])
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fill)
|
||
.padding(12),
|
||
]
|
||
.into()
|
||
}
|
||
|
||
/// Overlays the "screen sharing needs pixelpass" explainer popup over `base`
|
||
/// when open (A11). Triggered by the Share Screen / Watch controls when the
|
||
/// optional `pixelpass` companion isn't installed, so those controls open a
|
||
/// short how-to instead of being dead/disabled. Returns the base unchanged when
|
||
/// the popup is closed.
|
||
fn with_pixelpass_help<'a>(
|
||
base: Element<'a, AppMessage>,
|
||
state: &'a AppState,
|
||
) -> Element<'a, AppMessage> {
|
||
if !state.pixelpass_help_open {
|
||
return base;
|
||
}
|
||
let pal = state.config.theme.palette();
|
||
let crust = pal.crust;
|
||
let mantle = pal.mantle;
|
||
let surface = pal.surface;
|
||
let text_c = pal.text;
|
||
let subtext = pal.subtext;
|
||
let blue = pal.blue;
|
||
|
||
let backdrop = mouse_area(
|
||
container(horizontal_space())
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fill)
|
||
.style(move |_t: &Theme| container::Style {
|
||
background: Some(Background::Color(Color { a: 0.55, ..crust })),
|
||
..Default::default()
|
||
}),
|
||
)
|
||
.on_press(AppMessage::ClosePixelpassHelp);
|
||
|
||
let dialog = container(
|
||
column![
|
||
row![
|
||
text("Screen sharing").size(16).color(blue),
|
||
horizontal_space(),
|
||
button(text("✕").size(16).color(subtext))
|
||
.on_press(AppMessage::ClosePixelpassHelp)
|
||
.style(|_t: &Theme, _s: button::Status| button::Style {
|
||
background: None,
|
||
..Default::default()
|
||
})
|
||
.padding(2),
|
||
]
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
text(
|
||
"Screen sharing uses pixelpass, a small companion tool that \
|
||
streams video peer-to-peer alongside your call. It's optional \
|
||
and ships separately, so peerspeak works fully without it."
|
||
)
|
||
.size(13)
|
||
.color(text_c),
|
||
text("To enable sharing and watching:").size(13).color(text_c),
|
||
text("• Install pixelpass and make sure the `pixelpass` command is on your PATH.").size(12).color(subtext),
|
||
text("• Install `mpv` too — it's what opens a peer's shared screen.").size(12).color(subtext),
|
||
text("Once both are present, restart peerspeak and the Share Screen button will start a share.").size(12).color(subtext),
|
||
row![
|
||
horizontal_space(),
|
||
button(text("Got it").size(13))
|
||
.on_press(AppMessage::ClosePixelpassHelp)
|
||
.style(move |_t: &Theme, status: button::Status| button::Style {
|
||
background: Some(Background::Color(match status {
|
||
button::Status::Hovered => blue,
|
||
_ => surface,
|
||
})),
|
||
text_color: text_c,
|
||
border: Border { color: Color::TRANSPARENT, width: 0.0, radius: 6.0.into() },
|
||
..Default::default()
|
||
})
|
||
.padding(8),
|
||
],
|
||
]
|
||
.spacing(14),
|
||
)
|
||
.style(move |_t: &Theme| container::Style {
|
||
text_color: Some(text_c),
|
||
background: Some(Background::Color(mantle)),
|
||
border: Border { color: surface, width: 1.0, radius: 12.0.into() },
|
||
..Default::default()
|
||
})
|
||
.padding(20)
|
||
.width(iced::Length::Fixed(460.0));
|
||
|
||
stack![
|
||
base,
|
||
backdrop,
|
||
container(dialog)
|
||
.center_x(iced::Length::Fill)
|
||
.center_y(iced::Length::Fill),
|
||
]
|
||
.into()
|
||
}
|
||
|
||
/// Overlay the screen-share **audio picker** when open (A23). Lets the user
|
||
/// capture a single app's audio instead of the whole desktop sink — the default
|
||
/// whole-desktop capture contains our own call playout, so a viewer would
|
||
/// otherwise hear themselves echoed back. "All system audio" keeps the legacy
|
||
/// behavior (with a warning); picking an app passes `--app=<name>` to pixelpass.
|
||
fn with_share_picker<'a>(
|
||
base: Element<'a, AppMessage>,
|
||
state: &'a AppState,
|
||
) -> Element<'a, AppMessage> {
|
||
if !state.share_picker_open {
|
||
return base;
|
||
}
|
||
let pal = state.config.theme.palette();
|
||
let crust = pal.crust;
|
||
let mantle = pal.mantle;
|
||
let surface = pal.surface;
|
||
let text_c = pal.text;
|
||
let subtext = pal.subtext;
|
||
let blue = pal.blue;
|
||
let yellow = pal.yellow;
|
||
|
||
let backdrop = mouse_area(
|
||
container(horizontal_space())
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fill)
|
||
.style(move |_t: &Theme| container::Style {
|
||
background: Some(Background::Color(Color { a: 0.55, ..crust })),
|
||
..Default::default()
|
||
}),
|
||
)
|
||
.on_press(AppMessage::CloseSharePicker);
|
||
|
||
// One selectable row: a radio-style dot + label. Highlighted when selected.
|
||
let opt_row =
|
||
|selected: bool, label: String, sublabel: Option<&'static str>, msg: AppMessage| {
|
||
let dot = text(if selected { "●" } else { "○" })
|
||
.size(14)
|
||
.color(if selected { blue } else { subtext });
|
||
let mut labels = column![text(label).size(13).color(text_c)].spacing(2);
|
||
if let Some(s) = sublabel {
|
||
labels = labels.push(text(s).size(11).color(yellow));
|
||
}
|
||
button(
|
||
row![dot, labels]
|
||
.spacing(10)
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
)
|
||
.on_press(msg)
|
||
.width(iced::Length::Fill)
|
||
.padding(8)
|
||
.style(move |_t: &Theme, status: button::Status| {
|
||
let bg = if selected {
|
||
Some(Background::Color(surface))
|
||
} else if matches!(status, button::Status::Hovered) {
|
||
Some(Background::Color(Color { a: 0.5, ..surface }))
|
||
} else {
|
||
None
|
||
};
|
||
button::Style {
|
||
background: bg,
|
||
text_color: text_c,
|
||
border: Border {
|
||
color: Color::TRANSPARENT,
|
||
width: 0.0,
|
||
radius: 6.0.into(),
|
||
},
|
||
..Default::default()
|
||
}
|
||
})
|
||
};
|
||
|
||
// "All system audio" first (the whole-desktop default — carries the echo
|
||
// warning), then each currently-playing app.
|
||
let mut options = column![opt_row(
|
||
state.share_audio_selection.is_none(),
|
||
"All system audio".to_string(),
|
||
Some("⚠ may echo the call back to viewers"),
|
||
AppMessage::SelectShareAudioApp(None),
|
||
)]
|
||
.spacing(4);
|
||
for app in &state.share_audio_apps {
|
||
let selected = state.share_audio_selection.as_deref() == Some(app.as_str());
|
||
options = options.push(opt_row(
|
||
selected,
|
||
app.clone(),
|
||
None,
|
||
AppMessage::SelectShareAudioApp(Some(app.clone())),
|
||
));
|
||
}
|
||
|
||
let list: Element<'_, AppMessage> = if !state.share_app_audio_supported {
|
||
// Older pixelpass without --strict-audio: per-app capture is unavailable
|
||
// (it would hard-fail), so only whole-desktop is offered. Nudge to upgrade.
|
||
column![
|
||
options,
|
||
text("Update pixelpass to capture a single app's audio (avoids echoing the call to viewers).")
|
||
.size(11)
|
||
.color(yellow),
|
||
]
|
||
.spacing(8)
|
||
.into()
|
||
} else if state.share_audio_apps.is_empty() {
|
||
column![
|
||
options,
|
||
text("No other apps are playing audio right now.")
|
||
.size(11)
|
||
.color(subtext),
|
||
]
|
||
.spacing(8)
|
||
.into()
|
||
} else {
|
||
scrollable(options).height(iced::Length::Shrink).into()
|
||
};
|
||
|
||
let cancel_btn = button(text("Cancel").size(13).color(text_c))
|
||
.on_press(AppMessage::CloseSharePicker)
|
||
.style(move |_t: &Theme, status: button::Status| button::Style {
|
||
background: Some(Background::Color(match status {
|
||
button::Status::Hovered => surface,
|
||
_ => mantle,
|
||
})),
|
||
text_color: text_c,
|
||
border: Border {
|
||
color: surface,
|
||
width: 1.0,
|
||
radius: 6.0.into(),
|
||
},
|
||
..Default::default()
|
||
})
|
||
.padding(8);
|
||
let share_btn = button(text("Share").size(13).color(crust))
|
||
.on_press(AppMessage::ConfirmShareScreen)
|
||
.style(move |_t: &Theme, status: button::Status| button::Style {
|
||
background: Some(Background::Color(match status {
|
||
button::Status::Hovered => pal.lavender,
|
||
_ => blue,
|
||
})),
|
||
text_color: crust,
|
||
border: Border {
|
||
color: Color::TRANSPARENT,
|
||
width: 0.0,
|
||
radius: 6.0.into(),
|
||
},
|
||
..Default::default()
|
||
})
|
||
.padding(8);
|
||
|
||
let dialog = container(
|
||
column![
|
||
row![
|
||
text("Share screen audio").size(16).color(blue),
|
||
horizontal_space(),
|
||
button(text("✕").size(16).color(subtext))
|
||
.on_press(AppMessage::CloseSharePicker)
|
||
.style(|_t: &Theme, _s: button::Status| button::Style {
|
||
background: None,
|
||
..Default::default()
|
||
})
|
||
.padding(2),
|
||
]
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
text("Capture audio from:").size(13).color(text_c),
|
||
list,
|
||
row![horizontal_space(), cancel_btn, share_btn,].spacing(8),
|
||
]
|
||
.spacing(14),
|
||
)
|
||
.style(move |_t: &Theme| container::Style {
|
||
text_color: Some(text_c),
|
||
background: Some(Background::Color(mantle)),
|
||
border: Border {
|
||
color: surface,
|
||
width: 1.0,
|
||
radius: 12.0.into(),
|
||
},
|
||
..Default::default()
|
||
})
|
||
.padding(20)
|
||
.width(iced::Length::Fixed(420.0))
|
||
.max_height(460.0);
|
||
|
||
stack![
|
||
base,
|
||
backdrop,
|
||
container(dialog)
|
||
.center_x(iced::Length::Fill)
|
||
.center_y(iced::Length::Fill),
|
||
]
|
||
.into()
|
||
}
|
||
|
||
/// Overlay the "Regenerate identity?" confirm dialog when open (W7). A
|
||
/// destructive action — minting a new id discards the old one — so it's gated
|
||
/// behind an explicit confirm with a clear warning.
|
||
fn with_regenerate_confirm<'a>(
|
||
base: Element<'a, AppMessage>,
|
||
state: &'a AppState,
|
||
) -> Element<'a, AppMessage> {
|
||
if !state.regenerate_identity_confirm_open {
|
||
return base;
|
||
}
|
||
let pal = state.config.theme.palette();
|
||
let crust = pal.crust;
|
||
let mantle = pal.mantle;
|
||
let surface = pal.surface;
|
||
let text_c = pal.text;
|
||
let subtext = pal.subtext;
|
||
let maroon = pal.maroon;
|
||
|
||
let backdrop = mouse_area(
|
||
container(horizontal_space())
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fill)
|
||
.style(move |_t: &Theme| container::Style {
|
||
background: Some(Background::Color(Color { a: 0.55, ..crust })),
|
||
..Default::default()
|
||
}),
|
||
)
|
||
.on_press(AppMessage::CloseRegenerateIdentityConfirm);
|
||
|
||
let dialog = container(
|
||
column![
|
||
text("Regenerate identity?").size(16).color(maroon),
|
||
text(
|
||
"This mints a brand-new identity and permanently discards your \
|
||
current one. Friends who saved your old ID will no longer \
|
||
recognise or reach you until you reconnect and they re-add you. \
|
||
This can't be undone."
|
||
)
|
||
.size(13)
|
||
.color(text_c),
|
||
text("Takes effect on your next room join.")
|
||
.size(12)
|
||
.color(subtext),
|
||
row![
|
||
horizontal_space(),
|
||
button(text("Cancel").size(13))
|
||
.on_press(AppMessage::CloseRegenerateIdentityConfirm)
|
||
.style(move |_t: &Theme, status: button::Status| button::Style {
|
||
background: Some(Background::Color(match status {
|
||
button::Status::Hovered => surface,
|
||
_ => mantle,
|
||
})),
|
||
text_color: text_c,
|
||
border: Border {
|
||
color: surface,
|
||
width: 1.0,
|
||
radius: 6.0.into()
|
||
},
|
||
..Default::default()
|
||
})
|
||
.padding(8),
|
||
button(text("Regenerate").size(13))
|
||
.on_press(AppMessage::ConfirmRegenerateIdentity)
|
||
.style(move |_t: &Theme, status: button::Status| button::Style {
|
||
background: Some(Background::Color(match status {
|
||
button::Status::Hovered => Color { a: 0.85, ..maroon },
|
||
_ => maroon,
|
||
})),
|
||
text_color: text_c,
|
||
border: Border {
|
||
color: Color::TRANSPARENT,
|
||
width: 0.0,
|
||
radius: 6.0.into()
|
||
},
|
||
..Default::default()
|
||
})
|
||
.padding(8),
|
||
]
|
||
.spacing(10),
|
||
]
|
||
.spacing(14),
|
||
)
|
||
.style(move |_t: &Theme| container::Style {
|
||
text_color: Some(text_c),
|
||
background: Some(Background::Color(mantle)),
|
||
border: Border {
|
||
color: surface,
|
||
width: 1.0,
|
||
radius: 12.0.into(),
|
||
},
|
||
..Default::default()
|
||
})
|
||
.padding(20)
|
||
.width(iced::Length::Fixed(440.0));
|
||
|
||
stack![
|
||
base,
|
||
backdrop,
|
||
container(dialog)
|
||
.center_x(iced::Length::Fill)
|
||
.center_y(iced::Length::Fill),
|
||
]
|
||
.into()
|
||
}
|
||
|
||
/// Overlay the click-to-enlarge image lightbox when `state.image_lightbox` is set
|
||
/// (W22). Clicking the dimmed backdrop, the enlarged image, or the top-right ✕
|
||
/// button closes it (`CloseImageLightbox`). Modeled on `with_regenerate_confirm`.
|
||
fn with_image_lightbox<'a>(
|
||
base: Element<'a, AppMessage>,
|
||
state: &'a AppState,
|
||
) -> Element<'a, AppMessage> {
|
||
let Some(key) = state.image_lightbox.as_ref() else {
|
||
return base;
|
||
};
|
||
// If the cached handle was evicted (Tier C F-02 bound), there's nothing to
|
||
// show — fall back to the base UI rather than an empty overlay.
|
||
let Some(handle) = state.attachments.handle(key) else {
|
||
return base;
|
||
};
|
||
let pal = state.config.theme.palette();
|
||
let crust = pal.crust;
|
||
let mantle = pal.mantle;
|
||
let surface = pal.surface;
|
||
let text_c = pal.text;
|
||
|
||
// Full-window dim that closes on click (click-outside-to-close).
|
||
let backdrop = mouse_area(
|
||
container(horizontal_space())
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fill)
|
||
.style(move |_t: &Theme| container::Style {
|
||
background: Some(Background::Color(Color { a: 0.80, ..crust })),
|
||
..Default::default()
|
||
}),
|
||
)
|
||
.on_press(AppMessage::CloseImageLightbox);
|
||
|
||
// Contain-fit so the image scales down to the window without cropping or
|
||
// overflowing, bounded to most of the viewport by the surrounding padding.
|
||
let big = iced::widget::image(handle.clone())
|
||
.content_fit(iced::ContentFit::Contain)
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fill);
|
||
|
||
// ✕ close button, pinned top-right (subtle styling matching the dialog's
|
||
// Cancel button).
|
||
let close_btn = button(text("✕").size(18))
|
||
.on_press(AppMessage::CloseImageLightbox)
|
||
.style(move |_t: &Theme, status: button::Status| button::Style {
|
||
background: Some(Background::Color(match status {
|
||
button::Status::Hovered => surface,
|
||
_ => mantle,
|
||
})),
|
||
text_color: text_c,
|
||
border: Border {
|
||
color: surface,
|
||
width: 1.0,
|
||
radius: 6.0.into(),
|
||
},
|
||
..Default::default()
|
||
})
|
||
.padding(8);
|
||
|
||
// The image sits in its own layer above the backdrop, so clicking the picture
|
||
// wouldn't reach the backdrop — wrap it so the image itself also closes.
|
||
let image_area = mouse_area(
|
||
container(big)
|
||
.center_x(iced::Length::Fill)
|
||
.center_y(iced::Length::Fill)
|
||
.padding(40),
|
||
)
|
||
.on_press(AppMessage::CloseImageLightbox);
|
||
|
||
stack![
|
||
base,
|
||
backdrop,
|
||
image_area,
|
||
container(close_btn)
|
||
.align_x(iced::alignment::Horizontal::Right)
|
||
.align_y(iced::alignment::Vertical::Top)
|
||
.width(iced::Length::Fill)
|
||
.height(iced::Length::Fill)
|
||
.padding(16),
|
||
]
|
||
.into()
|
||
}
|
||
|
||
/// A small two-pane glyph for the square layout-picker button in the top bar.
|
||
struct LayoutIcon {
|
||
fg: Color,
|
||
}
|
||
|
||
impl Program<AppMessage> for LayoutIcon {
|
||
type State = ();
|
||
fn draw(
|
||
&self,
|
||
_state: &(),
|
||
renderer: &Renderer,
|
||
_theme: &Theme,
|
||
bounds: Rectangle,
|
||
_cursor: mouse::Cursor,
|
||
) -> Vec<Geometry> {
|
||
let mut f = Frame::new(renderer, bounds.size());
|
||
let (w, h) = (bounds.width, bounds.height);
|
||
f.stroke(
|
||
&Path::rectangle(Point::new(1.0, 1.5), Size::new(w - 2.0, h - 3.0)),
|
||
canvas::Stroke::default()
|
||
.with_color(self.fg)
|
||
.with_width(1.5),
|
||
);
|
||
// Vertical split into two panes.
|
||
f.fill(
|
||
&Path::rectangle(Point::new(w * 0.5 - 0.75, 1.5), Size::new(1.5, h - 3.0)),
|
||
self.fg,
|
||
);
|
||
vec![f.into_geometry()]
|
||
}
|
||
}
|
||
|
||
/// A schematic thumbnail of a [`RoomLayout`] (colored boxes for each panel),
|
||
/// drawn so the picker stays in sync with the theme and needs no image assets.
|
||
struct LayoutThumb {
|
||
layout: RoomLayout,
|
||
selected: bool,
|
||
base: Color,
|
||
surface: Color,
|
||
/// Accent shade for the chat panel, to distinguish it from the others.
|
||
overlay: Color,
|
||
/// Border colour (blue when selected, surface otherwise).
|
||
border: Color,
|
||
}
|
||
|
||
impl LayoutThumb {
|
||
fn pane(f: &mut Frame, x: f32, y: f32, w: f32, h: f32, color: Color) {
|
||
f.fill(&Path::rectangle(Point::new(x, y), Size::new(w, h)), color);
|
||
}
|
||
}
|
||
|
||
impl Program<AppMessage> for LayoutThumb {
|
||
type State = ();
|
||
fn draw(
|
||
&self,
|
||
_state: &(),
|
||
renderer: &Renderer,
|
||
_theme: &Theme,
|
||
bounds: Rectangle,
|
||
_cursor: mouse::Cursor,
|
||
) -> Vec<Geometry> {
|
||
let mut f = Frame::new(renderer, bounds.size());
|
||
let (w, h) = (bounds.width, bounds.height);
|
||
f.fill(&Path::rectangle(Point::ORIGIN, Size::new(w, h)), self.base);
|
||
|
||
let pad = 9.0;
|
||
let (ix, iy) = (pad, pad);
|
||
let (iw, ih) = (w - 2.0 * pad, h - 2.0 * pad);
|
||
let g = 4.0;
|
||
match self.layout {
|
||
RoomLayout::ThreeColumn => {
|
||
let c1 = iw * 0.34;
|
||
let c3 = iw * 0.24;
|
||
let c2 = iw - c1 - c3 - 2.0 * g;
|
||
Self::pane(&mut f, ix, iy, c1, ih, self.surface);
|
||
Self::pane(&mut f, ix + c1 + g, iy, c2, ih, self.overlay);
|
||
Self::pane(&mut f, ix + c1 + g + c2 + g, iy, c3, ih, self.surface);
|
||
}
|
||
RoomLayout::BottomDock => {
|
||
let toph = ih * 0.6;
|
||
let both = ih - toph - g;
|
||
let lw = iw * 0.66;
|
||
Self::pane(&mut f, ix, iy, lw, toph, self.surface);
|
||
Self::pane(&mut f, ix + lw + g, iy, iw - lw - g, toph, self.surface);
|
||
Self::pane(&mut f, ix, iy + toph + g, iw, both, self.overlay);
|
||
}
|
||
RoomLayout::Drawer => {
|
||
let dw = iw * 0.18;
|
||
let main = iw - dw - g;
|
||
let lw = main * 0.62;
|
||
Self::pane(&mut f, ix, iy, lw, ih, self.surface);
|
||
Self::pane(&mut f, ix + lw + g, iy, main - lw - g, ih, self.surface);
|
||
Self::pane(&mut f, ix + main + g, iy, dw, ih, self.overlay);
|
||
}
|
||
}
|
||
|
||
// Border on top (thicker + blue when selected).
|
||
let bw: f32 = if self.selected { 2.0 } else { 1.0 };
|
||
f.stroke(
|
||
&Path::rectangle(Point::new(bw / 2.0, bw / 2.0), Size::new(w - bw, h - bw)),
|
||
canvas::Stroke::default()
|
||
.with_color(self.border)
|
||
.with_width(bw),
|
||
);
|
||
vec![f.into_geometry()]
|
||
}
|
||
}
|
||
|
||
/// A small palette-preview tile for the theme picker (modeled on `LayoutThumb`):
|
||
/// the theme's background, a surface panel with two "text" lines to preview
|
||
/// legibility, and a stack of accent colours. Border highlights the selection.
|
||
struct ThemeSwatch {
|
||
palette: Palette,
|
||
selected: bool,
|
||
border: Color,
|
||
}
|
||
|
||
impl Program<AppMessage> for ThemeSwatch {
|
||
type State = ();
|
||
fn draw(
|
||
&self,
|
||
_state: &(),
|
||
renderer: &Renderer,
|
||
_theme: &Theme,
|
||
bounds: Rectangle,
|
||
_cursor: mouse::Cursor,
|
||
) -> Vec<Geometry> {
|
||
let mut f = Frame::new(renderer, bounds.size());
|
||
let (w, h) = (bounds.width, bounds.height);
|
||
let p = &self.palette;
|
||
let pad = 7.0;
|
||
|
||
// Background fill.
|
||
f.fill(&Path::rectangle(Point::ORIGIN, Size::new(w, h)), p.base);
|
||
|
||
// Surface panel on the left half, with two text-colour lines on it.
|
||
let panel_w = (w - 2.0 * pad) * 0.52;
|
||
f.fill(
|
||
&Path::rectangle(Point::new(pad, pad), Size::new(panel_w, h - 2.0 * pad)),
|
||
p.surface,
|
||
);
|
||
let mut line = |y: f32, ww: f32, col: Color| {
|
||
f.fill(
|
||
&Path::rectangle(Point::new(pad + 6.0, y), Size::new(ww, 3.0)),
|
||
col,
|
||
);
|
||
};
|
||
line(pad + 9.0, panel_w * 0.66, p.text);
|
||
line(pad + 17.0, panel_w * 0.48, p.subtext);
|
||
|
||
// Accent swatches stacked on the right.
|
||
let ax = pad + panel_w + 6.0;
|
||
let aw = (w - pad - ax).max(2.0);
|
||
let accents = [p.blue, p.green, p.yellow, p.red, p.lavender];
|
||
let gap = 3.0;
|
||
let ah = ((h - 2.0 * pad) - gap * (accents.len() as f32 - 1.0)) / accents.len() as f32;
|
||
for (i, c) in accents.iter().enumerate() {
|
||
let ay = pad + i as f32 * (ah + gap);
|
||
f.fill(&Path::rectangle(Point::new(ax, ay), Size::new(aw, ah)), *c);
|
||
}
|
||
|
||
// Border (thicker + accent when selected).
|
||
let bw: f32 = if self.selected { 2.0 } else { 1.0 };
|
||
f.stroke(
|
||
&Path::rectangle(Point::new(bw / 2.0, bw / 2.0), Size::new(w - bw, h - bw)),
|
||
canvas::Stroke::default()
|
||
.with_color(self.border)
|
||
.with_width(bw),
|
||
);
|
||
vec![f.into_geometry()]
|
||
}
|
||
}
|
||
|
||
/// The PeerSpeak icon set, drawn on a `canvas` so it needs no image/font asset
|
||
/// and recolors with the theme (consistent with `LayoutThumb`/`GateMeter`).
|
||
/// Each icon is authored in a 24×24 space and scaled to the widget size.
|
||
#[derive(Clone, Copy, PartialEq)]
|
||
enum IconKind {
|
||
Mic,
|
||
MicOff,
|
||
Headphones,
|
||
Deafen,
|
||
Speaker,
|
||
SpeakerOff,
|
||
Monitor,
|
||
Eye,
|
||
Record,
|
||
Stop,
|
||
Chat,
|
||
People,
|
||
Clock,
|
||
Info,
|
||
Settings,
|
||
Copy,
|
||
Leave,
|
||
Create,
|
||
Live,
|
||
}
|
||
|
||
struct Icon {
|
||
kind: IconKind,
|
||
color: Color,
|
||
/// Stroke weight in the 24-unit authoring space (scaled with the widget).
|
||
weight: f32,
|
||
}
|
||
|
||
/// Build an icon element at `size` px in `color`. Used throughout the room/
|
||
/// settings UI in place of emoji.
|
||
fn icon<'a>(kind: IconKind, size: f32, color: Color) -> Element<'a, AppMessage> {
|
||
Canvas::new(Icon {
|
||
kind,
|
||
color,
|
||
weight: 2.0,
|
||
})
|
||
.width(size)
|
||
.height(size)
|
||
.into()
|
||
}
|
||
|
||
/// A circular monogram avatar (W4): the participant's initial(s) on a colour
|
||
/// deterministically derived from `key` (their node id, or display name where no
|
||
/// id is available). This is the fallback shown until presets / custom uploads
|
||
/// (W4 Phases 2–3) override it. `size` is the diameter in px.
|
||
fn avatar_badge<'a>(name: &str, key: &str, size: f32) -> Element<'a, AppMessage> {
|
||
let (r, g, b) = crate::avatar::color_for_key(key);
|
||
let bg = Color::from_rgb8(r, g, b);
|
||
let fg = if crate::avatar::use_dark_text_on((r, g, b)) {
|
||
Color::from_rgb8(0x1e, 0x1e, 0x2e)
|
||
} else {
|
||
Color::WHITE
|
||
};
|
||
container(
|
||
text(crate::avatar::initials(name))
|
||
.size(size * 0.42)
|
||
.color(fg),
|
||
)
|
||
.center_x(iced::Length::Fixed(size))
|
||
.center_y(iced::Length::Fixed(size))
|
||
.style(move |_t: &Theme| container::Style {
|
||
background: Some(Background::Color(bg)),
|
||
border: Border {
|
||
color: Color::TRANSPARENT,
|
||
width: 0.0,
|
||
radius: (size / 2.0).into(),
|
||
},
|
||
..Default::default()
|
||
})
|
||
.into()
|
||
}
|
||
|
||
/// Maximum distinct avatar images we keep handles for. Each avatar is bounded to
|
||
/// 48 KiB / 256×256 at ingest, so a 64-entry LRU caps this cache at a few MB
|
||
/// regardless of how many distinct avatars peers publish over time (Tier C F-03).
|
||
const AVATAR_CACHE_CAP: usize = 64;
|
||
|
||
thread_local! {
|
||
/// Bounded cache of avatar image handles, keyed by PNG content, so the SAME
|
||
/// `image::Handle` (and thus the same GPU texture id) is reused across
|
||
/// redraws. `image::Handle::from_bytes` mints a fresh *unique* id on every
|
||
/// call, so building handles inline in `view()` made iced re-upload the
|
||
/// texture on every repaint — including the redraws fired on each mouse move —
|
||
/// which showed up as constant flicker. A peer can publish an unbounded stream
|
||
/// of distinct valid avatars over a session, so the cache is an LRU (bounded +
|
||
/// byte-equality keyed) rather than a plain map (Tier C F-03). Lives on the
|
||
/// (single) UI thread.
|
||
static AVATAR_HANDLE_CACHE:
|
||
std::cell::RefCell<crate::avatar::ByteLru<iced::widget::image::Handle>> =
|
||
std::cell::RefCell::new(crate::avatar::ByteLru::new(AVATAR_CACHE_CAP));
|
||
}
|
||
|
||
/// A stable image handle for these exact PNG bytes (cached by content), so it
|
||
/// keeps the same id across redraws — see [`AVATAR_HANDLE_CACHE`].
|
||
fn cached_image_handle(bytes: bytes::Bytes) -> iced::widget::image::Handle {
|
||
AVATAR_HANDLE_CACHE.with(|cache| {
|
||
cache.borrow_mut().get_or_insert(bytes.as_ref(), || {
|
||
iced::widget::image::Handle::from_bytes(bytes.clone())
|
||
})
|
||
})
|
||
}
|
||
|
||
/// Render a participant's avatar (W4): the chosen preset image if any, else the
|
||
/// monogram fallback. `name`/`key` feed the monogram; `size` is the diameter.
|
||
fn avatar_view<'a>(
|
||
avatar: &crate::avatar::Avatar,
|
||
name: &str,
|
||
key: &str,
|
||
size: f32,
|
||
) -> Element<'a, AppMessage> {
|
||
let png_bytes: Option<bytes::Bytes> = avatar
|
||
.preset_png()
|
||
.map(bytes::Bytes::from_static)
|
||
.or_else(|| avatar.custom_png().map(bytes::Bytes::from));
|
||
match png_bytes {
|
||
Some(b) => iced::widget::image(cached_image_handle(b))
|
||
.width(iced::Length::Fixed(size))
|
||
.height(iced::Length::Fixed(size))
|
||
.into(),
|
||
None => avatar_badge(name, key, size),
|
||
}
|
||
}
|
||
|
||
/// Centered "icon + label" content for a full-width control-panel button. The
|
||
/// icon takes the button's foreground colour so it matches the label.
|
||
fn btn_content<'a>(kind: IconKind, label: &'a str, color: Color) -> Element<'a, AppMessage> {
|
||
container(
|
||
row![icon(kind, 18.0, color), text(label).size(16)]
|
||
.spacing(8)
|
||
.align_y(iced::alignment::Vertical::Center),
|
||
)
|
||
.center_x(iced::Length::Fill)
|
||
.into()
|
||
}
|
||
|
||
impl Program<AppMessage> for Icon {
|
||
type State = ();
|
||
fn draw(
|
||
&self,
|
||
_state: &(),
|
||
renderer: &Renderer,
|
||
_theme: &Theme,
|
||
bounds: Rectangle,
|
||
_cursor: mouse::Cursor,
|
||
) -> Vec<Geometry> {
|
||
use std::f32::consts::PI;
|
||
let mut f = Frame::new(renderer, bounds.size());
|
||
let s = bounds.width.min(bounds.height) / 24.0;
|
||
let col = self.color;
|
||
let sw = (self.weight * s).max(1.0);
|
||
let p = |x: f32, y: f32| Point::new(x * s, y * s);
|
||
let stk = || {
|
||
canvas::Stroke::default()
|
||
.with_color(col)
|
||
.with_width(sw)
|
||
.with_line_cap(canvas::LineCap::Round)
|
||
.with_line_join(canvas::LineJoin::Round)
|
||
};
|
||
// A stroked polyline (or polygon when `closed`).
|
||
let poly = |pts: &[(f32, f32)], closed: bool| {
|
||
Path::new(|b| {
|
||
for (i, q) in pts.iter().enumerate() {
|
||
let pt = p(q.0, q.1);
|
||
if i == 0 {
|
||
b.move_to(pt);
|
||
} else {
|
||
b.line_to(pt);
|
||
}
|
||
}
|
||
if closed {
|
||
b.close();
|
||
}
|
||
})
|
||
};
|
||
// A stroked circular arc from a0..a1 radians (0 = +x, sweeps toward +y).
|
||
let arcp = |cx: f32, cy: f32, r: f32, a0: f32, a1: f32| {
|
||
Path::new(|b| {
|
||
b.arc(iced::widget::canvas::path::Arc {
|
||
center: p(cx, cy),
|
||
radius: r * s,
|
||
start_angle: iced::Radians(a0),
|
||
end_angle: iced::Radians(a1),
|
||
});
|
||
})
|
||
};
|
||
let rrect = |x: f32, y: f32, w: f32, h: f32, r: f32| {
|
||
Path::rounded_rectangle(p(x, y), Size::new(w * s, h * s), (r * s).into())
|
||
};
|
||
|
||
match self.kind {
|
||
IconKind::Mic => {
|
||
f.stroke(&rrect(9.0, 2.5, 6.0, 11.0, 3.0), stk());
|
||
f.stroke(&arcp(12.0, 11.0, 6.5, 0.0, PI), stk());
|
||
f.stroke(&poly(&[(12.0, 17.5), (12.0, 21.0)], false), stk());
|
||
f.stroke(&poly(&[(8.5, 21.0), (15.5, 21.0)], false), stk());
|
||
}
|
||
IconKind::MicOff => {
|
||
f.stroke(&rrect(9.0, 2.5, 6.0, 11.0, 3.0), stk());
|
||
f.stroke(&arcp(12.0, 11.0, 6.5, 0.0, PI), stk());
|
||
f.stroke(&poly(&[(12.0, 17.5), (12.0, 21.0)], false), stk());
|
||
f.stroke(&poly(&[(8.5, 21.0), (15.5, 21.0)], false), stk());
|
||
f.stroke(&poly(&[(3.5, 3.5), (20.5, 20.5)], false), stk());
|
||
}
|
||
IconKind::Headphones => {
|
||
f.stroke(&arcp(12.0, 12.5, 8.0, PI, 2.0 * PI), stk());
|
||
f.stroke(&rrect(3.0, 13.0, 4.5, 7.0, 2.25), stk());
|
||
f.stroke(&rrect(16.5, 13.0, 4.5, 7.0, 2.25), stk());
|
||
}
|
||
IconKind::Deafen => {
|
||
f.stroke(&arcp(12.0, 12.5, 8.0, PI, 2.0 * PI), stk());
|
||
f.stroke(&rrect(3.0, 13.0, 4.5, 7.0, 2.25), stk());
|
||
f.stroke(&rrect(16.5, 13.0, 4.5, 7.0, 2.25), stk());
|
||
f.stroke(&poly(&[(3.0, 3.0), (21.0, 21.0)], false), stk());
|
||
}
|
||
IconKind::Speaker => {
|
||
f.stroke(
|
||
&poly(
|
||
&[
|
||
(4.0, 9.5),
|
||
(7.5, 9.5),
|
||
(13.0, 5.0),
|
||
(13.0, 19.0),
|
||
(7.5, 14.5),
|
||
(4.0, 14.5),
|
||
],
|
||
true,
|
||
),
|
||
stk(),
|
||
);
|
||
f.stroke(&arcp(14.0, 12.0, 4.0, -0.5, 0.5), stk());
|
||
f.stroke(&arcp(14.0, 12.0, 7.5, -0.65, 0.65), stk());
|
||
}
|
||
IconKind::SpeakerOff => {
|
||
f.stroke(
|
||
&poly(
|
||
&[
|
||
(4.0, 9.5),
|
||
(7.5, 9.5),
|
||
(13.0, 5.0),
|
||
(13.0, 19.0),
|
||
(7.5, 14.5),
|
||
(4.0, 14.5),
|
||
],
|
||
true,
|
||
),
|
||
stk(),
|
||
);
|
||
f.stroke(&poly(&[(16.5, 9.5), (21.5, 14.5)], false), stk());
|
||
f.stroke(&poly(&[(21.5, 9.5), (16.5, 14.5)], false), stk());
|
||
}
|
||
IconKind::Monitor => {
|
||
f.stroke(&rrect(3.0, 4.0, 18.0, 12.0, 2.0), stk());
|
||
f.stroke(&poly(&[(9.0, 20.0), (15.0, 20.0)], false), stk());
|
||
f.stroke(&poly(&[(12.0, 16.0), (12.0, 20.0)], false), stk());
|
||
// up-arrow inside (the "share" cue)
|
||
f.stroke(&poly(&[(12.0, 13.0), (12.0, 7.5)], false), stk());
|
||
f.stroke(
|
||
&poly(&[(9.5, 10.0), (12.0, 7.5), (14.5, 10.0)], false),
|
||
stk(),
|
||
);
|
||
}
|
||
IconKind::Eye => {
|
||
f.stroke(
|
||
&poly(
|
||
&[
|
||
(2.5, 12.0),
|
||
(6.0, 8.5),
|
||
(12.0, 7.0),
|
||
(18.0, 8.5),
|
||
(21.5, 12.0),
|
||
(18.0, 15.5),
|
||
(12.0, 17.0),
|
||
(6.0, 15.5),
|
||
],
|
||
true,
|
||
),
|
||
stk(),
|
||
);
|
||
f.stroke(&Path::circle(p(12.0, 12.0), 3.0 * s), stk());
|
||
}
|
||
IconKind::Record => {
|
||
f.fill(&Path::circle(p(12.0, 12.0), 6.0 * s), col);
|
||
}
|
||
IconKind::Stop => {
|
||
f.fill(&rrect(6.0, 6.0, 12.0, 12.0, 2.5), col);
|
||
}
|
||
IconKind::Chat => {
|
||
f.stroke(&rrect(3.0, 4.0, 18.0, 12.0, 3.0), stk());
|
||
f.stroke(
|
||
&poly(&[(8.0, 16.0), (8.0, 20.5), (12.5, 16.0)], false),
|
||
stk(),
|
||
);
|
||
}
|
||
IconKind::People => {
|
||
f.stroke(&Path::circle(p(9.0, 9.0), 3.2 * s), stk());
|
||
f.stroke(&arcp(9.0, 20.0, 5.3, PI, 2.0 * PI), stk());
|
||
f.stroke(&Path::circle(p(16.5, 8.0), 2.7 * s), stk());
|
||
f.stroke(&arcp(16.5, 20.0, 4.6, PI, 2.0 * PI), stk());
|
||
}
|
||
IconKind::Clock => {
|
||
f.stroke(&Path::circle(p(12.0, 12.0), 8.5 * s), stk());
|
||
f.stroke(&poly(&[(12.0, 7.0), (12.0, 12.0)], false), stk());
|
||
f.stroke(&poly(&[(12.0, 12.0), (15.5, 14.0)], false), stk());
|
||
}
|
||
IconKind::Info => {
|
||
f.stroke(&Path::circle(p(12.0, 12.0), 8.5 * s), stk());
|
||
f.stroke(&poly(&[(12.0, 10.5), (12.0, 17.0)], false), stk());
|
||
f.fill(&Path::circle(p(12.0, 7.0), 1.1 * s), col);
|
||
}
|
||
IconKind::Settings => {
|
||
f.stroke(&poly(&[(4.0, 7.0), (20.0, 7.0)], false), stk());
|
||
f.stroke(&poly(&[(4.0, 12.0), (20.0, 12.0)], false), stk());
|
||
f.stroke(&poly(&[(4.0, 17.0), (20.0, 17.0)], false), stk());
|
||
f.fill(&Path::circle(p(15.0, 7.0), 2.4 * s), col);
|
||
f.fill(&Path::circle(p(9.0, 12.0), 2.4 * s), col);
|
||
f.fill(&Path::circle(p(16.0, 17.0), 2.4 * s), col);
|
||
}
|
||
IconKind::Copy => {
|
||
f.stroke(&rrect(8.0, 8.0, 12.0, 12.0, 2.0), stk());
|
||
f.stroke(&rrect(4.0, 4.0, 12.0, 12.0, 2.0), stk());
|
||
}
|
||
IconKind::Leave => {
|
||
f.stroke(
|
||
&poly(&[(9.0, 4.0), (6.0, 4.0), (6.0, 20.0), (9.0, 20.0)], false),
|
||
stk(),
|
||
);
|
||
f.stroke(&poly(&[(9.5, 12.0), (20.0, 12.0)], false), stk());
|
||
f.stroke(
|
||
&poly(&[(15.5, 8.0), (20.0, 12.0), (15.5, 16.0)], false),
|
||
stk(),
|
||
);
|
||
}
|
||
IconKind::Create => {
|
||
f.stroke(&Path::circle(p(12.0, 12.0), 8.5 * s), stk());
|
||
f.stroke(&poly(&[(12.0, 8.0), (12.0, 16.0)], false), stk());
|
||
f.stroke(&poly(&[(8.0, 12.0), (16.0, 12.0)], false), stk());
|
||
}
|
||
IconKind::Live => {
|
||
f.fill(&Path::circle(p(12.0, 12.0), 3.0 * s), col);
|
||
f.stroke(&arcp(12.0, 12.0, 5.0, -0.4 * PI, 0.4 * PI), stk());
|
||
f.stroke(&arcp(12.0, 12.0, 5.0, 0.6 * PI, 1.4 * PI), stk());
|
||
f.stroke(&arcp(12.0, 12.0, 8.0, -0.32 * PI, 0.32 * PI), stk());
|
||
f.stroke(&arcp(12.0, 12.0, 8.0, 0.68 * PI, 1.32 * PI), stk());
|
||
}
|
||
}
|
||
vec![f.into_geometry()]
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::{
|
||
AppConfig, AppMessage, AppState, AttachmentCache, AttachmentState,
|
||
CLOCK_SKEW_WARNING_VISIBLE_SECS, ChatEntry, ClockSkewBanner, GateMeter, METER_MAX,
|
||
ScreenBounds, UiEvent, attachment_default_name, clamp_window_position,
|
||
clear_expired_clock_skew_warning, format_clock_skew_duration, format_duration,
|
||
format_relative_ago, initial_window_position, now_playing_label, reconnect_attempt_chime,
|
||
reconnected_chime, set_peer_gate_config, set_peer_volume_config, show_clock_skew_warning,
|
||
update,
|
||
};
|
||
use iroh::SecretKey;
|
||
|
||
/// Two distinct (peer, id) keys for cache tests; `id` may be shared to model
|
||
/// a malicious peer reusing a victim's attachment id (Tier C F-12).
|
||
fn peer_key(id: [u8; 32]) -> super::AttachmentKey {
|
||
(SecretKey::generate().public(), id)
|
||
}
|
||
|
||
#[test]
|
||
fn attachment_cache_evicts_oldest_when_full() {
|
||
let mut cache = AttachmentCache::new(2);
|
||
let k1 = peer_key([1u8; 32]);
|
||
let k2 = peer_key([2u8; 32]);
|
||
let k3 = peer_key([3u8; 32]);
|
||
cache.insert(k1, AttachmentState::Ready(vec![1]), None);
|
||
cache.insert(k2, AttachmentState::Ready(vec![2]), None);
|
||
assert_eq!(cache.len(), 2);
|
||
// Inserting a third NEW key evicts the oldest (k1), not the newest.
|
||
cache.insert(k3, AttachmentState::Ready(vec![3]), None);
|
||
assert_eq!(cache.len(), 2);
|
||
assert!(cache.get(&k1).is_none(), "oldest should be evicted");
|
||
assert!(cache.get(&k2).is_some());
|
||
assert!(cache.get(&k3).is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn attachment_cache_replace_keeps_position_and_count() {
|
||
let mut cache = AttachmentCache::new(2);
|
||
let k1 = peer_key([1u8; 32]);
|
||
let k2 = peer_key([2u8; 32]);
|
||
cache.insert(k1, AttachmentState::Failed("pending".into()), None);
|
||
cache.insert(k2, AttachmentState::Ready(vec![2]), None);
|
||
// Replacing k1 (Failed -> Ready) must NOT bump it to newest; it stays the
|
||
// eviction candidate, and the count is unchanged (no order leak).
|
||
cache.insert(k1, AttachmentState::Ready(vec![1]), None);
|
||
assert_eq!(cache.len(), 2);
|
||
let k3 = peer_key([3u8; 32]);
|
||
cache.insert(k3, AttachmentState::Ready(vec![3]), None);
|
||
assert!(cache.get(&k1).is_none(), "replaced entry kept its old age");
|
||
assert!(cache.get(&k2).is_some());
|
||
assert!(cache.get(&k3).is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn attachment_cache_same_id_distinct_authors_do_not_alias() {
|
||
// The F-12 core property: two peers sending the SAME attachment id keep
|
||
// separate bytes — one never overwrites or aliases the other.
|
||
let mut cache = AttachmentCache::new(8);
|
||
let shared_id = [7u8; 32];
|
||
let victim = peer_key(shared_id);
|
||
let attacker = peer_key(shared_id);
|
||
cache.insert(victim, AttachmentState::Ready(vec![1, 1, 1]), None);
|
||
cache.insert(attacker, AttachmentState::Ready(vec![9, 9, 9]), None);
|
||
assert_eq!(cache.len(), 2);
|
||
assert!(matches!(cache.get(&victim), Some(AttachmentState::Ready(b)) if b == &[1, 1, 1]));
|
||
assert!(matches!(cache.get(&attacker), Some(AttachmentState::Ready(b)) if b == &[9, 9, 9]));
|
||
}
|
||
|
||
#[test]
|
||
fn attachment_cache_is_ready_and_handle_and_clear() {
|
||
let mut cache = AttachmentCache::new(4);
|
||
let k = peer_key([5u8; 32]);
|
||
cache.insert(
|
||
k,
|
||
AttachmentState::Ready(vec![1]),
|
||
Some(iced::widget::image::Handle::from_bytes(vec![1])),
|
||
);
|
||
assert!(cache.is_ready(&k));
|
||
assert!(cache.handle(&k).is_some());
|
||
let failed = peer_key([6u8; 32]);
|
||
cache.insert(failed, AttachmentState::Failed("nope".into()), None);
|
||
assert!(!cache.is_ready(&failed));
|
||
assert!(cache.handle(&failed).is_none());
|
||
cache.clear();
|
||
assert_eq!(cache.len(), 0);
|
||
assert!(cache.get(&k).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn attachment_cache_cap_zero_is_clamped_to_one() {
|
||
let mut cache = AttachmentCache::new(0);
|
||
let k = peer_key([1u8; 32]);
|
||
cache.insert(k, AttachmentState::Ready(vec![1]), None);
|
||
assert_eq!(cache.len(), 1);
|
||
assert!(cache.get(&k).is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn attachment_default_name_matches_full_key_not_bare_id() {
|
||
// Two chat lines carry the SAME attachment id but come from different
|
||
// senders. The save-dialog default filename must be the one belonging to
|
||
// the clicked (author, id) — not whichever line happens to match the bare
|
||
// id first (Tier C F-12 metadata residual).
|
||
let victim = SecretKey::generate().public();
|
||
let attacker = SecretKey::generate().public();
|
||
let shared_id = [7u8; 32];
|
||
let mk = |from: iroh::EndpointId, fname: &str| ChatEntry {
|
||
name: "Peer".to_string(),
|
||
text: String::new(),
|
||
mine: false,
|
||
from: Some(from.to_string()),
|
||
attachment: Some(crate::files::ChatAttachment {
|
||
name: fname.to_string(),
|
||
size: 3,
|
||
kind: crate::files::AttachmentKind::File,
|
||
id: shared_id,
|
||
}),
|
||
};
|
||
// Attacker's line is FIRST in history, so a bare-id scan would pick it.
|
||
let messages = vec![mk(attacker, "evil.sh"), mk(victim, "report.pdf")];
|
||
assert_eq!(
|
||
attachment_default_name(&messages, (victim, shared_id)),
|
||
"report.pdf"
|
||
);
|
||
assert_eq!(
|
||
attachment_default_name(&messages, (attacker, shared_id)),
|
||
"evil.sh"
|
||
);
|
||
// Unknown line → safe fallback.
|
||
let unknown = SecretKey::generate().public();
|
||
assert_eq!(
|
||
attachment_default_name(&messages, (unknown, shared_id)),
|
||
"download"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn reset_room_state_clears_all_room_scoped_state() {
|
||
let mut state = AppState::default();
|
||
let peer = SecretKey::generate().public();
|
||
let attachment_id = [9u8; 32];
|
||
let now = std::time::Instant::now();
|
||
|
||
state.peers.insert(
|
||
peer,
|
||
crate::network::PeerState {
|
||
name: "Peer".to_string(),
|
||
is_muted: false,
|
||
addr: iroh::EndpointAddr::from(peer),
|
||
sharing: None,
|
||
avatar: crate::avatar::Avatar::default(),
|
||
game: None,
|
||
music: None,
|
||
},
|
||
);
|
||
state.audio_levels.insert(peer, 0.5);
|
||
state.locally_muted.insert(peer);
|
||
state.chat_messages.push(ChatEntry {
|
||
name: "Peer".to_string(),
|
||
text: "old room".to_string(),
|
||
mine: false,
|
||
from: Some(peer.to_string()),
|
||
attachment: None,
|
||
});
|
||
state.chat_input = "draft".to_string();
|
||
let att_key = (peer, attachment_id);
|
||
state.attachments.insert(
|
||
att_key,
|
||
AttachmentState::Ready(vec![1]),
|
||
Some(iced::widget::image::Handle::from_bytes(vec![1])),
|
||
);
|
||
state.pending_saves.insert(att_key);
|
||
state.pending_plays.insert(attachment_id);
|
||
state.invalid_audio.insert(attachment_id);
|
||
state.connecting.insert(peer);
|
||
state.ever_connected.insert(peer);
|
||
state.recording = true;
|
||
state.recording_started = Some(now);
|
||
state.call_started = Some(now);
|
||
state.mic_level = 0.75;
|
||
state.self_sharing = true;
|
||
state.share_picker_open = true;
|
||
state.share_audio_apps = vec!["Firefox".to_string()];
|
||
state.share_audio_selection = Some("Firefox".to_string());
|
||
state.share_starting = true;
|
||
state.share_audio_dropped = true;
|
||
state.share_audio_app_active = true;
|
||
state.share_app_audio_supported = false;
|
||
state.clock_skew_warning = Some(ClockSkewBanner {
|
||
skew_secs: 180,
|
||
peer_ahead: true,
|
||
expires_at: now,
|
||
});
|
||
state.music_broadcast_id = Some(attachment_id);
|
||
state.music_broadcast_next = Some((0, attachment_id, 128));
|
||
state.music_listening_to = Some(peer);
|
||
state.music_listen_loaded = Some(attachment_id);
|
||
state.music_listen_inflight = Some(attachment_id);
|
||
state.music_prefetch = Some((attachment_id, vec![1, 2, 3]));
|
||
state.music_prefetch_inflight = Some(attachment_id);
|
||
state.clip_status.lock().unwrap().playing_id = Some(attachment_id);
|
||
|
||
state.reset_room_state();
|
||
|
||
assert!(state.peers.is_empty());
|
||
assert!(state.audio_levels.is_empty());
|
||
assert!(state.locally_muted.is_empty());
|
||
assert!(state.chat_messages.is_empty());
|
||
assert!(state.chat_input.is_empty());
|
||
assert!(state.attachments.len() == 0);
|
||
assert!(state.pending_saves.is_empty());
|
||
assert!(state.pending_plays.is_empty());
|
||
assert!(state.invalid_audio.is_empty());
|
||
assert!(state.connecting.is_empty());
|
||
assert!(state.ever_connected.is_empty());
|
||
assert!(!state.recording);
|
||
assert!(state.recording_started.is_none());
|
||
assert!(state.call_started.is_none());
|
||
assert_eq!(state.mic_level, 0.0);
|
||
assert!(!state.self_sharing);
|
||
assert!(!state.share_picker_open);
|
||
assert!(state.share_audio_apps.is_empty());
|
||
assert!(state.share_audio_selection.is_none());
|
||
assert_eq!(
|
||
state.share_quality_selection,
|
||
state.config.screen_share.quality
|
||
);
|
||
assert!(!state.share_starting);
|
||
assert!(!state.share_audio_dropped);
|
||
assert!(!state.share_audio_app_active);
|
||
assert!(
|
||
state.share_app_audio_supported,
|
||
"reset is optimistic by default"
|
||
);
|
||
assert!(state.clock_skew_warning.is_none());
|
||
assert!(state.music_broadcast_id.is_none());
|
||
assert!(state.music_broadcast_next.is_none());
|
||
assert!(state.music_listening_to.is_none());
|
||
assert!(state.music_listen_loaded.is_none());
|
||
assert!(state.music_listen_inflight.is_none());
|
||
assert!(state.music_prefetch.is_none());
|
||
assert!(state.music_prefetch_inflight.is_none());
|
||
|
||
for _ in 0..50 {
|
||
if crate::audio::clip_player::status_snapshot(&state.clip_status)
|
||
.playing_id
|
||
.is_none()
|
||
{
|
||
return;
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||
}
|
||
panic!("clip player did not stop during room reset");
|
||
}
|
||
|
||
#[test]
|
||
fn clock_skew_warning_shows_dismisses_and_expires() {
|
||
let mut state = AppState::default();
|
||
let now = std::time::Instant::now();
|
||
|
||
show_clock_skew_warning(&mut state, 181, true, now);
|
||
let warning = state.clock_skew_warning.expect("warning should be visible");
|
||
assert_eq!(warning.skew_secs, 181);
|
||
assert!(warning.peer_ahead);
|
||
assert_eq!(
|
||
warning.expires_at,
|
||
now + std::time::Duration::from_secs(CLOCK_SKEW_WARNING_VISIBLE_SECS)
|
||
);
|
||
|
||
let _ = update(&mut state, AppMessage::DismissClockSkewWarning);
|
||
assert!(state.clock_skew_warning.is_none());
|
||
|
||
show_clock_skew_warning(&mut state, 240, false, now);
|
||
clear_expired_clock_skew_warning(
|
||
&mut state,
|
||
now + std::time::Duration::from_secs(CLOCK_SKEW_WARNING_VISIBLE_SECS - 1),
|
||
);
|
||
assert!(state.clock_skew_warning.is_some());
|
||
clear_expired_clock_skew_warning(
|
||
&mut state,
|
||
now + std::time::Duration::from_secs(CLOCK_SKEW_WARNING_VISIBLE_SECS),
|
||
);
|
||
assert!(state.clock_skew_warning.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn clock_skew_ui_event_populates_banner() {
|
||
let mut state = AppState::default();
|
||
let _ = update(
|
||
&mut state,
|
||
AppMessage::UiEventReceived(UiEvent::ClockSkewWarning {
|
||
skew_secs: 121,
|
||
peer_ahead: false,
|
||
}),
|
||
);
|
||
|
||
let warning = state.clock_skew_warning.expect("event should show banner");
|
||
assert_eq!(warning.skew_secs, 121);
|
||
assert!(!warning.peer_ahead);
|
||
}
|
||
|
||
#[test]
|
||
fn clock_skew_duration_rounds_up_to_minutes() {
|
||
assert_eq!(format_clock_skew_duration(0), "1 minute");
|
||
assert_eq!(format_clock_skew_duration(1), "1 minute");
|
||
assert_eq!(format_clock_skew_duration(60), "1 minute");
|
||
assert_eq!(format_clock_skew_duration(61), "2 minutes");
|
||
assert_eq!(format_clock_skew_duration(181), "4 minutes");
|
||
}
|
||
|
||
#[test]
|
||
fn share_picker_startup_window_is_guarded() {
|
||
// P3-1: between confirming the picker and the core's ScreenShareStarted,
|
||
// self_sharing is still false. The picker must not be reopenable in that
|
||
// window, and a stray confirm must not re-fire StartScreenShare.
|
||
// Picker open, user confirms a selection.
|
||
let mut state = AppState {
|
||
share_picker_open: true,
|
||
share_audio_selection: Some("mpv".to_string()),
|
||
..Default::default()
|
||
};
|
||
let _ = update(&mut state, AppMessage::ConfirmShareScreen);
|
||
assert!(
|
||
state.share_starting,
|
||
"confirm should mark a start in flight"
|
||
);
|
||
assert!(!state.share_picker_open, "confirm should close the picker");
|
||
assert!(!state.self_sharing, "core hasn't acked the start yet");
|
||
|
||
// Clicking Share again during startup must NOT reopen the picker.
|
||
let _ = update(&mut state, AppMessage::ToggleScreenShare);
|
||
assert!(
|
||
!state.share_picker_open,
|
||
"picker must stay closed while starting"
|
||
);
|
||
assert!(state.share_starting);
|
||
|
||
// A stray confirm during startup is ignored (no double-start).
|
||
let _ = update(&mut state, AppMessage::ConfirmShareScreen);
|
||
assert!(state.share_starting);
|
||
|
||
// Core acks: flag clears, sharing begins, no picker lingers.
|
||
let _ = update(
|
||
&mut state,
|
||
AppMessage::UiEventReceived(UiEvent::ScreenShareStarted),
|
||
);
|
||
assert!(!state.share_starting);
|
||
assert!(state.self_sharing);
|
||
assert!(!state.share_picker_open);
|
||
}
|
||
|
||
#[test]
|
||
fn inline_quality_override_survives_opening_the_picker() {
|
||
// The inline quality dropdown (next to the Share button) sets a
|
||
// per-call `share_quality_selection`. Opening the audio picker via
|
||
// ToggleScreenShare must NOT reset it back to the saved config default,
|
||
// or the override the user just made is silently discarded before
|
||
// ConfirmShareScreen reads it into StartScreenShare.
|
||
use crate::config::ShareQuality;
|
||
let mut state = AppState::default();
|
||
// Saved default is Auto; the user picks a different per-call quality.
|
||
assert_eq!(state.config.screen_share.quality, ShareQuality::Auto);
|
||
let _ = update(
|
||
&mut state,
|
||
AppMessage::SelectShareQualityOverride(ShareQuality::High),
|
||
);
|
||
assert_eq!(state.share_quality_selection, ShareQuality::High);
|
||
|
||
// Clicking Share opens the picker — the override must be preserved.
|
||
let _ = update(&mut state, AppMessage::ToggleScreenShare);
|
||
assert!(state.share_picker_open);
|
||
assert_eq!(
|
||
state.share_quality_selection,
|
||
ShareQuality::High,
|
||
"opening the picker must not clobber the inline per-call override"
|
||
);
|
||
|
||
// Confirming reads that same override into the share start.
|
||
let _ = update(&mut state, AppMessage::ConfirmShareScreen);
|
||
assert!(state.share_starting);
|
||
assert_eq!(
|
||
state.share_quality_selection,
|
||
ShareQuality::High,
|
||
"the override the picker preserved must still be what ConfirmShareScreen sends"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn share_start_failure_clears_in_flight_flag() {
|
||
// A failed spawn surfaces as UiEvent::Error (not ScreenShareStopped); the
|
||
// in-flight flag must still clear so the user can retry.
|
||
let mut state = AppState {
|
||
share_picker_open: true,
|
||
..Default::default()
|
||
};
|
||
let _ = update(&mut state, AppMessage::ConfirmShareScreen);
|
||
assert!(state.share_starting);
|
||
|
||
let _ = update(
|
||
&mut state,
|
||
AppMessage::UiEventReceived(UiEvent::Error("boom".into())),
|
||
);
|
||
assert!(!state.share_starting, "error must un-wedge the start flag");
|
||
assert!(!state.self_sharing);
|
||
|
||
// And now the picker can be opened again.
|
||
let _ = update(&mut state, AppMessage::ToggleScreenShare);
|
||
assert!(state.share_picker_open);
|
||
}
|
||
|
||
#[test]
|
||
fn share_audio_dropped_tracks_app_audio_events() {
|
||
// While sharing a specific app, app_audio lost/routed toggles the warning
|
||
// flag; start and stop both reset it so it can't linger across sessions.
|
||
// A specific app was chosen in the picker, so the share is app-specific.
|
||
let mut state = AppState {
|
||
share_audio_selection: Some("mpv".to_string()),
|
||
..Default::default()
|
||
};
|
||
|
||
// Start sharing — flag is clear, and the share is marked app-specific.
|
||
let _ = update(
|
||
&mut state,
|
||
AppMessage::UiEventReceived(UiEvent::ScreenShareStarted),
|
||
);
|
||
assert!(!state.share_audio_dropped);
|
||
assert!(state.share_audio_app_active);
|
||
|
||
// The chosen app's audio stops → warning on.
|
||
let _ = update(
|
||
&mut state,
|
||
AppMessage::UiEventReceived(UiEvent::ShareAudioActive(false)),
|
||
);
|
||
assert!(state.share_audio_dropped);
|
||
|
||
// It plays again → warning off.
|
||
let _ = update(
|
||
&mut state,
|
||
AppMessage::UiEventReceived(UiEvent::ShareAudioActive(true)),
|
||
);
|
||
assert!(!state.share_audio_dropped);
|
||
|
||
// Drop again, then stop sharing → flag reset regardless.
|
||
let _ = update(
|
||
&mut state,
|
||
AppMessage::UiEventReceived(UiEvent::ShareAudioActive(false)),
|
||
);
|
||
assert!(state.share_audio_dropped);
|
||
let _ = update(
|
||
&mut state,
|
||
AppMessage::UiEventReceived(UiEvent::ScreenShareStopped),
|
||
);
|
||
assert!(!state.share_audio_dropped);
|
||
assert!(!state.share_audio_app_active);
|
||
}
|
||
|
||
#[test]
|
||
fn share_audio_active_ignored_unless_app_sharing() {
|
||
// P3 (unscoped events): a late app_audio event from a just-killed host
|
||
// must not flip the warning when we're not sharing a specific app —
|
||
// neither after stop nor on a whole-desktop share.
|
||
|
||
// (a) Whole-desktop share (no app selected): events are ignored.
|
||
let mut state = AppState::default();
|
||
let _ = update(
|
||
&mut state,
|
||
AppMessage::UiEventReceived(UiEvent::ScreenShareStarted),
|
||
);
|
||
assert!(
|
||
!state.share_audio_app_active,
|
||
"no app selected ⇒ not app-specific"
|
||
);
|
||
let _ = update(
|
||
&mut state,
|
||
AppMessage::UiEventReceived(UiEvent::ShareAudioActive(false)),
|
||
);
|
||
assert!(
|
||
!state.share_audio_dropped,
|
||
"whole-desktop share ignores app_audio"
|
||
);
|
||
|
||
// (b) After stop: a straggling event can't resurrect the warning.
|
||
let mut state = AppState {
|
||
share_audio_selection: Some("mpv".to_string()),
|
||
..Default::default()
|
||
};
|
||
let _ = update(
|
||
&mut state,
|
||
AppMessage::UiEventReceived(UiEvent::ScreenShareStarted),
|
||
);
|
||
let _ = update(
|
||
&mut state,
|
||
AppMessage::UiEventReceived(UiEvent::ScreenShareStopped),
|
||
);
|
||
let _ = update(
|
||
&mut state,
|
||
AppMessage::UiEventReceived(UiEvent::ShareAudioActive(false)),
|
||
);
|
||
assert!(!state.share_audio_dropped, "post-stop event is ignored");
|
||
}
|
||
|
||
#[test]
|
||
fn old_pixelpass_picker_offers_whole_desktop_only() {
|
||
// P2 (version skew): when the resolved pixelpass lacks --strict-audio, the
|
||
// picker must drop all per-app options and force the whole-desktop choice,
|
||
// so a per-app share (which would pass the unknown flag) can't be started.
|
||
let mut state = AppState {
|
||
share_picker_open: true,
|
||
share_audio_selection: Some("Firefox".to_string()),
|
||
share_audio_apps: vec!["Firefox".to_string(), "mpv".to_string()],
|
||
..Default::default()
|
||
};
|
||
let _ = update(
|
||
&mut state,
|
||
AppMessage::UiEventReceived(UiEvent::AudioAppsListed {
|
||
apps: vec!["Firefox".to_string(), "mpv".to_string()],
|
||
app_audio_supported: false,
|
||
}),
|
||
);
|
||
assert!(!state.share_app_audio_supported);
|
||
assert!(state.share_audio_apps.is_empty(), "no per-app rows offered");
|
||
assert!(
|
||
state.share_audio_selection.is_none(),
|
||
"forced to whole-desktop"
|
||
);
|
||
|
||
// A supported pixelpass keeps the app list and a valid selection.
|
||
let _ = update(
|
||
&mut state,
|
||
AppMessage::UiEventReceived(UiEvent::AudioAppsListed {
|
||
apps: vec!["Firefox".to_string(), "mpv".to_string()],
|
||
app_audio_supported: true,
|
||
}),
|
||
);
|
||
assert!(state.share_app_audio_supported);
|
||
assert_eq!(state.share_audio_apps.len(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn peer_gate_persists_when_on_and_clears_when_off() {
|
||
let mut config = AppConfig::default();
|
||
let id = SecretKey::generate().public();
|
||
|
||
// A positive threshold is stored, clamped to the slider's METER_MAX ceiling.
|
||
let stored = set_peer_gate_config(&mut config, id, 0.05);
|
||
assert_eq!(stored, 0.05);
|
||
assert_eq!(config.peer_gate.get(&id.to_string()).copied(), Some(0.05));
|
||
assert_eq!(set_peer_gate_config(&mut config, id, 99.0), METER_MAX);
|
||
|
||
// Zero (or negative) means "gate off" — the entry is removed so the
|
||
// config doesn't carry a disabled gate.
|
||
let stored = set_peer_gate_config(&mut config, id, 0.0);
|
||
assert_eq!(stored, 0.0);
|
||
assert!(!config.peer_gate.contains_key(&id.to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn peer_volume_persists_non_unity_and_clears_at_unity() {
|
||
let mut config = AppConfig::default();
|
||
let id = SecretKey::generate().public();
|
||
|
||
// A non-unity value is clamped into range and stored.
|
||
let stored = set_peer_volume_config(&mut config, id, 1.5);
|
||
assert_eq!(stored, 1.5);
|
||
assert_eq!(config.peer_volume.get(&id.to_string()).copied(), Some(1.5));
|
||
|
||
// Out-of-range values clamp to the slider bounds.
|
||
assert_eq!(set_peer_volume_config(&mut config, id, 5.0), 2.0);
|
||
assert_eq!(set_peer_volume_config(&mut config, id, -1.0), 0.0);
|
||
|
||
// Returning to unity removes the entry (unity is the implicit default),
|
||
// so the config doesn't accumulate no-op entries.
|
||
let stored = set_peer_volume_config(&mut config, id, 1.0);
|
||
assert_eq!(stored, 1.0);
|
||
assert!(!config.peer_volume.contains_key(&id.to_string()));
|
||
}
|
||
|
||
fn assert_specific_position(position: iced::window::Position, x: i32, y: i32) {
|
||
match position {
|
||
iced::window::Position::Specific(p) => {
|
||
assert_eq!(p.x, x as f32);
|
||
assert_eq!(p.y, y as f32);
|
||
}
|
||
other => panic!("expected Specific, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn clamp_window_position_preserves_fully_inside_bounds() {
|
||
let bounds = ScreenBounds {
|
||
x: 0,
|
||
y: 0,
|
||
width: 1200,
|
||
height: 900,
|
||
};
|
||
|
||
assert_specific_position(
|
||
clamp_window_position(120, 80, 900, 760, Some(bounds)),
|
||
120,
|
||
80,
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn clamp_window_position_pulls_visible_edge_back_inside_bounds() {
|
||
let bounds = ScreenBounds {
|
||
x: 0,
|
||
y: 0,
|
||
width: 1000,
|
||
height: 800,
|
||
};
|
||
|
||
assert_specific_position(
|
||
clamp_window_position(760, 650, 300, 200, Some(bounds)),
|
||
700,
|
||
600,
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn clamp_window_position_centers_disconnected_monitor_position() {
|
||
let bounds = ScreenBounds {
|
||
x: 0,
|
||
y: 0,
|
||
width: 1000,
|
||
height: 800,
|
||
};
|
||
|
||
assert!(matches!(
|
||
clamp_window_position(5000, 5000, 900, 760, Some(bounds)),
|
||
iced::window::Position::Centered
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn clamp_window_position_preserves_negative_origin_monitor_position() {
|
||
let bounds = ScreenBounds {
|
||
x: -1920,
|
||
y: -200,
|
||
width: 1920,
|
||
height: 1080,
|
||
};
|
||
|
||
assert_specific_position(
|
||
clamp_window_position(-1800, -120, 900, 760, Some(bounds)),
|
||
-1800,
|
||
-120,
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn clamp_window_position_without_bounds_preserves_sane_and_rejects_absurd() {
|
||
assert_specific_position(clamp_window_position(120, -40, 900, 760, None), 120, -40);
|
||
assert!(matches!(
|
||
clamp_window_position(32_001, 0, 900, 760, None),
|
||
iced::window::Position::Centered
|
||
));
|
||
assert!(matches!(
|
||
clamp_window_position(0, -32_001, 900, 760, None),
|
||
iced::window::Position::Centered
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn x11_restores_saved_window_position() {
|
||
// On X11 (is_wayland = false) a saved position becomes Specific(x, y).
|
||
match initial_window_position(Some(120), Some(-40), 900, 760, false) {
|
||
iced::window::Position::Specific(p) => {
|
||
assert_eq!(p.x, 120.0);
|
||
assert_eq!(p.y, -40.0); // negative coords (left/above primary) preserved
|
||
}
|
||
other => panic!("expected Specific, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn wayland_always_centers_even_with_saved_position() {
|
||
assert!(matches!(
|
||
initial_window_position(Some(120), Some(40), 900, 760, true),
|
||
iced::window::Position::Centered
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn missing_or_partial_saved_position_centers() {
|
||
assert!(matches!(
|
||
initial_window_position(None, None, 900, 760, false),
|
||
iced::window::Position::Centered
|
||
));
|
||
// A half-saved position (one axis missing) is not enough to restore.
|
||
assert!(matches!(
|
||
initial_window_position(Some(10), None, 900, 760, false),
|
||
iced::window::Position::Centered
|
||
));
|
||
assert!(matches!(
|
||
initial_window_position(None, Some(10), 900, 760, false),
|
||
iced::window::Position::Centered
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn format_duration_renders_mss_and_hmmss() {
|
||
assert_eq!(format_duration(0), "0:00");
|
||
assert_eq!(format_duration(5), "0:05");
|
||
assert_eq!(format_duration(59), "0:59");
|
||
assert_eq!(format_duration(65), "1:05");
|
||
assert_eq!(format_duration(600), "10:00");
|
||
assert_eq!(format_duration(3599), "59:59");
|
||
// Past an hour switches to h:mm:ss with zero-padded minutes/seconds.
|
||
assert_eq!(format_duration(3600), "1:00:00");
|
||
assert_eq!(format_duration(3661), "1:01:01");
|
||
assert_eq!(format_duration(3725), "1:02:05");
|
||
}
|
||
|
||
#[test]
|
||
fn format_relative_ago_buckets_by_minute() {
|
||
use std::time::Duration;
|
||
// Under a minute reads "just now" (no seconds-level churn).
|
||
assert_eq!(format_relative_ago(Duration::from_secs(0)), "just now");
|
||
assert_eq!(format_relative_ago(Duration::from_secs(59)), "just now");
|
||
// Minutes.
|
||
assert_eq!(format_relative_ago(Duration::from_secs(60)), "1m ago");
|
||
assert_eq!(format_relative_ago(Duration::from_secs(125)), "2m ago");
|
||
assert_eq!(format_relative_ago(Duration::from_secs(59 * 60)), "59m ago");
|
||
// Hours, with minutes only when non-zero.
|
||
assert_eq!(format_relative_ago(Duration::from_secs(3600)), "1h ago");
|
||
assert_eq!(
|
||
format_relative_ago(Duration::from_secs(3600 + 120)),
|
||
"1h 2m ago"
|
||
);
|
||
assert_eq!(
|
||
format_relative_ago(Duration::from_secs(23 * 3600 + 59 * 60)),
|
||
"23h 59m ago"
|
||
);
|
||
// Days, with hours only when non-zero.
|
||
assert_eq!(
|
||
format_relative_ago(Duration::from_secs(24 * 3600)),
|
||
"1d ago"
|
||
);
|
||
assert_eq!(
|
||
format_relative_ago(Duration::from_secs(50 * 3600)),
|
||
"2d 2h ago"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn settings_categories_are_stable_and_grouped_for_navigation() {
|
||
use super::SettingsCategory;
|
||
let labels: Vec<_> = SettingsCategory::ALL.iter().map(|c| c.label()).collect();
|
||
assert_eq!(
|
||
labels,
|
||
vec![
|
||
"Audio",
|
||
"Hotkeys",
|
||
"Recording",
|
||
"Profile",
|
||
"Appearance",
|
||
"Network",
|
||
"Advanced",
|
||
"Notifications",
|
||
"Games"
|
||
]
|
||
);
|
||
assert_eq!(SettingsCategory::Audio.hint(), "Devices, mic gate, echo");
|
||
assert_eq!(SettingsCategory::Profile.hint(), "Avatar and identity");
|
||
assert_eq!(SettingsCategory::Advanced.hint(), "Screen sharing");
|
||
assert_eq!(
|
||
SettingsCategory::Games.hint(),
|
||
"Detection, presence, backgrounds"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn home_layout_prioritizes_connect_on_empty_home() {
|
||
use super::{HomeLayoutMode, home_layout_mode};
|
||
assert_eq!(
|
||
home_layout_mode(1280.0, false, false),
|
||
HomeLayoutMode::FocusedEmpty
|
||
);
|
||
assert_eq!(
|
||
home_layout_mode(760.0, false, false),
|
||
HomeLayoutMode::Stacked
|
||
);
|
||
assert_eq!(
|
||
home_layout_mode(1280.0, true, false),
|
||
HomeLayoutMode::ThreeColumn
|
||
);
|
||
assert_eq!(
|
||
home_layout_mode(1100.0, true, true),
|
||
HomeLayoutMode::Stacked
|
||
);
|
||
}
|
||
|
||
use super::{CHAT_MIN_H, PARTICIPANTS_MIN_W, clamp_chat_height, clamp_participants_width};
|
||
|
||
#[test]
|
||
fn participants_width_clamps_to_min_and_leaves_room_for_controls() {
|
||
let window_w = 900.0;
|
||
// Mid-range value passes through unchanged.
|
||
assert_eq!(clamp_participants_width(500.0, window_w), 500.0);
|
||
// Below the minimum snaps up to it.
|
||
assert_eq!(clamp_participants_width(50.0, window_w), PARTICIPANTS_MIN_W);
|
||
// Too wide leaves at least CONTROLS_MIN_W (220) for the controls panel.
|
||
assert_eq!(
|
||
clamp_participants_width(window_w, window_w),
|
||
window_w - 220.0
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn chat_height_clamps_to_min_and_leaves_room_above() {
|
||
let window_h = 760.0;
|
||
assert_eq!(clamp_chat_height(200.0, window_h), 200.0);
|
||
assert_eq!(clamp_chat_height(10.0, window_h), CHAT_MIN_H);
|
||
// Too tall leaves at least ABOVE_CHAT_MIN_H (300) above the dock.
|
||
assert_eq!(clamp_chat_height(window_h, window_h), window_h - 300.0);
|
||
}
|
||
|
||
#[test]
|
||
fn sanitize_chat_strips_control_chars_and_collapses_whitespace() {
|
||
use super::sanitize_chat;
|
||
// Plain text is unchanged.
|
||
assert_eq!(sanitize_chat("hello world"), "hello world");
|
||
// Leading/trailing whitespace trimmed; interior runs collapsed.
|
||
assert_eq!(sanitize_chat(" hi there "), "hi there");
|
||
// Control chars (NUL, CR, LF, TAB, ANSI ESC) become spaces, then collapse.
|
||
// The ESC of an ANSI sequence is stripped; the printable "[31m" remains inert text.
|
||
assert_eq!(sanitize_chat("a\u{0}b\r\nc\td\u{1b}[31m"), "a b c d [31m");
|
||
// An all-control / all-whitespace message sanitizes to empty.
|
||
assert_eq!(sanitize_chat("\u{0}\r\n\t "), "");
|
||
// Unicode/emoji text is preserved.
|
||
assert_eq!(sanitize_chat("héllo 🎙 世界"), "héllo 🎙 世界");
|
||
}
|
||
|
||
#[test]
|
||
fn sanitize_chat_caps_length() {
|
||
use super::{CHAT_MSG_MAX_CHARS, sanitize_chat};
|
||
let long = "x".repeat(CHAT_MSG_MAX_CHARS + 500);
|
||
assert_eq!(sanitize_chat(&long).chars().count(), CHAT_MSG_MAX_CHARS);
|
||
}
|
||
|
||
#[test]
|
||
fn short_id_is_panic_free_on_short_and_unicode_ids() {
|
||
use super::short_id;
|
||
// Normal long hex id → first 8 chars.
|
||
assert_eq!(short_id("abcdef0123456789"), "abcdef01");
|
||
// Shorter-than-8 id must not panic (the old `[..8]` slice would).
|
||
assert_eq!(short_id("abc"), "abc");
|
||
assert_eq!(short_id(""), "");
|
||
// Multi-byte chars: take 8 *chars*, never split a byte boundary.
|
||
assert_eq!(short_id("héllo 世界 more"), "héllo 世界");
|
||
}
|
||
|
||
#[test]
|
||
fn divider_clamps_are_finite_on_a_tiny_window() {
|
||
// A window smaller than the reserves must not produce NaN/inverted ranges.
|
||
let pw = clamp_participants_width(300.0, 100.0);
|
||
assert!(pw.is_finite() && pw >= PARTICIPANTS_MIN_W);
|
||
let ch = clamp_chat_height(300.0, 100.0);
|
||
assert!(ch.is_finite() && ch >= CHAT_MIN_H);
|
||
}
|
||
|
||
#[test]
|
||
fn controls_and_drawer_width_clamps() {
|
||
use super::{CHAT_MIN_W, CONTROLS_MIN_W, clamp_chat_drawer_width, clamp_controls_width};
|
||
let window_w = 1000.0;
|
||
// Mid-range passes through.
|
||
assert_eq!(clamp_controls_width(300.0, window_w), 300.0);
|
||
assert_eq!(clamp_chat_drawer_width(320.0, window_w), 320.0);
|
||
// Below minimum snaps up.
|
||
assert_eq!(clamp_controls_width(10.0, window_w), CONTROLS_MIN_W);
|
||
assert_eq!(clamp_chat_drawer_width(10.0, window_w), CHAT_MIN_W);
|
||
// Tiny window stays finite and at/above the minimum (no inverted range).
|
||
let c = clamp_controls_width(400.0, 100.0);
|
||
assert!(c.is_finite() && c >= CONTROLS_MIN_W);
|
||
let d = clamp_chat_drawer_width(400.0, 100.0);
|
||
assert!(d.is_finite() && d >= CHAT_MIN_W);
|
||
}
|
||
|
||
#[test]
|
||
fn playlist_drawer_width_clamps() {
|
||
use super::{CHAT_MIN_W, CONTROLS_MIN_W, PARTICIPANTS_MIN_W, clamp_playlist_drawer_width};
|
||
let window_w = 1000.0;
|
||
let max = window_w - PARTICIPANTS_MIN_W - CONTROLS_MIN_W;
|
||
// Mid-range passes through.
|
||
assert_eq!(clamp_playlist_drawer_width(320.0, window_w), 320.0);
|
||
// Below minimum snaps up.
|
||
assert_eq!(clamp_playlist_drawer_width(10.0, window_w), CHAT_MIN_W);
|
||
// Above maximum leaves the rest of the room at its reserved width.
|
||
assert_eq!(clamp_playlist_drawer_width(800.0, window_w), max);
|
||
// Tiny window stays finite and at/above the minimum (no inverted range).
|
||
let d = clamp_playlist_drawer_width(400.0, 100.0);
|
||
assert!(d.is_finite() && d >= CHAT_MIN_W);
|
||
}
|
||
|
||
#[test]
|
||
fn now_playing_label_prefers_tuned_peer() {
|
||
assert_eq!(
|
||
now_playing_label(Some("Alice"), Some("local.flac")),
|
||
"Tuned in to Alice"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn now_playing_label_uses_current_track_when_local() {
|
||
assert_eq!(now_playing_label(None, Some("local.flac")), "local.flac");
|
||
}
|
||
|
||
#[test]
|
||
fn now_playing_label_handles_empty_player() {
|
||
assert_eq!(now_playing_label(None, None), "Nothing playing");
|
||
}
|
||
|
||
use crate::notify::Sound;
|
||
use iroh::EndpointId;
|
||
use std::collections::HashSet;
|
||
|
||
const W: f32 = 200.0;
|
||
|
||
#[test]
|
||
fn gate_drag_maps_left_edge_to_zero() {
|
||
assert_eq!(GateMeter::x_to_threshold(0.0, W), 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn gate_drag_maps_right_edge_to_full_scale() {
|
||
assert!((GateMeter::x_to_threshold(W, W) - METER_MAX).abs() < 1e-6);
|
||
}
|
||
|
||
#[test]
|
||
fn gate_drag_maps_midpoint_to_half_scale() {
|
||
assert!((GateMeter::x_to_threshold(W / 2.0, W) - METER_MAX / 2.0).abs() < 1e-6);
|
||
}
|
||
|
||
#[test]
|
||
fn gate_drag_clamps_out_of_bounds() {
|
||
// Dragging past either edge clamps to the axis ends (no overshoot).
|
||
assert_eq!(GateMeter::x_to_threshold(-50.0, W), 0.0);
|
||
assert!((GateMeter::x_to_threshold(W + 80.0, W) - METER_MAX).abs() < 1e-6);
|
||
}
|
||
|
||
#[test]
|
||
fn gate_drag_zero_width_is_finite() {
|
||
// A degenerate bound (pre-layout) must not divide by zero / produce NaN.
|
||
let t = GateMeter::x_to_threshold(10.0, 0.0);
|
||
assert!(t.is_finite());
|
||
assert!((0.0..=METER_MAX).contains(&t));
|
||
}
|
||
|
||
/// A distinct, real `EndpointId` (via the same path the network tests use).
|
||
fn id() -> EndpointId {
|
||
iroh::EndpointAddr::from(iroh::SecretKey::generate().public()).id
|
||
}
|
||
|
||
#[test]
|
||
fn first_dial_does_not_chime() {
|
||
let mut connecting = HashSet::new();
|
||
let ever = HashSet::new(); // never connected
|
||
let peer = id();
|
||
// A peer we've never linked with is just an initial connect, not a reconnect.
|
||
assert_eq!(reconnect_attempt_chime(&mut connecting, &ever, peer), None);
|
||
assert!(connecting.contains(&peer)); // but it is now marked connecting
|
||
}
|
||
|
||
#[test]
|
||
fn first_connected_does_not_chime() {
|
||
let mut connecting = HashSet::from([id()]);
|
||
let mut ever = HashSet::new();
|
||
let peer = id();
|
||
connecting.insert(peer);
|
||
// First successful link: record it, but no "reconnected" chime.
|
||
assert_eq!(reconnected_chime(&mut connecting, &mut ever, peer), None);
|
||
assert!(ever.contains(&peer));
|
||
assert!(!connecting.contains(&peer)); // connecting state cleared
|
||
}
|
||
|
||
#[test]
|
||
fn reconnect_attempt_chimes_once_then_stays_silent_on_redials() {
|
||
let peer = id();
|
||
let mut connecting = HashSet::new();
|
||
let ever = HashSet::from([peer]); // previously connected
|
||
|
||
// First drop → one ReconnectAttempt chime.
|
||
assert_eq!(
|
||
reconnect_attempt_chime(&mut connecting, &ever, peer),
|
||
Some(Sound::ReconnectAttempt)
|
||
);
|
||
// The supervisor redials repeatedly while still down — must NOT re-chime.
|
||
assert_eq!(reconnect_attempt_chime(&mut connecting, &ever, peer), None);
|
||
assert_eq!(reconnect_attempt_chime(&mut connecting, &ever, peer), None);
|
||
}
|
||
|
||
#[test]
|
||
fn full_outage_cycle_chimes_attempt_then_reconnected_each_time() {
|
||
let peer = id();
|
||
let mut connecting = HashSet::new();
|
||
let mut ever = HashSet::new();
|
||
|
||
// Initial connect: silent, records the peer.
|
||
assert_eq!(reconnected_chime(&mut connecting, &mut ever, peer), None);
|
||
|
||
// Outage 1: attempt chimes once, recovery chimes "reconnected".
|
||
assert_eq!(
|
||
reconnect_attempt_chime(&mut connecting, &ever, peer),
|
||
Some(Sound::ReconnectAttempt)
|
||
);
|
||
assert_eq!(
|
||
reconnected_chime(&mut connecting, &mut ever, peer),
|
||
Some(Sound::Reconnected)
|
||
);
|
||
|
||
// Outage 2: a fresh disconnect chimes again (per-outage, not once-ever).
|
||
assert_eq!(
|
||
reconnect_attempt_chime(&mut connecting, &ever, peer),
|
||
Some(Sound::ReconnectAttempt)
|
||
);
|
||
assert_eq!(
|
||
reconnected_chime(&mut connecting, &mut ever, peer),
|
||
Some(Sound::Reconnected)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_push_chat_single() {
|
||
use super::{ChatEntry, push_chat};
|
||
let mut messages = Vec::new();
|
||
let entry = ChatEntry {
|
||
name: "Alice".to_string(),
|
||
text: "Hello".to_string(),
|
||
mine: true,
|
||
from: None,
|
||
attachment: None,
|
||
};
|
||
push_chat(&mut messages, entry);
|
||
assert_eq!(messages.len(), 1);
|
||
assert_eq!(messages[0].name, "Alice");
|
||
assert_eq!(messages[0].text, "Hello");
|
||
assert!(messages[0].mine);
|
||
}
|
||
|
||
#[test]
|
||
fn test_push_chat_below_cap() {
|
||
use super::{CHAT_HISTORY_MAX, ChatEntry, push_chat};
|
||
let mut messages = Vec::new();
|
||
for i in 0..CHAT_HISTORY_MAX - 10 {
|
||
push_chat(
|
||
&mut messages,
|
||
ChatEntry {
|
||
name: format!("User{}", i),
|
||
text: format!("Msg{}", i),
|
||
mine: i % 2 == 0,
|
||
from: None,
|
||
attachment: None,
|
||
},
|
||
);
|
||
}
|
||
assert_eq!(messages.len(), CHAT_HISTORY_MAX - 10);
|
||
assert_eq!(messages[0].name, "User0");
|
||
assert_eq!(messages[0].text, "Msg0");
|
||
assert_eq!(
|
||
messages[messages.len() - 1].name,
|
||
format!("User{}", CHAT_HISTORY_MAX - 11)
|
||
);
|
||
assert_eq!(
|
||
messages[messages.len() - 1].text,
|
||
format!("Msg{}", CHAT_HISTORY_MAX - 11)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_push_chat_above_cap() {
|
||
use super::{CHAT_HISTORY_MAX, ChatEntry, push_chat};
|
||
let mut messages = Vec::new();
|
||
let total_pushes = CHAT_HISTORY_MAX + 5;
|
||
for i in 0..total_pushes {
|
||
push_chat(
|
||
&mut messages,
|
||
ChatEntry {
|
||
name: format!("User{}", i),
|
||
text: format!("Msg{}", i),
|
||
mine: i % 2 == 0,
|
||
from: None,
|
||
attachment: None,
|
||
},
|
||
);
|
||
}
|
||
assert_eq!(messages.len(), CHAT_HISTORY_MAX);
|
||
// The first 5 should be dropped. First remaining should be index 5.
|
||
assert_eq!(messages[0].name, "User5");
|
||
assert_eq!(messages[0].text, "Msg5");
|
||
// The last remaining should be index total_pushes - 1.
|
||
assert_eq!(
|
||
messages[messages.len() - 1].name,
|
||
format!("User{}", total_pushes - 1)
|
||
);
|
||
assert_eq!(
|
||
messages[messages.len() - 1].text,
|
||
format!("Msg{}", total_pushes - 1)
|
||
);
|
||
}
|
||
}
|