3 Commits
Author SHA1 Message Date
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
10 changed files with 464 additions and 101 deletions
+18
View File
@@ -4,6 +4,24 @@ All notable changes to PeerSpeak are documented here.
## [Unreleased]
### 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.
### 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
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)],
}
+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",