Files
peerspeak/src/app/mod.rs
T
molluskandClaude Opus 4.8 faad8ce26a screenshare: honor viewer settings for the VLC player too
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>
2026-07-06 13:22:31 -04:00

10028 lines
404 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 23) 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)
);
}
}