4 Commits
Author SHA1 Message Date
molluskandClaude Opus 4.8 76c4f68bb3 release: 0.6.5
CI / check (push) Successful in 2m54s
Local-only changes since 0.6.4 (no wire change; PROTO planes unchanged),
so this is a PATCH bump per VERSIONING.md.

Ships the low-latency screen-share live-edge fix (4bfc184), which landed
three hours after the v0.6.4 tag and was therefore never released.

Also adds the missing CHANGELOG entry for the participant "Advanced audio"
foldout (26d6600), which shipped without one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 14:45:16 -04:00
mollusk c427231858 feat(notifications): add chat and contact sounds
CI / check (push) Successful in 2m39s
2026-07-19 02:02:03 -04:00
mollusk 4bfc18463b fix(screenshare): keep low-latency playback live 2026-07-18 22:22:24 -04:00
mollusk 26d66007de ui: fold participant audio controls 2026-07-18 20:14:09 -04:00
13 changed files with 475 additions and 104 deletions
+26
View File
@@ -4,6 +4,32 @@ All notable changes to PeerSpeak are documented here.
## [Unreleased]
## [0.6.5] — 2026-07-19
### Added
- **Chat message sounds.** Successful outgoing messages and admitted incoming
messages now have distinct notification chimes, each with its own enable
toggle and optional custom WAV path in Notifications settings.
- **Contact presence sounds.** The home-screen contacts list now announces a
contact becoming online or offline. Initial online contacts are announced;
initial offline results stay silent. Both events have independent toggles and
optional custom WAV paths.
- **Notification sound browser.** Every notification event now has a native
Browse button for choosing a custom WAV instead of typing its path manually.
### Changed
- **Tidier per-participant audio controls.** The equalizer bands and noise gate
for each participant now live behind an **"Advanced audio"** foldout instead
of being expanded all the time, so a call with several people no longer fills
the panel with sliders. The controls themselves are unchanged.
### Fixed
- **Low-latency screen sharing stays near the live edge again.** mpv's
timestamp pacing could let stale frames accumulate across the reliable
PixelPass transport until a share was 710 seconds behind. Low-latency mode
now presents decoded frames immediately; Smooth mode retains timestamp pacing
when keeping shared-video audio and video synchronized matters more.
## [0.6.4] — 2026-07-18
### Added
Generated
+1 -1
View File
@@ -4871,7 +4871,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "peerspeak"
version = "0.6.4"
version = "0.6.5"
dependencies = [
"anyhow",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "peerspeak"
version = "0.6.4"
version = "0.6.5"
edition = "2024"
description = "Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
license = "MIT"
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+8
View File
@@ -76,6 +76,14 @@ CHIMES = {
"mic-toggle.wav": [(E5, 0.08)],
# Reconnect gave up: disappointing low two-note fall.
"reconnect-failed.wav": [(C5, 0.15), (349.23, 0.30)],
# Our chat message entered the room: a tiny bright acknowledgement.
"chat-sent.wav": [(1046.50, 0.06)],
# A peer message arrived: a soft two-note lift, distinct but unobtrusive.
"chat-received.wav": [(E5, 0.07), (G5, 0.11)],
# A saved contact came online: a light, higher two-note arrival.
"contact-online.wav": [(E5, 0.09), (880.00, 0.18)],
# A saved contact went offline: the same tonal family falling away.
"contact-offline.wav": [(E5, 0.09), (440.00, 0.18)],
}
+1 -1
View File
@@ -12,7 +12,7 @@
; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed).
#define MyAppName "PeerSpeak"
#define MyAppVersion "0.6.4"
#define MyAppVersion "0.6.5"
#define MyAppPublisher "mollusk"
#define MyAppExeName "peerspeak.exe"
+336 -65
View File
@@ -700,6 +700,8 @@ pub enum AppMessage {
PeerPanChanged(EndpointId, f32),
PeerGateChanged(EndpointId, f32),
PeerEqChanged(EndpointId, EqBand, f32),
/// Show or hide the secondary audio controls on one participant card.
TogglePeerAdvancedAudio(EndpointId),
/// Toggle local mute of a peer (silence them just for us).
TogglePeerMute(EndpointId),
InputDeviceSelected(AudioDevice),
@@ -758,6 +760,10 @@ pub enum AppMessage {
ToggleNotifications(bool),
ToggleEchoCancellation(bool),
CustomSoundPathChanged(Sound, String),
/// Open a native WAV picker for one notification event.
BrowseCustomSound(Sound),
/// Result of the notification WAV picker (`None` = cancelled).
CustomSoundFilePicked(Sound, Option<std::path::PathBuf>),
/// Toggle the per-sound enable flag for a single chime (W6).
ToggleSoundEnabled(Sound, bool),
/// Open / cancel the "Regenerate identity?" confirm modal (W7).
@@ -1047,6 +1053,9 @@ pub struct AppState {
conn_stats: HashMap<EndpointId, crate::core::connstats::PeerConnInfo>,
/// Peers we've locally muted (their audio isn't mixed into our output).
locally_muted: HashSet<EndpointId>,
/// Participant cards whose volume/pan/gate/EQ foldout is open. Session-only:
/// a fresh room starts compact, regardless of the previous room's UI state.
peer_audio_expanded: 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).
@@ -1240,6 +1249,7 @@ impl AppState {
self.audio_levels.clear();
self.conn_stats.clear();
self.locally_muted.clear();
self.peer_audio_expanded.clear();
self.chat_messages.clear();
// Unsent queue + retry bytes die with the room's transcript. The pacer
// and id counter deliberately survive: receivers' per-author buckets
@@ -1333,16 +1343,20 @@ impl AppState {
/// so Retry can re-dispatch, unless the entry is already gone (history
/// eviction / room reset), in which case the payload is dropped so its map
/// can't leak. Either way an id with no matching entry is a harmless no-op.
fn apply_send_result(&mut self, local_id: u64, error: Option<String>) {
/// Returns `true` only when a successful result matched a live local echo,
/// which is the boundary used for the outgoing-message notification.
fn apply_send_result(&mut self, local_id: u64, error: Option<String>) -> bool {
match error {
None => {
self.set_send_status(local_id, SendStatus::Broadcast);
let matched = self.set_send_status(local_id, SendStatus::Broadcast);
self.send_payloads.remove(&local_id);
matched
}
Some(e) => {
if !self.set_send_status(local_id, SendStatus::Failed(e)) {
self.send_payloads.remove(&local_id);
}
false
}
}
}
@@ -1373,9 +1387,30 @@ impl AppState {
Sound::SelfLeave => &self.config.custom_sound_self_leave,
Sound::MicToggle => &self.config.custom_sound_mic_toggle,
Sound::ReconnectFailed => &self.config.custom_sound_reconnect_failed,
Sound::ChatSent => &self.config.custom_sound_chat_sent,
Sound::ChatReceived => &self.config.custom_sound_chat_received,
Sound::ContactOnline => &self.config.custom_sound_contact_online,
Sound::ContactOffline => &self.config.custom_sound_contact_offline,
};
opt.as_deref().unwrap_or("")
}
fn set_custom_sound_path(&mut self, sound: Sound, path: Option<String>) {
match sound {
Sound::SelfJoin => self.config.custom_sound_self_join = path,
Sound::PeerJoin => self.config.custom_sound_peer_join = path,
Sound::PeerLeave => self.config.custom_sound_peer_leave = path,
Sound::ReconnectAttempt => self.config.custom_sound_reconnect_attempt = path,
Sound::Reconnected => self.config.custom_sound_reconnected = path,
Sound::SelfLeave => self.config.custom_sound_self_leave = path,
Sound::MicToggle => self.config.custom_sound_mic_toggle = path,
Sound::ReconnectFailed => self.config.custom_sound_reconnect_failed = path,
Sound::ChatSent => self.config.custom_sound_chat_sent = path,
Sound::ChatReceived => self.config.custom_sound_chat_received = path,
Sound::ContactOnline => self.config.custom_sound_contact_online = path,
Sound::ContactOffline => self.config.custom_sound_contact_offline = path,
}
}
}
impl Default for AppState {
@@ -1502,6 +1537,7 @@ impl Default for AppState {
audio_levels: HashMap::new(),
conn_stats: HashMap::new(),
locally_muted: HashSet::new(),
peer_audio_expanded: HashSet::new(),
call_started: None,
recording: false,
recording_started: None,
@@ -1864,6 +1900,47 @@ fn reconnected_chime(
was_reconnect.then_some(Sound::Reconnected)
}
/// Return the landing-page contact chime for one definitive presence update.
/// An initial online result is an arrival (so contacts already online at app
/// startup are announced), while an initial offline result is silent. Online
/// includes both plain `Online` and `InRoom`; moving between those two states is
/// not a connection transition. Updates continue to populate the presence map
/// off-home, but notification sounds are intentionally limited to the home page.
fn friend_presence_notification(
screen: Screen,
previous: Option<&crate::presence::FriendPresence>,
next: &crate::presence::FriendPresence,
) -> Option<Sound> {
if screen != Screen::Home {
return None;
}
let online = |presence: &crate::presence::FriendPresence| {
matches!(
presence,
crate::presence::FriendPresence::Online
| crate::presence::FriendPresence::InRoom { .. }
)
};
match (previous.map(online), online(next)) {
(None | Some(false), true) => Some(Sound::ContactOnline),
(Some(true), false) => Some(Sound::ContactOffline),
_ => None,
}
}
/// Convert a native picker result into the persisted notification path. The
/// dialog filter is advisory on some desktops, so enforce WAV here as well.
/// `None` (cancel) and a non-WAV selection leave the existing setting untouched.
fn selected_wav_path(picked: Option<std::path::PathBuf>) -> Option<String> {
let path = picked?;
let is_wav = path
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("wav"));
is_wav.then(|| path.to_string_lossy().into_owned())
}
fn in_call(state: &AppState) -> bool {
!state.ticket.is_empty()
}
@@ -2217,6 +2294,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.peers.remove(&id);
state.audio_levels.remove(&id);
state.locally_muted.remove(&id);
state.peer_audio_expanded.remove(&id);
state.connecting.remove(&id);
state.ever_connected.remove(&id);
if state.music_listening_to == Some(id) {
@@ -2238,6 +2316,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.peers.remove(&id);
state.audio_levels.remove(&id);
state.locally_muted.remove(&id);
state.peer_audio_expanded.remove(&id);
state.connecting.remove(&id);
state.ever_connected.remove(&id);
notify::play(
@@ -2301,7 +2380,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
// failure it's retained for Retry — unless the entry is gone
// (history eviction / room reset), in which case drop it so
// the payload map can't leak.
state.apply_send_result(local_id, error);
if state.apply_send_result(local_id, error) {
notify::play(
Sound::ChatSent,
state.config.custom_sound_chat_sent.as_deref(),
);
}
}
UiEvent::ChatMessage {
from,
@@ -2334,6 +2418,10 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
local_send: None,
},
);
notify::play(
Sound::ChatReceived,
state.config.custom_sound_chat_received.as_deref(),
);
}
}
UiEvent::AttachmentReady { from, id, data } => {
@@ -2503,7 +2591,15 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.friends_read_only = read_only;
}
UiEvent::FriendPresence { id, presence } => {
let sound = friend_presence_notification(
state.current_screen,
state.friend_presence.get(&id),
&presence,
);
state.friend_presence.insert(id, presence);
if let Some(sound) = sound {
notify::play(sound, Some(state.custom_sound_path(sound)));
}
}
UiEvent::FriendsRescanned => {
// The manual pass finished. Stamp the time for the live "scanned
@@ -2588,6 +2684,11 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
let settings = set_peer_eq_config(&mut state.config, id, band, gain_db);
let _ = state.controller.send(CoreCommand::SetPeerEq(id, settings));
}
AppMessage::TogglePeerAdvancedAudio(id) => {
if !state.peer_audio_expanded.remove(&id) && state.peers.contains_key(&id) {
state.peer_audio_expanded.insert(id);
}
}
AppMessage::TogglePeerMute(id) => {
let now_muted = if state.locally_muted.contains(&id) {
state.locally_muted.remove(&id);
@@ -2873,15 +2974,36 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
} 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,
state.set_custom_sound_path(sound, path_opt);
}
AppMessage::BrowseCustomSound(sound) => {
let initial_dir = {
let current = state.custom_sound_path(sound);
(!current.trim().is_empty())
.then(|| notify::expand_tilde(current))
.and_then(|path| path.parent().map(std::path::Path::to_path_buf))
.filter(|path| path.is_dir())
};
return Task::perform(
async move {
let mut dialog = rfd::AsyncFileDialog::new()
.add_filter("WAV audio", &["wav"])
.set_title("Choose a notification sound");
if let Some(dir) = initial_dir {
dialog = dialog.set_directory(dir);
}
dialog
.pick_file()
.await
.map(|handle| handle.path().to_path_buf())
},
move |picked| AppMessage::CustomSoundFilePicked(sound, picked),
);
}
AppMessage::CustomSoundFilePicked(sound, picked) => {
if let Some(path) = selected_wav_path(picked) {
state.set_custom_sound_path(sound, Some(path));
state.config.save();
}
}
AppMessage::ToggleSoundEnabled(sound, enabled) => {
@@ -5268,10 +5390,19 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
]
.spacing(6)
.align_y(iced::alignment::Vertical::Center),
row![
context_input("Default (embedded)...", path)
.on_input(move |val| AppMessage::CustomSoundPathChanged(sound, val))
.style(t_style)
.padding(8)
.width(iced::Length::Fill),
button(text("Browse…").size(11))
.on_press(AppMessage::BrowseCustomSound(sound))
.style(b_style(color_surface, color_blue, color_text, 5.0))
.padding([8, 10]),
]
.spacing(6)
.width(iced::Length::Fill)
]
.spacing(4)
.width(iced::Length::Fill)
@@ -6021,6 +6152,14 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
path_field("Mic Toggle", Sound::MicToggle),
path_field("Reconnect Failed", Sound::ReconnectFailed),
].spacing(20).width(iced::Length::Fill),
row![
path_field("Chat Sent", Sound::ChatSent),
path_field("Chat Received", Sound::ChatReceived),
].spacing(20).width(iced::Length::Fill),
row![
path_field("Contact Online", Sound::ContactOnline),
path_field("Contact Offline", Sound::ContactOffline),
].spacing(20).width(iced::Length::Fill),
].spacing(8).width(iced::Length::Fill),
]
.spacing(10)
@@ -6748,48 +6887,32 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
]
.spacing(8);
// Peer volume slider
let advanced_audio_open = state.peer_audio_expanded.contains(peer_id);
let foldout_symbol = if advanced_audio_open { "" } else { "" };
card_content = card_content.push(
button(
row![
text(foldout_symbol).size(13).color(color_subtext),
text("Advanced audio").size(12).color(color_text),
]
.spacing(6)
.align_y(iced::alignment::Vertical::Center),
)
.on_press(AppMessage::TogglePeerAdvancedAudio(peer_id_clone))
.style(b_style(color_surface, color_blue, color_text, 6.0))
.padding([6, 8])
.width(iced::Length::Fill),
);
if advanced_audio_open {
let peer_key = peer_id.to_string();
let current_vol = state
.config
.peer_volume
.get(&peer_id.to_string())
.get(&peer_key)
.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
@@ -6804,8 +6927,34 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
(current_gate / METER_MAX * 100.0).clamp(0.0, 100.0)
)
};
card_content = card_content.push(
row![
let volume_row = 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 pan_row = 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 on the mic meter's 0..METER_MAX scale; 0 = off.
let gate_row = row![
text("Gate:").size(12).color(color_subtext),
container(text(gate_label).size(11).color(color_subtext))
.width(iced::Length::Fixed(58.0)),
@@ -6816,8 +6965,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.on_release(AppMessage::PersistConfig),
]
.spacing(8)
.align_y(iced::alignment::Vertical::Center),
);
.align_y(iced::alignment::Vertical::Center);
let eq = peer_eq_settings(&state.config, peer_id);
let eq_row =
@@ -6839,15 +6987,23 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.align_y(iced::alignment::Vertical::Center)
.into()
};
card_content = card_content.push(
column![
let advanced_audio = column![
volume_row,
pan_row,
gate_row,
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),
.spacing(6);
card_content = card_content.push(
container(advanced_audio)
.style(c_style(color_crust, color_surface, 6.0))
.padding(10)
.width(iced::Length::Fill),
);
}
let card = container(card_content)
.style(c_style(
@@ -9513,12 +9669,12 @@ mod tests {
use super::sendqueue::{self, LocalSend, SendStatus};
use super::{
AppConfig, AppMessage, AppState, AttachmentCache, AttachmentState,
CLOCK_SKEW_WARNING_VISIBLE_SECS, ChatEntry, ClockSkewBanner, GateMeter, METER_MAX,
CLOCK_SKEW_WARNING_VISIBLE_SECS, ChatEntry, ClockSkewBanner, GateMeter, METER_MAX, Screen,
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,
format_relative_ago, friend_presence_notification, initial_window_position,
now_playing_label, reconnect_attempt_chime, reconnected_chime, selected_wav_path,
set_peer_gate_config, set_peer_volume_config, show_clock_skew_warning, update,
};
use iroh::SecretKey;
use std::collections::VecDeque;
@@ -9776,6 +9932,7 @@ mod tests {
);
state.audio_levels.insert(peer, 0.5);
state.locally_muted.insert(peer);
state.peer_audio_expanded.insert(peer);
state.chat_messages.push(ChatEntry {
name: "Peer".to_string(),
text: "old room".to_string(),
@@ -9837,6 +9994,7 @@ mod tests {
assert!(state.peers.is_empty());
assert!(state.audio_levels.is_empty());
assert!(state.locally_muted.is_empty());
assert!(state.peer_audio_expanded.is_empty());
assert!(state.chat_messages.is_empty());
assert!(state.chat_input.is_empty());
assert!(state.attachments.len() == 0);
@@ -9886,6 +10044,34 @@ mod tests {
panic!("clip player did not stop during room reset");
}
#[test]
fn peer_advanced_audio_toggle_is_per_peer_and_rejects_stale_ids() {
let mut state = AppState::default();
let peer = SecretKey::generate().public();
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,
},
);
let _ = update(&mut state, AppMessage::TogglePeerAdvancedAudio(peer));
assert!(state.peer_audio_expanded.contains(&peer));
let _ = update(&mut state, AppMessage::TogglePeerAdvancedAudio(peer));
assert!(!state.peer_audio_expanded.contains(&peer));
let stale = SecretKey::generate().public();
let _ = update(&mut state, AppMessage::TogglePeerAdvancedAudio(stale));
assert!(!state.peer_audio_expanded.contains(&stale));
}
#[test]
fn clock_skew_warning_shows_dismisses_and_expires() {
let mut state = AppState::default();
@@ -10579,6 +10765,91 @@ mod tests {
const W: f32 = 200.0;
#[test]
fn initial_contact_presence_announces_only_online() {
use crate::presence::FriendPresence;
assert_eq!(
friend_presence_notification(Screen::Home, None, &FriendPresence::Online),
Some(Sound::ContactOnline)
);
assert_eq!(
friend_presence_notification(Screen::Home, None, &FriendPresence::Offline),
None
);
}
#[test]
fn contact_presence_chimes_only_on_online_boundary() {
use crate::presence::FriendPresence;
let in_room = FriendPresence::InRoom {
name: "Game night".to_string(),
ticket: "ticket".to_string(),
};
assert_eq!(
friend_presence_notification(Screen::Home, Some(&FriendPresence::Offline), &in_room,),
Some(Sound::ContactOnline)
);
assert_eq!(
friend_presence_notification(
Screen::Home,
Some(&FriendPresence::Online),
&FriendPresence::Offline,
),
Some(Sound::ContactOffline)
);
assert_eq!(
friend_presence_notification(Screen::Home, Some(&FriendPresence::Online), &in_room,),
None
);
assert_eq!(
friend_presence_notification(
Screen::Home,
Some(&FriendPresence::Offline),
&FriendPresence::Offline,
),
None
);
}
#[test]
fn contact_presence_is_silent_away_from_landing_page() {
use crate::presence::FriendPresence;
assert_eq!(
friend_presence_notification(Screen::Room, None, &FriendPresence::Online),
None
);
assert_eq!(
friend_presence_notification(
Screen::Settings,
Some(&FriendPresence::Online),
&FriendPresence::Offline,
),
None
);
}
#[test]
fn selected_notification_sound_accepts_wav_and_preserves_cancel() {
use std::path::PathBuf;
assert_eq!(selected_wav_path(None), None);
assert_eq!(
selected_wav_path(Some(PathBuf::from("/tmp/notify.mp3"))),
None
);
assert_eq!(
selected_wav_path(Some(PathBuf::from("/tmp/notify.wav"))),
Some("/tmp/notify.wav".to_string())
);
assert_eq!(
selected_wav_path(Some(PathBuf::from("/tmp/notify.WAV"))),
Some("/tmp/notify.WAV".to_string())
);
}
#[test]
fn gate_drag_maps_left_edge_to_zero() {
assert_eq!(GateMeter::x_to_threshold(0.0, W), 0.0);
@@ -10862,7 +11133,7 @@ mod tests {
// Empty queue + a fresh full pacer → dispatched immediately.
assert_eq!(status_of(&state, id), Some(SendStatus::Pending));
assert!(state.send_payloads.contains_key(&id));
state.apply_send_result(id, None);
assert!(state.apply_send_result(id, None));
assert_eq!(status_of(&state, id), Some(SendStatus::Broadcast));
// A completed send releases its retry payload.
assert!(!state.send_payloads.contains_key(&id));
@@ -10873,7 +11144,7 @@ mod tests {
let mut state = AppState::default();
let id = push_own(&mut state, "yo");
state.submit_send(id, PendingSend::Text("yo".to_string()));
state.apply_send_result(id, Some("not in a room".to_string()));
assert!(!state.apply_send_result(id, Some("not in a room".to_string())));
assert_eq!(
status_of(&state, id),
Some(SendStatus::Failed("not in a room".to_string()))
@@ -10889,11 +11160,11 @@ mod tests {
state.submit_send(a, PendingSend::Text("a".to_string()));
let b = push_own(&mut state, "b");
state.submit_send(b, PendingSend::Text("b".to_string()));
state.apply_send_result(a, None);
assert!(state.apply_send_result(a, None));
assert_eq!(status_of(&state, a), Some(SendStatus::Broadcast));
assert_eq!(status_of(&state, b), Some(SendStatus::Pending));
// A result for an id with no matching entry is a harmless no-op.
state.apply_send_result(9999, None);
assert!(!state.apply_send_result(9999, None));
assert_eq!(status_of(&state, b), Some(SendStatus::Pending));
}
@@ -10907,7 +11178,7 @@ mod tests {
state
.chat_messages
.retain(|m| m.local_send.as_ref().map(|s| s.id) != Some(id));
state.apply_send_result(id, Some("dead".to_string()));
assert!(!state.apply_send_result(id, Some("dead".to_string())));
// No entry to mark → the payload must not leak.
assert!(!state.send_payloads.contains_key(&id));
}
@@ -10922,7 +11193,7 @@ mod tests {
assert!(state.send_queue.is_empty());
assert!(state.send_payloads.is_empty());
// A late result for the pre-reset send touches nothing and adds no entry.
state.apply_send_result(id, None);
assert!(!state.apply_send_result(id, None));
assert!(state.chat_messages.is_empty());
assert!(state.send_payloads.is_empty());
}
+36
View File
@@ -478,6 +478,14 @@ pub struct AppConfig {
pub custom_sound_mic_toggle: Option<String>,
#[serde(default)]
pub custom_sound_reconnect_failed: Option<String>,
#[serde(default)]
pub custom_sound_chat_sent: Option<String>,
#[serde(default)]
pub custom_sound_chat_received: Option<String>,
#[serde(default)]
pub custom_sound_contact_online: Option<String>,
#[serde(default)]
pub custom_sound_contact_offline: Option<String>,
/// Per-sound enable flags (W6). The master `notifications_enabled` toggle
/// gates ALL chimes; these let the user silence individual events while the
/// master stays on. A chime plays only if the master AND its flag are true.
@@ -498,6 +506,14 @@ pub struct AppConfig {
pub sound_mic_toggle_enabled: bool,
#[serde(default = "default_true")]
pub sound_reconnect_failed_enabled: bool,
#[serde(default = "default_true")]
pub sound_chat_sent_enabled: bool,
#[serde(default = "default_true")]
pub sound_chat_received_enabled: bool,
#[serde(default = "default_true")]
pub sound_contact_online_enabled: bool,
#[serde(default = "default_true")]
pub sound_contact_offline_enabled: bool,
/// Optional override for the `pixelpass` binary location (screen share).
/// Empty / unset = look it up on `$PATH`. Hand-editable; no Settings UI yet.
#[serde(default)]
@@ -601,6 +617,10 @@ impl Default for AppConfig {
custom_sound_self_leave: None,
custom_sound_mic_toggle: None,
custom_sound_reconnect_failed: None,
custom_sound_chat_sent: None,
custom_sound_chat_received: None,
custom_sound_contact_online: None,
custom_sound_contact_offline: None,
sound_self_join_enabled: true,
sound_peer_join_enabled: true,
sound_peer_leave_enabled: true,
@@ -609,6 +629,10 @@ impl Default for AppConfig {
sound_self_leave_enabled: true,
sound_mic_toggle_enabled: true,
sound_reconnect_failed_enabled: true,
sound_chat_sent_enabled: true,
sound_chat_received_enabled: true,
sound_contact_online_enabled: true,
sound_contact_offline_enabled: true,
pixelpass_path: None,
screen_share: ScreenShareSettings::default(),
recents: Vec::new(),
@@ -639,6 +663,10 @@ impl AppConfig {
Sound::SelfLeave => self.sound_self_leave_enabled,
Sound::MicToggle => self.sound_mic_toggle_enabled,
Sound::ReconnectFailed => self.sound_reconnect_failed_enabled,
Sound::ChatSent => self.sound_chat_sent_enabled,
Sound::ChatReceived => self.sound_chat_received_enabled,
Sound::ContactOnline => self.sound_contact_online_enabled,
Sound::ContactOffline => self.sound_contact_offline_enabled,
}
}
@@ -653,6 +681,10 @@ impl AppConfig {
Sound::SelfLeave => self.sound_self_leave_enabled = enabled,
Sound::MicToggle => self.sound_mic_toggle_enabled = enabled,
Sound::ReconnectFailed => self.sound_reconnect_failed_enabled = enabled,
Sound::ChatSent => self.sound_chat_sent_enabled = enabled,
Sound::ChatReceived => self.sound_chat_received_enabled = enabled,
Sound::ContactOnline => self.sound_contact_online_enabled = enabled,
Sound::ContactOffline => self.sound_contact_offline_enabled = enabled,
}
}
@@ -940,6 +972,10 @@ mod tests {
assert!(deserialized.custom_sound_self_leave.is_none());
assert!(deserialized.custom_sound_mic_toggle.is_none());
assert!(deserialized.custom_sound_reconnect_failed.is_none());
assert!(deserialized.custom_sound_chat_sent.is_none());
assert!(deserialized.custom_sound_chat_received.is_none());
assert!(deserialized.custom_sound_contact_online.is_none());
assert!(deserialized.custom_sound_contact_offline.is_none());
assert_eq!(deserialized.screen_share, ScreenShareSettings::default());
assert_eq!(deserialized.screen_share.quality, ShareQuality::Auto);
assert_eq!(deserialized.screen_share.player, SharePlayer::Mpv);
+25 -1
View File
@@ -80,6 +80,14 @@ pub enum Sound {
MicToggle,
/// Reconnect failed / peer evicted.
ReconnectFailed,
/// One of our chat messages was broadcast to the room.
ChatSent,
/// A chat message from another participant was admitted.
ChatReceived,
/// A saved contact was detected online on the home screen.
ContactOnline,
/// A saved contact previously seen online went offline on the home screen.
ContactOffline,
}
impl Sound {
@@ -93,10 +101,14 @@ impl Sound {
Sound::SelfLeave,
Sound::MicToggle,
Sound::ReconnectFailed,
Sound::ChatSent,
Sound::ChatReceived,
Sound::ContactOnline,
Sound::ContactOffline,
];
/// Number of distinct notification events.
pub const COUNT: usize = 8;
pub const COUNT: usize = 12;
/// Stable 0-based index into the per-sound flag array. Must match `ALL`.
fn index(self) -> usize {
@@ -109,6 +121,10 @@ impl Sound {
Sound::SelfLeave => 5,
Sound::MicToggle => 6,
Sound::ReconnectFailed => 7,
Sound::ChatSent => 8,
Sound::ChatReceived => 9,
Sound::ContactOnline => 10,
Sound::ContactOffline => 11,
}
}
@@ -123,6 +139,10 @@ impl Sound {
Sound::SelfLeave => include_bytes!("../assets/sounds/self-leave.wav"),
Sound::MicToggle => include_bytes!("../assets/sounds/mic-toggle.wav"),
Sound::ReconnectFailed => include_bytes!("../assets/sounds/reconnect-failed.wav"),
Sound::ChatSent => include_bytes!("../assets/sounds/chat-sent.wav"),
Sound::ChatReceived => include_bytes!("../assets/sounds/chat-received.wav"),
Sound::ContactOnline => include_bytes!("../assets/sounds/contact-online.wav"),
Sound::ContactOffline => include_bytes!("../assets/sounds/contact-offline.wav"),
}
}
@@ -137,6 +157,10 @@ impl Sound {
Sound::SelfLeave => "self-leave",
Sound::MicToggle => "mic-toggle",
Sound::ReconnectFailed => "reconnect-failed",
Sound::ChatSent => "chat-sent",
Sound::ChatReceived => "chat-received",
Sound::ContactOnline => "contact-online",
Sound::ContactOffline => "contact-offline",
}
}
}
+14 -8
View File
@@ -607,14 +607,13 @@ fn event_for_log(ev: &PixelpassEvent) -> String {
/// player is reaped in a background task so it doesn't linger as a zombie when
/// its window closes.
///
/// The flags keep latency low while preserving A/V sync. We deliberately do
/// NOT pass mpv's `--untimed`: that displays each video frame the instant it
/// decodes, ignoring audio timestamps, which makes a shared *video* drift
/// progressively out of sync with its audio. Pacing to the audio clock costs a
/// little latency (negligible for pointing at a desktop) and keeps a shared
/// video in sync. We also leave hwdec at the `low-latency` default (software
/// decode): forcing `--hwdec=auto` froze some viewers on frame 1 while audio
/// kept playing.
/// The buffering posture chooses the latency/A/V-sync tradeoff. Low latency
/// passes mpv's `--untimed`, displaying frames as soon as they decode so a live
/// share cannot accumulate several seconds in the reliable QUIC/TCP/player
/// buffer chain. Smooth leaves timestamp pacing enabled, which keeps a shared
/// video's audio and video synchronized at the cost of extra live latency.
/// Hardware decoding remains opt-in: forcing `--hwdec=auto` froze some viewers
/// on frame 1 while audio kept playing.
fn launch_player(url: &str, settings: &ScreenShareSettings) -> std::io::Result<()> {
let mpv_args = mpv_args(settings);
let vlc_args = vlc_args(settings);
@@ -648,6 +647,12 @@ pub fn mpv_args(settings: &ScreenShareSettings) -> Vec<String> {
match settings.buffering {
ShareBuffering::LowLatency => {
args.push("--profile=low-latency".to_string());
// Pixelpass carries MPEG-TS through reliable ordered QUIC/TCP. If
// mpv paces a live stream even slightly slower than capture, those
// downstream buffers retain stale frames and latency grows into
// seconds. Immediate presentation keeps this posture at the live
// edge; Smooth deliberately retains timestamp pacing for A/V sync.
args.push("--untimed".to_string());
args.push("--audio-buffer=0.2".to_string());
args.push("--demuxer-readahead-secs=0.5".to_string());
}
@@ -828,6 +833,7 @@ mod tests {
mpv_args(&ScreenShareSettings::default()),
vec![
"--profile=low-latency",
"--untimed",
"--audio-buffer=0.2",
"--demuxer-readahead-secs=0.5",
"--demuxer-max-bytes=2M",