Compare commits
3
Commits
3d7b01c8a2
...
c427231858
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c427231858 | ||
|
|
4bfc18463b | ||
|
|
26d66007de |
@@ -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 7–10 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.
@@ -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)],
|
||||
}
|
||||
|
||||
|
||||
|
||||
+363
-92
@@ -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),
|
||||
context_input("Default (embedded)...", path)
|
||||
.on_input(move |val| AppMessage::CustomSoundPathChanged(sound, val))
|
||||
.style(t_style)
|
||||
.padding(8)
|
||||
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,15 +6887,48 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
]
|
||||
.spacing(8);
|
||||
|
||||
// Peer volume slider
|
||||
let current_vol = state
|
||||
.config
|
||||
.peer_volume
|
||||
.get(&peer_id.to_string())
|
||||
.copied()
|
||||
.unwrap_or(1.0);
|
||||
let advanced_audio_open = state.peer_audio_expanded.contains(peer_id);
|
||||
let foldout_symbol = if advanced_audio_open { "▾" } else { "▸" };
|
||||
card_content = card_content.push(
|
||||
row![
|
||||
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_key)
|
||||
.copied()
|
||||
.unwrap_or(1.0);
|
||||
let current_pan = state.config.peer_pan.get(&peer_key).copied().unwrap_or(0.0);
|
||||
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)
|
||||
)
|
||||
};
|
||||
|
||||
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)
|
||||
@@ -6765,47 +6937,24 @@ 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 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![
|
||||
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)
|
||||
)
|
||||
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),
|
||||
);
|
||||
.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![
|
||||
// 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,38 +6965,45 @@ 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 =
|
||||
|label: &'static str, band: EqBand, value: f32| -> Element<'_, AppMessage> {
|
||||
row![
|
||||
container(
|
||||
text(format!("{label} {value:+.1} dB"))
|
||||
.size(11)
|
||||
.color(color_subtext)
|
||||
)
|
||||
.width(iced::Length::Fixed(86.0)),
|
||||
slider(EQ_GAIN_DB_MIN..=EQ_GAIN_DB_MAX, value, move |v| {
|
||||
AppMessage::PeerEqChanged(peer_id_clone, band, v)
|
||||
})
|
||||
.step(0.5)
|
||||
.on_release(AppMessage::PersistConfig),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::alignment::Vertical::Center)
|
||||
.into()
|
||||
};
|
||||
card_content = card_content.push(
|
||||
column![
|
||||
let eq = peer_eq_settings(&state.config, peer_id);
|
||||
let eq_row =
|
||||
|label: &'static str, band: EqBand, value: f32| -> Element<'_, AppMessage> {
|
||||
row![
|
||||
container(
|
||||
text(format!("{label} {value:+.1} dB"))
|
||||
.size(11)
|
||||
.color(color_subtext)
|
||||
)
|
||||
.width(iced::Length::Fixed(86.0)),
|
||||
slider(EQ_GAIN_DB_MIN..=EQ_GAIN_DB_MAX, value, move |v| {
|
||||
AppMessage::PeerEqChanged(peer_id_clone, band, v)
|
||||
})
|
||||
.step(0.5)
|
||||
.on_release(AppMessage::PersistConfig),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::alignment::Vertical::Center)
|
||||
.into()
|
||||
};
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user