Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbba4b644e | ||
|
|
c5375e200a | ||
|
|
06e97b9f50 | ||
|
|
601ec92181 | ||
|
|
713526b2a8 | ||
|
|
450121b591 | ||
|
|
eab9357f23 | ||
|
|
70a0e6798f |
+286
-9
@@ -208,6 +208,7 @@ pub enum AppMessage {
|
||||
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),
|
||||
@@ -290,6 +291,15 @@ pub enum AppMessage {
|
||||
/// 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 (spawns/kills a pixelpass host).
|
||||
@@ -335,8 +345,11 @@ pub struct AppState {
|
||||
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>,
|
||||
peers: HashMap<EndpointId, PeerState>,
|
||||
peer_volumes: HashMap<EndpointId, f32>,
|
||||
audio_levels: HashMap<EndpointId, f32>,
|
||||
/// Peers we've locally muted (their audio isn't mixed into our output).
|
||||
locally_muted: HashSet<EndpointId>,
|
||||
@@ -461,6 +474,16 @@ impl Default for AppState {
|
||||
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();
|
||||
@@ -470,6 +493,7 @@ impl Default for AppState {
|
||||
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);
|
||||
|
||||
Self {
|
||||
// Pre-fill the nickname with the last one used (or "Peer" by default).
|
||||
@@ -489,8 +513,8 @@ impl Default for AppState {
|
||||
selected_input,
|
||||
selected_output,
|
||||
config,
|
||||
background_image,
|
||||
peers: HashMap::new(),
|
||||
peer_volumes: HashMap::new(),
|
||||
audio_levels: HashMap::new(),
|
||||
locally_muted: HashSet::new(),
|
||||
call_started: None,
|
||||
@@ -533,6 +557,15 @@ 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)
|
||||
}
|
||||
|
||||
pub fn run_gui() -> iced::Result {
|
||||
// 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
|
||||
@@ -540,7 +573,7 @@ pub fn run_gui() -> iced::Result {
|
||||
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, is_wayland());
|
||||
iced::application(AppState::default, update, view)
|
||||
iced::application(AppState::default, update, view_with_background)
|
||||
.title("PeerSpeak P2P Voice Chat")
|
||||
.theme(theme)
|
||||
.subscription(subscription)
|
||||
@@ -720,6 +753,34 @@ fn set_peer_pan_config(config: &mut AppConfig, id: EndpointId, pan: f32) -> f32
|
||||
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
|
||||
@@ -1014,13 +1075,17 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
state.config.save();
|
||||
}
|
||||
AppMessage::PeerVolumeChanged(id, vol) => {
|
||||
state.peer_volumes.insert(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));
|
||||
@@ -1347,6 +1412,73 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
}
|
||||
}
|
||||
}
|
||||
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::ToggleDrawerChat => {
|
||||
state.drawer_chat_open = !state.drawer_chat_open;
|
||||
}
|
||||
@@ -2018,6 +2150,40 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.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);
|
||||
let Some(bytes) = state.background_image.clone() else {
|
||||
return content;
|
||||
};
|
||||
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()
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -2036,6 +2202,16 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
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 {
|
||||
@@ -2297,6 +2473,35 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.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).
|
||||
@@ -2645,6 +2850,9 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
vertical_space(section_gap),
|
||||
section_header("Theme"),
|
||||
theme_section,
|
||||
vertical_space(section_gap),
|
||||
section_header("Background"),
|
||||
background_section,
|
||||
]
|
||||
.spacing(10)
|
||||
.width(iced::Length::Fill)
|
||||
@@ -2830,7 +3038,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.height(iced::Length::Fill)
|
||||
.padding(24)
|
||||
.center_x(iced::Length::Fill)
|
||||
.style(c_style(color_crust, Color::TRANSPARENT, 0.0));
|
||||
.style(c_style(root_bg, Color::TRANSPARENT, 0.0));
|
||||
|
||||
return with_regenerate_confirm(settings_screen.into(), state);
|
||||
}
|
||||
@@ -2889,7 +3097,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
)
|
||||
.width(iced::Length::Fill)
|
||||
.height(iced::Length::Fill)
|
||||
.style(c_style(color_crust, Color::TRANSPARENT, 0.0));
|
||||
.style(c_style(root_bg, Color::TRANSPARENT, 0.0));
|
||||
|
||||
with_hotkey_info(with_layout_picker(home.into(), state), state)
|
||||
} else {
|
||||
@@ -3181,11 +3389,18 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
].spacing(8);
|
||||
|
||||
// Peer volume slider
|
||||
let current_vol = state.peer_volumes.get(peer_id).copied().unwrap_or(1.0);
|
||||
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)
|
||||
);
|
||||
|
||||
@@ -3204,6 +3419,28 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.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![
|
||||
@@ -3590,7 +3827,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.padding(15)
|
||||
.width(iced::Length::Fill)
|
||||
.height(iced::Length::Fill)
|
||||
.style(c_style(color_crust, Color::TRANSPARENT, 0.0));
|
||||
.style(c_style(root_bg, Color::TRANSPARENT, 0.0));
|
||||
|
||||
with_hotkey_info(
|
||||
with_pixelpass_help(with_layout_picker(room.into(), state), state),
|
||||
@@ -4733,8 +4970,48 @@ impl Program<AppMessage> for Icon {
|
||||
mod tests {
|
||||
use super::{
|
||||
format_duration, initial_window_position, reconnect_attempt_chime, reconnected_chime,
|
||||
GateMeter, METER_MAX,
|
||||
set_peer_gate_config, set_peer_volume_config, AppConfig, GateMeter, METER_MAX,
|
||||
};
|
||||
use iroh::SecretKey;
|
||||
|
||||
#[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()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn x11_restores_saved_window_position() {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Custom UI background (W16): turn a user-picked image into a capped PNG to
|
||||
//! render behind the whole UI, plus the legibility scrim drawn over it.
|
||||
//!
|
||||
//! This is the pure, unit-testable seam — `process_background` decodes/downscales
|
||||
//! arbitrary input defensively (same caution as avatar uploads) and `scrim_color`
|
||||
//! computes the overlay tint. The file I/O, the `rfd` picker, and the iced
|
||||
//! `stack!` that layers image → scrim → UI all live at the app edge in
|
||||
//! `src/app/mod.rs`. The background is **local-only** — never sent to peers — so
|
||||
//! there's no gossip-frame budget here (hence a much larger size cap than avatars).
|
||||
|
||||
use iced::Color;
|
||||
|
||||
/// Longest side a custom background is downscaled to on ingest (aspect preserved,
|
||||
/// never upscaled). Big enough to look crisp filling the window, small enough to
|
||||
/// decode and cache cheaply. Local-only, so this is generous vs. the avatar cap.
|
||||
pub const BACKGROUND_MAX_PX: u32 = 1920;
|
||||
|
||||
/// Default scrim strength. `0.0` = the image shows at full strength, `1.0` = it's
|
||||
/// fully hidden behind the theme's base colour. Half keeps a photo clearly visible
|
||||
/// while text and cards stay readable over it.
|
||||
pub const DEFAULT_DIM: f32 = 0.5;
|
||||
|
||||
/// Decode an arbitrary user image (png/jpeg/…), downscale so its longest side is
|
||||
/// at most [`BACKGROUND_MAX_PX`] (aspect preserved; smaller images are left as-is,
|
||||
/// never upscaled), and re-encode as PNG bytes ready to write to disk. Decoding is
|
||||
/// bounded by the `image` crate's defaults so a malformed/huge file is rejected
|
||||
/// rather than exhausting memory. Errors come back as a message for the UI.
|
||||
pub fn process_background(raw: &[u8]) -> Result<Vec<u8>, String> {
|
||||
let img = image::load_from_memory(raw).map_err(|e| format!("Couldn't read image: {e}"))?;
|
||||
// Only ever shrink. `resize` preserves aspect, fitting within the box; a
|
||||
// higher-quality filter than `thumbnail` since a background fills the window.
|
||||
let scaled = if img.width() > BACKGROUND_MAX_PX || img.height() > BACKGROUND_MAX_PX {
|
||||
img.resize(
|
||||
BACKGROUND_MAX_PX,
|
||||
BACKGROUND_MAX_PX,
|
||||
image::imageops::FilterType::Lanczos3,
|
||||
)
|
||||
} else {
|
||||
img
|
||||
};
|
||||
let mut png = std::io::Cursor::new(Vec::new());
|
||||
scaled
|
||||
.write_to(&mut png, image::ImageFormat::Png)
|
||||
.map_err(|e| format!("Couldn't encode image: {e}"))?;
|
||||
Ok(png.into_inner())
|
||||
}
|
||||
|
||||
/// The legibility scrim drawn between the background image and the UI: the active
|
||||
/// theme's base colour at `dim` alpha (clamped to `0.0..=1.0`). A higher `dim`
|
||||
/// recedes the image so body text and panel chrome stay readable, and it re-tints
|
||||
/// per theme since `base` comes from the active palette.
|
||||
pub fn scrim_color(base: Color, dim: f32) -> Color {
|
||||
Color { a: dim.clamp(0.0, 1.0), ..base }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A valid PNG of the given size, as raw bytes (test helper).
|
||||
fn make_png(w: u32, h: u32) -> Vec<u8> {
|
||||
let img = image::DynamicImage::new_rgb8(w, h);
|
||||
let mut buf = std::io::Cursor::new(Vec::new());
|
||||
img.write_to(&mut buf, image::ImageFormat::Png).unwrap();
|
||||
buf.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_background_downscales_oversized() {
|
||||
// A 4000x2000 image is shrunk so the longest side is BACKGROUND_MAX_PX,
|
||||
// aspect preserved, and the result re-decodes as a PNG within bounds.
|
||||
let raw = make_png(4000, 2000);
|
||||
let png = process_background(&raw).expect("should process");
|
||||
let decoded = image::load_from_memory(&png).unwrap();
|
||||
assert_eq!(decoded.width().max(decoded.height()), BACKGROUND_MAX_PX);
|
||||
assert_eq!(decoded.width(), BACKGROUND_MAX_PX);
|
||||
assert_eq!(decoded.height(), BACKGROUND_MAX_PX / 2); // 2:1 aspect kept
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_background_leaves_small_images_unscaled() {
|
||||
let raw = make_png(640, 480);
|
||||
let png = process_background(&raw).expect("should process");
|
||||
let decoded = image::load_from_memory(&png).unwrap();
|
||||
assert_eq!((decoded.width(), decoded.height()), (640, 480));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_background_rejects_non_image() {
|
||||
assert!(process_background(b"definitely not an image").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrim_color_sets_alpha_and_keeps_rgb() {
|
||||
let base = Color::from_rgb(0.1, 0.2, 0.3);
|
||||
let s = scrim_color(base, 0.5);
|
||||
assert_eq!((s.r, s.g, s.b), (0.1, 0.2, 0.3));
|
||||
assert!((s.a - 0.5).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrim_color_clamps_dim() {
|
||||
let base = Color::BLACK;
|
||||
assert!((scrim_color(base, -1.0).a - 0.0).abs() < f32::EPSILON);
|
||||
assert!((scrim_color(base, 2.0).a - 1.0).abs() < f32::EPSILON);
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,10 @@ fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_background_dim() -> f32 {
|
||||
crate::background::DEFAULT_DIM
|
||||
}
|
||||
|
||||
fn default_volume() -> f32 {
|
||||
1.0
|
||||
}
|
||||
@@ -185,6 +189,16 @@ pub struct AppConfig {
|
||||
/// Our chosen avatar (W4): monogram fallback or a bundled preset.
|
||||
#[serde(default)]
|
||||
pub avatar: crate::avatar::Avatar,
|
||||
/// Custom UI background image (W16): path to the downscaled PNG we wrote into
|
||||
/// the config dir (see `background_path`). `None` = use the theme background.
|
||||
/// Local-only; never sent to peers.
|
||||
#[serde(default)]
|
||||
pub background: Option<String>,
|
||||
/// Scrim strength drawn over the custom background for legibility (0.0 = image
|
||||
/// at full strength, 1.0 = fully hidden behind the theme base). See
|
||||
/// `crate::background::scrim_color`.
|
||||
#[serde(default = "default_background_dim")]
|
||||
pub background_dim: f32,
|
||||
/// What a call recording captures (mixed / per-peer stems / both).
|
||||
#[serde(default)]
|
||||
pub recording_mode: RecordingMode,
|
||||
@@ -241,6 +255,15 @@ pub struct AppConfig {
|
||||
/// keyed by peer node id string. Local preference only.
|
||||
#[serde(default)]
|
||||
pub peer_pan: HashMap<String, f32>,
|
||||
/// Per-peer listener-side volume/gain (`1.0` = unity), keyed by peer node id
|
||||
/// string. Local preference only; never sent to peers. Absent entry = unity.
|
||||
#[serde(default)]
|
||||
pub peer_volume: HashMap<String, f32>,
|
||||
/// Per-peer listener-side noise-gate threshold (normalized RMS, `0.0` = off),
|
||||
/// keyed by peer node id string. Local preference only; never sent to peers.
|
||||
/// Absent entry = gate disabled (pass-through).
|
||||
#[serde(default)]
|
||||
pub peer_gate: HashMap<String, f32>,
|
||||
/// Focused app-local keyboard shortcuts.
|
||||
#[serde(default)]
|
||||
pub hotkeys: crate::hotkeys::HotkeyMap,
|
||||
@@ -280,6 +303,8 @@ impl Default for AppConfig {
|
||||
room_layout: RoomLayout::default(),
|
||||
theme: AppTheme::default(),
|
||||
avatar: crate::avatar::Avatar::default(),
|
||||
background: None,
|
||||
background_dim: default_background_dim(),
|
||||
recording_mode: RecordingMode::default(),
|
||||
custom_sound_self_join: None,
|
||||
custom_sound_peer_join: None,
|
||||
@@ -301,6 +326,8 @@ impl Default for AppConfig {
|
||||
recents: Vec::new(),
|
||||
peer_eq: HashMap::new(),
|
||||
peer_pan: HashMap::new(),
|
||||
peer_volume: HashMap::new(),
|
||||
peer_gate: HashMap::new(),
|
||||
hotkeys: crate::hotkeys::HotkeyMap::default(),
|
||||
window_width: default_window_width(),
|
||||
window_height: default_window_height(),
|
||||
@@ -348,6 +375,17 @@ impl AppConfig {
|
||||
})
|
||||
}
|
||||
|
||||
/// Path the processed custom-background PNG (W16) is written to, alongside
|
||||
/// `config.json` in the app config dir. We store our own downscaled copy here
|
||||
/// (rather than base64 in the config) so the JSON stays small.
|
||||
pub fn background_path() -> Option<PathBuf> {
|
||||
dirs::config_dir().map(|mut p| {
|
||||
p.push("peerspeak");
|
||||
p.push("background.png");
|
||||
p
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load() -> Self {
|
||||
if let Some(path) = Self::config_path()
|
||||
&& let Ok(contents) = fs::read_to_string(&path)
|
||||
@@ -430,6 +468,8 @@ mod tests {
|
||||
// shortcut settings.
|
||||
assert!(deserialized.peer_eq.is_empty());
|
||||
assert!(deserialized.peer_pan.is_empty());
|
||||
assert!(deserialized.peer_volume.is_empty());
|
||||
assert!(deserialized.peer_gate.is_empty());
|
||||
assert_eq!(
|
||||
crate::hotkeys::format_binding(
|
||||
deserialized
|
||||
|
||||
@@ -26,6 +26,10 @@ pub enum CoreCommand {
|
||||
SetPeerEq(EndpointId, crate::audio::eq::EqSettings),
|
||||
/// Listener-side per-peer pan. Local only; never leaves this app instance.
|
||||
SetPeerPan(EndpointId, f32),
|
||||
/// Listener-side per-peer noise gate threshold (normalized RMS, `0.0` = off).
|
||||
/// Applies the same smooth gate as the mic path to a peer's incoming audio,
|
||||
/// to suppress their background noise on our end. Local only.
|
||||
SetPeerGate(EndpointId, f32),
|
||||
/// Locally mute/unmute a peer: when muted, their audio is decoded (so levels
|
||||
/// still show) but not mixed into our output.
|
||||
SetPeerMuted(EndpointId, bool),
|
||||
|
||||
@@ -869,6 +869,8 @@ async fn run_core_loop(
|
||||
let peer_volumes = Arc::new(Mutex::new(HashMap::<EndpointId, f32>::new()));
|
||||
let peer_eq = Arc::new(Mutex::new(HashMap::<EndpointId, EqSettings>::new()));
|
||||
let peer_pan = Arc::new(Mutex::new(HashMap::<EndpointId, f32>::new()));
|
||||
// Listener-side per-peer noise-gate thresholds (normalized RMS, 0.0 = off).
|
||||
let peer_gate = Arc::new(Mutex::new(HashMap::<EndpointId, f32>::new()));
|
||||
// Peers locally muted by us: decoded for level metering but not mixed.
|
||||
let locally_muted = Arc::new(Mutex::new(HashSet::<EndpointId>::new()));
|
||||
let mut current_name = "Anonymous".to_string();
|
||||
@@ -1397,6 +1399,7 @@ async fn run_core_loop(
|
||||
let peer_volumes_mixer = peer_volumes.clone();
|
||||
let peer_eq_mixer = peer_eq.clone();
|
||||
let peer_pan_mixer = peer_pan.clone();
|
||||
let peer_gate_mixer = peer_gate.clone();
|
||||
let locally_muted_mixer = locally_muted.clone();
|
||||
let output_gain_mixer = output_gain.clone();
|
||||
let ui_tx_mixer = ui_tx.clone();
|
||||
@@ -1413,6 +1416,11 @@ async fn run_core_loop(
|
||||
// Per-peer EQ filter state. Settings are live-cloned each
|
||||
// cycle; state is rebuilt only when a peer's EQ changes.
|
||||
let mut peer_eqs: HashMap<EndpointId, Eq> = HashMap::new();
|
||||
// Per-peer noise-gate envelope state. The threshold is passed
|
||||
// per frame (live slider), so the gate is never rebuilt — only
|
||||
// created once per peer and dropped when the peer leaves.
|
||||
let mut peer_noise_gates: HashMap<EndpointId, crate::audio::gate::NoiseGate> =
|
||||
HashMap::new();
|
||||
// When the ring is at/above target we have nothing to do; nap
|
||||
// briefly and re-check. Short enough (relative to the ~60ms
|
||||
// target and ~21ms device quantum) that we always refill well
|
||||
@@ -1438,6 +1446,7 @@ async fn run_core_loop(
|
||||
let current_volumes = peer_volumes_mixer.lock().await.clone();
|
||||
let current_eq = peer_eq_mixer.lock().await.clone();
|
||||
let current_pans = peer_pan_mixer.lock().await.clone();
|
||||
let current_gates = peer_gate_mixer.lock().await.clone();
|
||||
let muted_peers = locally_muted_mixer.lock().await.clone();
|
||||
let mut peer_frames: Vec<(Vec<i16>, f32)> = Vec::new();
|
||||
let mut peers_seen = HashSet::new();
|
||||
@@ -1463,6 +1472,26 @@ async fn run_core_loop(
|
||||
stems.push((peer_id, frame.clone()));
|
||||
}
|
||||
|
||||
// Listener-side per-peer noise gate, applied to the
|
||||
// raw decoded frame (after the clean stem tap, before
|
||||
// volume/EQ) so the threshold tracks the peer's true
|
||||
// signal level regardless of our volume setting. The
|
||||
// gate's "should transmit" return is irrelevant here —
|
||||
// we only attenuate. Threshold 0 = off; the gate is
|
||||
// created lazily and dropped when disabled.
|
||||
let gate_threshold =
|
||||
current_gates.get(&peer_id).copied().unwrap_or(0.0);
|
||||
if gate_threshold > 0.0 {
|
||||
peer_noise_gates
|
||||
.entry(peer_id)
|
||||
.or_insert_with(|| {
|
||||
crate::audio::gate::NoiseGate::new(48_000)
|
||||
})
|
||||
.process(&mut frame, gate_threshold);
|
||||
} else {
|
||||
peer_noise_gates.remove(&peer_id);
|
||||
}
|
||||
|
||||
let vol = current_volumes.get(&peer_id).copied().unwrap_or(1.0);
|
||||
apply_volume(&mut frame, vol);
|
||||
|
||||
@@ -1507,6 +1536,8 @@ async fn run_core_loop(
|
||||
}
|
||||
}
|
||||
peer_eqs.retain(|id, _| peers_seen.contains(id) || current_eq.contains_key(id));
|
||||
peer_noise_gates
|
||||
.retain(|id, _| peers_seen.contains(id) || current_gates.contains_key(id));
|
||||
|
||||
// Lossless i32 sum, then the limiter applies the master
|
||||
// output gain (in f32, so a boost past the ceiling is
|
||||
@@ -1884,6 +1915,16 @@ async fn run_core_loop(
|
||||
}
|
||||
}
|
||||
|
||||
CoreCommand::SetPeerGate(peer_id, threshold) => {
|
||||
let threshold = threshold.clamp(0.0, 1.0);
|
||||
let mut guard = peer_gate.lock().await;
|
||||
if threshold <= 0.0 {
|
||||
guard.remove(&peer_id);
|
||||
} else {
|
||||
guard.insert(peer_id, threshold);
|
||||
}
|
||||
}
|
||||
|
||||
CoreCommand::SetPeerMuted(peer_id, muted) => {
|
||||
let mut guard = locally_muted.lock().await;
|
||||
if muted {
|
||||
|
||||
@@ -15,6 +15,7 @@ pub mod notify;
|
||||
pub mod screenshare;
|
||||
pub mod sanitize;
|
||||
pub mod avatar;
|
||||
pub mod background;
|
||||
pub mod recents;
|
||||
pub mod discovery;
|
||||
pub mod hotkeys;
|
||||
|
||||
Reference in New Issue
Block a user