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]
|
## [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
|
## [0.6.4] — 2026-07-18
|
||||||
|
|
||||||
### Added
|
### 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)],
|
"mic-toggle.wav": [(E5, 0.08)],
|
||||||
# Reconnect gave up: disappointing low two-note fall.
|
# Reconnect gave up: disappointing low two-note fall.
|
||||||
"reconnect-failed.wav": [(C5, 0.15), (349.23, 0.30)],
|
"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
@@ -700,6 +700,8 @@ pub enum AppMessage {
|
|||||||
PeerPanChanged(EndpointId, f32),
|
PeerPanChanged(EndpointId, f32),
|
||||||
PeerGateChanged(EndpointId, f32),
|
PeerGateChanged(EndpointId, f32),
|
||||||
PeerEqChanged(EndpointId, EqBand, 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).
|
/// Toggle local mute of a peer (silence them just for us).
|
||||||
TogglePeerMute(EndpointId),
|
TogglePeerMute(EndpointId),
|
||||||
InputDeviceSelected(AudioDevice),
|
InputDeviceSelected(AudioDevice),
|
||||||
@@ -758,6 +760,10 @@ pub enum AppMessage {
|
|||||||
ToggleNotifications(bool),
|
ToggleNotifications(bool),
|
||||||
ToggleEchoCancellation(bool),
|
ToggleEchoCancellation(bool),
|
||||||
CustomSoundPathChanged(Sound, String),
|
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).
|
/// Toggle the per-sound enable flag for a single chime (W6).
|
||||||
ToggleSoundEnabled(Sound, bool),
|
ToggleSoundEnabled(Sound, bool),
|
||||||
/// Open / cancel the "Regenerate identity?" confirm modal (W7).
|
/// Open / cancel the "Regenerate identity?" confirm modal (W7).
|
||||||
@@ -1047,6 +1053,9 @@ pub struct AppState {
|
|||||||
conn_stats: HashMap<EndpointId, crate::core::connstats::PeerConnInfo>,
|
conn_stats: HashMap<EndpointId, crate::core::connstats::PeerConnInfo>,
|
||||||
/// Peers we've locally muted (their audio isn't mixed into our output).
|
/// Peers we've locally muted (their audio isn't mixed into our output).
|
||||||
locally_muted: HashSet<EndpointId>,
|
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.
|
/// When we joined the current room, for the in-room call-duration timer.
|
||||||
call_started: Option<std::time::Instant>,
|
call_started: Option<std::time::Instant>,
|
||||||
/// Whether a local call recording is in progress (confirmed by the core).
|
/// Whether a local call recording is in progress (confirmed by the core).
|
||||||
@@ -1240,6 +1249,7 @@ impl AppState {
|
|||||||
self.audio_levels.clear();
|
self.audio_levels.clear();
|
||||||
self.conn_stats.clear();
|
self.conn_stats.clear();
|
||||||
self.locally_muted.clear();
|
self.locally_muted.clear();
|
||||||
|
self.peer_audio_expanded.clear();
|
||||||
self.chat_messages.clear();
|
self.chat_messages.clear();
|
||||||
// Unsent queue + retry bytes die with the room's transcript. The pacer
|
// Unsent queue + retry bytes die with the room's transcript. The pacer
|
||||||
// and id counter deliberately survive: receivers' per-author buckets
|
// 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
|
/// 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
|
/// 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.
|
/// 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 {
|
match error {
|
||||||
None => {
|
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);
|
self.send_payloads.remove(&local_id);
|
||||||
|
matched
|
||||||
}
|
}
|
||||||
Some(e) => {
|
Some(e) => {
|
||||||
if !self.set_send_status(local_id, SendStatus::Failed(e)) {
|
if !self.set_send_status(local_id, SendStatus::Failed(e)) {
|
||||||
self.send_payloads.remove(&local_id);
|
self.send_payloads.remove(&local_id);
|
||||||
}
|
}
|
||||||
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1373,9 +1387,30 @@ impl AppState {
|
|||||||
Sound::SelfLeave => &self.config.custom_sound_self_leave,
|
Sound::SelfLeave => &self.config.custom_sound_self_leave,
|
||||||
Sound::MicToggle => &self.config.custom_sound_mic_toggle,
|
Sound::MicToggle => &self.config.custom_sound_mic_toggle,
|
||||||
Sound::ReconnectFailed => &self.config.custom_sound_reconnect_failed,
|
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("")
|
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 {
|
impl Default for AppState {
|
||||||
@@ -1502,6 +1537,7 @@ impl Default for AppState {
|
|||||||
audio_levels: HashMap::new(),
|
audio_levels: HashMap::new(),
|
||||||
conn_stats: HashMap::new(),
|
conn_stats: HashMap::new(),
|
||||||
locally_muted: HashSet::new(),
|
locally_muted: HashSet::new(),
|
||||||
|
peer_audio_expanded: HashSet::new(),
|
||||||
call_started: None,
|
call_started: None,
|
||||||
recording: false,
|
recording: false,
|
||||||
recording_started: None,
|
recording_started: None,
|
||||||
@@ -1864,6 +1900,47 @@ fn reconnected_chime(
|
|||||||
was_reconnect.then_some(Sound::Reconnected)
|
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 {
|
fn in_call(state: &AppState) -> bool {
|
||||||
!state.ticket.is_empty()
|
!state.ticket.is_empty()
|
||||||
}
|
}
|
||||||
@@ -2217,6 +2294,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
state.peers.remove(&id);
|
state.peers.remove(&id);
|
||||||
state.audio_levels.remove(&id);
|
state.audio_levels.remove(&id);
|
||||||
state.locally_muted.remove(&id);
|
state.locally_muted.remove(&id);
|
||||||
|
state.peer_audio_expanded.remove(&id);
|
||||||
state.connecting.remove(&id);
|
state.connecting.remove(&id);
|
||||||
state.ever_connected.remove(&id);
|
state.ever_connected.remove(&id);
|
||||||
if state.music_listening_to == Some(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.peers.remove(&id);
|
||||||
state.audio_levels.remove(&id);
|
state.audio_levels.remove(&id);
|
||||||
state.locally_muted.remove(&id);
|
state.locally_muted.remove(&id);
|
||||||
|
state.peer_audio_expanded.remove(&id);
|
||||||
state.connecting.remove(&id);
|
state.connecting.remove(&id);
|
||||||
state.ever_connected.remove(&id);
|
state.ever_connected.remove(&id);
|
||||||
notify::play(
|
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
|
// failure it's retained for Retry — unless the entry is gone
|
||||||
// (history eviction / room reset), in which case drop it so
|
// (history eviction / room reset), in which case drop it so
|
||||||
// the payload map can't leak.
|
// 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 {
|
UiEvent::ChatMessage {
|
||||||
from,
|
from,
|
||||||
@@ -2334,6 +2418,10 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
local_send: None,
|
local_send: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
notify::play(
|
||||||
|
Sound::ChatReceived,
|
||||||
|
state.config.custom_sound_chat_received.as_deref(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
UiEvent::AttachmentReady { from, id, data } => {
|
UiEvent::AttachmentReady { from, id, data } => {
|
||||||
@@ -2503,7 +2591,15 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
state.friends_read_only = read_only;
|
state.friends_read_only = read_only;
|
||||||
}
|
}
|
||||||
UiEvent::FriendPresence { id, presence } => {
|
UiEvent::FriendPresence { id, presence } => {
|
||||||
|
let sound = friend_presence_notification(
|
||||||
|
state.current_screen,
|
||||||
|
state.friend_presence.get(&id),
|
||||||
|
&presence,
|
||||||
|
);
|
||||||
state.friend_presence.insert(id, presence);
|
state.friend_presence.insert(id, presence);
|
||||||
|
if let Some(sound) = sound {
|
||||||
|
notify::play(sound, Some(state.custom_sound_path(sound)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
UiEvent::FriendsRescanned => {
|
UiEvent::FriendsRescanned => {
|
||||||
// The manual pass finished. Stamp the time for the live "scanned
|
// 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 settings = set_peer_eq_config(&mut state.config, id, band, gain_db);
|
||||||
let _ = state.controller.send(CoreCommand::SetPeerEq(id, settings));
|
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) => {
|
AppMessage::TogglePeerMute(id) => {
|
||||||
let now_muted = if state.locally_muted.contains(&id) {
|
let now_muted = if state.locally_muted.contains(&id) {
|
||||||
state.locally_muted.remove(&id);
|
state.locally_muted.remove(&id);
|
||||||
@@ -2873,15 +2974,36 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
} else {
|
} else {
|
||||||
Some(path)
|
Some(path)
|
||||||
};
|
};
|
||||||
match sound {
|
state.set_custom_sound_path(sound, path_opt);
|
||||||
Sound::SelfJoin => state.config.custom_sound_self_join = path_opt,
|
}
|
||||||
Sound::PeerJoin => state.config.custom_sound_peer_join = path_opt,
|
AppMessage::BrowseCustomSound(sound) => {
|
||||||
Sound::PeerLeave => state.config.custom_sound_peer_leave = path_opt,
|
let initial_dir = {
|
||||||
Sound::ReconnectAttempt => state.config.custom_sound_reconnect_attempt = path_opt,
|
let current = state.custom_sound_path(sound);
|
||||||
Sound::Reconnected => state.config.custom_sound_reconnected = path_opt,
|
(!current.trim().is_empty())
|
||||||
Sound::SelfLeave => state.config.custom_sound_self_leave = path_opt,
|
.then(|| notify::expand_tilde(current))
|
||||||
Sound::MicToggle => state.config.custom_sound_mic_toggle = path_opt,
|
.and_then(|path| path.parent().map(std::path::Path::to_path_buf))
|
||||||
Sound::ReconnectFailed => state.config.custom_sound_reconnect_failed = path_opt,
|
.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) => {
|
AppMessage::ToggleSoundEnabled(sound, enabled) => {
|
||||||
@@ -5268,10 +5390,19 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
]
|
]
|
||||||
.spacing(6)
|
.spacing(6)
|
||||||
.align_y(iced::alignment::Vertical::Center),
|
.align_y(iced::alignment::Vertical::Center),
|
||||||
|
row![
|
||||||
context_input("Default (embedded)...", path)
|
context_input("Default (embedded)...", path)
|
||||||
.on_input(move |val| AppMessage::CustomSoundPathChanged(sound, val))
|
.on_input(move |val| AppMessage::CustomSoundPathChanged(sound, val))
|
||||||
.style(t_style)
|
.style(t_style)
|
||||||
.padding(8)
|
.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)
|
.spacing(4)
|
||||||
.width(iced::Length::Fill)
|
.width(iced::Length::Fill)
|
||||||
@@ -6021,6 +6152,14 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
path_field("Mic Toggle", Sound::MicToggle),
|
path_field("Mic Toggle", Sound::MicToggle),
|
||||||
path_field("Reconnect Failed", Sound::ReconnectFailed),
|
path_field("Reconnect Failed", Sound::ReconnectFailed),
|
||||||
].spacing(20).width(iced::Length::Fill),
|
].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(8).width(iced::Length::Fill),
|
||||||
]
|
]
|
||||||
.spacing(10)
|
.spacing(10)
|
||||||
@@ -6748,48 +6887,32 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
]
|
]
|
||||||
.spacing(8);
|
.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
|
let current_vol = state
|
||||||
.config
|
.config
|
||||||
.peer_volume
|
.peer_volume
|
||||||
.get(&peer_id.to_string())
|
.get(&peer_key)
|
||||||
.copied()
|
.copied()
|
||||||
.unwrap_or(1.0);
|
.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);
|
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
|
let current_gate = state
|
||||||
.config
|
.config
|
||||||
.peer_gate
|
.peer_gate
|
||||||
@@ -6804,8 +6927,34 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
(current_gate / METER_MAX * 100.0).clamp(0.0, 100.0)
|
(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),
|
text("Gate:").size(12).color(color_subtext),
|
||||||
container(text(gate_label).size(11).color(color_subtext))
|
container(text(gate_label).size(11).color(color_subtext))
|
||||||
.width(iced::Length::Fixed(58.0)),
|
.width(iced::Length::Fixed(58.0)),
|
||||||
@@ -6816,8 +6965,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
.on_release(AppMessage::PersistConfig),
|
.on_release(AppMessage::PersistConfig),
|
||||||
]
|
]
|
||||||
.spacing(8)
|
.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 = peer_eq_settings(&state.config, peer_id);
|
||||||
let eq_row =
|
let eq_row =
|
||||||
@@ -6839,15 +6987,23 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
.align_y(iced::alignment::Vertical::Center)
|
.align_y(iced::alignment::Vertical::Center)
|
||||||
.into()
|
.into()
|
||||||
};
|
};
|
||||||
card_content = card_content.push(
|
let advanced_audio = column![
|
||||||
column![
|
volume_row,
|
||||||
|
pan_row,
|
||||||
|
gate_row,
|
||||||
text("EQ").size(11).color(color_subtext),
|
text("EQ").size(11).color(color_subtext),
|
||||||
eq_row("Low", EqBand::Low, eq.low_gain_db),
|
eq_row("Low", EqBand::Low, eq.low_gain_db),
|
||||||
eq_row("Mid", EqBand::Mid, eq.mid_gain_db),
|
eq_row("Mid", EqBand::Mid, eq.mid_gain_db),
|
||||||
eq_row("High", EqBand::High, eq.high_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)
|
let card = container(card_content)
|
||||||
.style(c_style(
|
.style(c_style(
|
||||||
@@ -9513,12 +9669,12 @@ mod tests {
|
|||||||
use super::sendqueue::{self, LocalSend, SendStatus};
|
use super::sendqueue::{self, LocalSend, SendStatus};
|
||||||
use super::{
|
use super::{
|
||||||
AppConfig, AppMessage, AppState, AttachmentCache, AttachmentState,
|
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,
|
ScreenBounds, UiEvent, attachment_default_name, clamp_window_position,
|
||||||
clear_expired_clock_skew_warning, format_clock_skew_duration, format_duration,
|
clear_expired_clock_skew_warning, format_clock_skew_duration, format_duration,
|
||||||
format_relative_ago, initial_window_position, now_playing_label, reconnect_attempt_chime,
|
format_relative_ago, friend_presence_notification, initial_window_position,
|
||||||
reconnected_chime, set_peer_gate_config, set_peer_volume_config, show_clock_skew_warning,
|
now_playing_label, reconnect_attempt_chime, reconnected_chime, selected_wav_path,
|
||||||
update,
|
set_peer_gate_config, set_peer_volume_config, show_clock_skew_warning, update,
|
||||||
};
|
};
|
||||||
use iroh::SecretKey;
|
use iroh::SecretKey;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
@@ -9776,6 +9932,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
state.audio_levels.insert(peer, 0.5);
|
state.audio_levels.insert(peer, 0.5);
|
||||||
state.locally_muted.insert(peer);
|
state.locally_muted.insert(peer);
|
||||||
|
state.peer_audio_expanded.insert(peer);
|
||||||
state.chat_messages.push(ChatEntry {
|
state.chat_messages.push(ChatEntry {
|
||||||
name: "Peer".to_string(),
|
name: "Peer".to_string(),
|
||||||
text: "old room".to_string(),
|
text: "old room".to_string(),
|
||||||
@@ -9837,6 +9994,7 @@ mod tests {
|
|||||||
assert!(state.peers.is_empty());
|
assert!(state.peers.is_empty());
|
||||||
assert!(state.audio_levels.is_empty());
|
assert!(state.audio_levels.is_empty());
|
||||||
assert!(state.locally_muted.is_empty());
|
assert!(state.locally_muted.is_empty());
|
||||||
|
assert!(state.peer_audio_expanded.is_empty());
|
||||||
assert!(state.chat_messages.is_empty());
|
assert!(state.chat_messages.is_empty());
|
||||||
assert!(state.chat_input.is_empty());
|
assert!(state.chat_input.is_empty());
|
||||||
assert!(state.attachments.len() == 0);
|
assert!(state.attachments.len() == 0);
|
||||||
@@ -9886,6 +10044,34 @@ mod tests {
|
|||||||
panic!("clip player did not stop during room reset");
|
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]
|
#[test]
|
||||||
fn clock_skew_warning_shows_dismisses_and_expires() {
|
fn clock_skew_warning_shows_dismisses_and_expires() {
|
||||||
let mut state = AppState::default();
|
let mut state = AppState::default();
|
||||||
@@ -10579,6 +10765,91 @@ mod tests {
|
|||||||
|
|
||||||
const W: f32 = 200.0;
|
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]
|
#[test]
|
||||||
fn gate_drag_maps_left_edge_to_zero() {
|
fn gate_drag_maps_left_edge_to_zero() {
|
||||||
assert_eq!(GateMeter::x_to_threshold(0.0, W), 0.0);
|
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.
|
// Empty queue + a fresh full pacer → dispatched immediately.
|
||||||
assert_eq!(status_of(&state, id), Some(SendStatus::Pending));
|
assert_eq!(status_of(&state, id), Some(SendStatus::Pending));
|
||||||
assert!(state.send_payloads.contains_key(&id));
|
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));
|
assert_eq!(status_of(&state, id), Some(SendStatus::Broadcast));
|
||||||
// A completed send releases its retry payload.
|
// A completed send releases its retry payload.
|
||||||
assert!(!state.send_payloads.contains_key(&id));
|
assert!(!state.send_payloads.contains_key(&id));
|
||||||
@@ -10873,7 +11144,7 @@ mod tests {
|
|||||||
let mut state = AppState::default();
|
let mut state = AppState::default();
|
||||||
let id = push_own(&mut state, "yo");
|
let id = push_own(&mut state, "yo");
|
||||||
state.submit_send(id, PendingSend::Text("yo".to_string()));
|
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!(
|
assert_eq!(
|
||||||
status_of(&state, id),
|
status_of(&state, id),
|
||||||
Some(SendStatus::Failed("not in a room".to_string()))
|
Some(SendStatus::Failed("not in a room".to_string()))
|
||||||
@@ -10889,11 +11160,11 @@ mod tests {
|
|||||||
state.submit_send(a, PendingSend::Text("a".to_string()));
|
state.submit_send(a, PendingSend::Text("a".to_string()));
|
||||||
let b = push_own(&mut state, "b");
|
let b = push_own(&mut state, "b");
|
||||||
state.submit_send(b, PendingSend::Text("b".to_string()));
|
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, a), Some(SendStatus::Broadcast));
|
||||||
assert_eq!(status_of(&state, b), Some(SendStatus::Pending));
|
assert_eq!(status_of(&state, b), Some(SendStatus::Pending));
|
||||||
// A result for an id with no matching entry is a harmless no-op.
|
// 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));
|
assert_eq!(status_of(&state, b), Some(SendStatus::Pending));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10907,7 +11178,7 @@ mod tests {
|
|||||||
state
|
state
|
||||||
.chat_messages
|
.chat_messages
|
||||||
.retain(|m| m.local_send.as_ref().map(|s| s.id) != Some(id));
|
.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.
|
// No entry to mark → the payload must not leak.
|
||||||
assert!(!state.send_payloads.contains_key(&id));
|
assert!(!state.send_payloads.contains_key(&id));
|
||||||
}
|
}
|
||||||
@@ -10922,7 +11193,7 @@ mod tests {
|
|||||||
assert!(state.send_queue.is_empty());
|
assert!(state.send_queue.is_empty());
|
||||||
assert!(state.send_payloads.is_empty());
|
assert!(state.send_payloads.is_empty());
|
||||||
// A late result for the pre-reset send touches nothing and adds no entry.
|
// 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.chat_messages.is_empty());
|
||||||
assert!(state.send_payloads.is_empty());
|
assert!(state.send_payloads.is_empty());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -478,6 +478,14 @@ pub struct AppConfig {
|
|||||||
pub custom_sound_mic_toggle: Option<String>,
|
pub custom_sound_mic_toggle: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub custom_sound_reconnect_failed: Option<String>,
|
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
|
/// Per-sound enable flags (W6). The master `notifications_enabled` toggle
|
||||||
/// gates ALL chimes; these let the user silence individual events while the
|
/// 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.
|
/// 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,
|
pub sound_mic_toggle_enabled: bool,
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub sound_reconnect_failed_enabled: bool,
|
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).
|
/// Optional override for the `pixelpass` binary location (screen share).
|
||||||
/// Empty / unset = look it up on `$PATH`. Hand-editable; no Settings UI yet.
|
/// Empty / unset = look it up on `$PATH`. Hand-editable; no Settings UI yet.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -601,6 +617,10 @@ impl Default for AppConfig {
|
|||||||
custom_sound_self_leave: None,
|
custom_sound_self_leave: None,
|
||||||
custom_sound_mic_toggle: None,
|
custom_sound_mic_toggle: None,
|
||||||
custom_sound_reconnect_failed: 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_self_join_enabled: true,
|
||||||
sound_peer_join_enabled: true,
|
sound_peer_join_enabled: true,
|
||||||
sound_peer_leave_enabled: true,
|
sound_peer_leave_enabled: true,
|
||||||
@@ -609,6 +629,10 @@ impl Default for AppConfig {
|
|||||||
sound_self_leave_enabled: true,
|
sound_self_leave_enabled: true,
|
||||||
sound_mic_toggle_enabled: true,
|
sound_mic_toggle_enabled: true,
|
||||||
sound_reconnect_failed_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,
|
pixelpass_path: None,
|
||||||
screen_share: ScreenShareSettings::default(),
|
screen_share: ScreenShareSettings::default(),
|
||||||
recents: Vec::new(),
|
recents: Vec::new(),
|
||||||
@@ -639,6 +663,10 @@ impl AppConfig {
|
|||||||
Sound::SelfLeave => self.sound_self_leave_enabled,
|
Sound::SelfLeave => self.sound_self_leave_enabled,
|
||||||
Sound::MicToggle => self.sound_mic_toggle_enabled,
|
Sound::MicToggle => self.sound_mic_toggle_enabled,
|
||||||
Sound::ReconnectFailed => self.sound_reconnect_failed_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::SelfLeave => self.sound_self_leave_enabled = enabled,
|
||||||
Sound::MicToggle => self.sound_mic_toggle_enabled = enabled,
|
Sound::MicToggle => self.sound_mic_toggle_enabled = enabled,
|
||||||
Sound::ReconnectFailed => self.sound_reconnect_failed_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_self_leave.is_none());
|
||||||
assert!(deserialized.custom_sound_mic_toggle.is_none());
|
assert!(deserialized.custom_sound_mic_toggle.is_none());
|
||||||
assert!(deserialized.custom_sound_reconnect_failed.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, ScreenShareSettings::default());
|
||||||
assert_eq!(deserialized.screen_share.quality, ShareQuality::Auto);
|
assert_eq!(deserialized.screen_share.quality, ShareQuality::Auto);
|
||||||
assert_eq!(deserialized.screen_share.player, SharePlayer::Mpv);
|
assert_eq!(deserialized.screen_share.player, SharePlayer::Mpv);
|
||||||
|
|||||||
+25
-1
@@ -80,6 +80,14 @@ pub enum Sound {
|
|||||||
MicToggle,
|
MicToggle,
|
||||||
/// Reconnect failed / peer evicted.
|
/// Reconnect failed / peer evicted.
|
||||||
ReconnectFailed,
|
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 {
|
impl Sound {
|
||||||
@@ -93,10 +101,14 @@ impl Sound {
|
|||||||
Sound::SelfLeave,
|
Sound::SelfLeave,
|
||||||
Sound::MicToggle,
|
Sound::MicToggle,
|
||||||
Sound::ReconnectFailed,
|
Sound::ReconnectFailed,
|
||||||
|
Sound::ChatSent,
|
||||||
|
Sound::ChatReceived,
|
||||||
|
Sound::ContactOnline,
|
||||||
|
Sound::ContactOffline,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Number of distinct notification events.
|
/// 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`.
|
/// Stable 0-based index into the per-sound flag array. Must match `ALL`.
|
||||||
fn index(self) -> usize {
|
fn index(self) -> usize {
|
||||||
@@ -109,6 +121,10 @@ impl Sound {
|
|||||||
Sound::SelfLeave => 5,
|
Sound::SelfLeave => 5,
|
||||||
Sound::MicToggle => 6,
|
Sound::MicToggle => 6,
|
||||||
Sound::ReconnectFailed => 7,
|
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::SelfLeave => include_bytes!("../assets/sounds/self-leave.wav"),
|
||||||
Sound::MicToggle => include_bytes!("../assets/sounds/mic-toggle.wav"),
|
Sound::MicToggle => include_bytes!("../assets/sounds/mic-toggle.wav"),
|
||||||
Sound::ReconnectFailed => include_bytes!("../assets/sounds/reconnect-failed.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::SelfLeave => "self-leave",
|
||||||
Sound::MicToggle => "mic-toggle",
|
Sound::MicToggle => "mic-toggle",
|
||||||
Sound::ReconnectFailed => "reconnect-failed",
|
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
|
/// player is reaped in a background task so it doesn't linger as a zombie when
|
||||||
/// its window closes.
|
/// its window closes.
|
||||||
///
|
///
|
||||||
/// The flags keep latency low while preserving A/V sync. We deliberately do
|
/// The buffering posture chooses the latency/A/V-sync tradeoff. Low latency
|
||||||
/// NOT pass mpv's `--untimed`: that displays each video frame the instant it
|
/// passes mpv's `--untimed`, displaying frames as soon as they decode so a live
|
||||||
/// decodes, ignoring audio timestamps, which makes a shared *video* drift
|
/// share cannot accumulate several seconds in the reliable QUIC/TCP/player
|
||||||
/// progressively out of sync with its audio. Pacing to the audio clock costs a
|
/// buffer chain. Smooth leaves timestamp pacing enabled, which keeps a shared
|
||||||
/// little latency (negligible for pointing at a desktop) and keeps a shared
|
/// video's audio and video synchronized at the cost of extra live latency.
|
||||||
/// video in sync. We also leave hwdec at the `low-latency` default (software
|
/// Hardware decoding remains opt-in: forcing `--hwdec=auto` froze some viewers
|
||||||
/// decode): forcing `--hwdec=auto` froze some viewers on frame 1 while audio
|
/// on frame 1 while audio kept playing.
|
||||||
/// kept playing.
|
|
||||||
fn launch_player(url: &str, settings: &ScreenShareSettings) -> std::io::Result<()> {
|
fn launch_player(url: &str, settings: &ScreenShareSettings) -> std::io::Result<()> {
|
||||||
let mpv_args = mpv_args(settings);
|
let mpv_args = mpv_args(settings);
|
||||||
let vlc_args = vlc_args(settings);
|
let vlc_args = vlc_args(settings);
|
||||||
@@ -648,6 +647,12 @@ pub fn mpv_args(settings: &ScreenShareSettings) -> Vec<String> {
|
|||||||
match settings.buffering {
|
match settings.buffering {
|
||||||
ShareBuffering::LowLatency => {
|
ShareBuffering::LowLatency => {
|
||||||
args.push("--profile=low-latency".to_string());
|
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("--audio-buffer=0.2".to_string());
|
||||||
args.push("--demuxer-readahead-secs=0.5".to_string());
|
args.push("--demuxer-readahead-secs=0.5".to_string());
|
||||||
}
|
}
|
||||||
@@ -828,6 +833,7 @@ mod tests {
|
|||||||
mpv_args(&ScreenShareSettings::default()),
|
mpv_args(&ScreenShareSettings::default()),
|
||||||
vec![
|
vec![
|
||||||
"--profile=low-latency",
|
"--profile=low-latency",
|
||||||
|
"--untimed",
|
||||||
"--audio-buffer=0.2",
|
"--audio-buffer=0.2",
|
||||||
"--demuxer-readahead-secs=0.5",
|
"--demuxer-readahead-secs=0.5",
|
||||||
"--demuxer-max-bytes=2M",
|
"--demuxer-max-bytes=2M",
|
||||||
|
|||||||
Reference in New Issue
Block a user