Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0588d92537 | ||
|
|
b4a4c00711 | ||
|
|
8c4f4a0b8b | ||
|
|
76c4f68bb3 | ||
|
|
c427231858 | ||
|
|
4bfc18463b | ||
|
|
26d66007de |
@@ -4,6 +4,53 @@ All notable changes to PeerSpeak are documented here.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.6.6] — 2026-07-19
|
||||
|
||||
### Fixed
|
||||
- **A screen share that falls behind now catches back up.** On a lossy
|
||||
connection (satellite links are the worst case) the share could settle several
|
||||
seconds behind the host and simply stay there for the rest of the call. The
|
||||
viewer now notices a deep buffer and plays imperceptibly fast until it is back
|
||||
at the live edge — the audio stays in tune and in sync while it does. This
|
||||
replaces the previous attempt at the problem, which measurement showed did not
|
||||
help. Applies to the Low latency setting; Smooth intentionally keeps its
|
||||
larger buffer.
|
||||
|
||||
### Changed
|
||||
- **Low latency now keeps a tighter viewer buffer.** The screen-share cache
|
||||
setting is a size in megabytes, which at a given bitrate quietly decides how
|
||||
many *seconds* behind a viewer can drift — a 2 MB buffer turned out to hold
|
||||
about six seconds of a typical share. Low latency now caps that buffer at 1 MB
|
||||
regardless of the setting, which halved how far behind a share fell on a bad
|
||||
connection before anything else kicked in. Smooth still honors the value you
|
||||
choose, since a deep buffer is the point of that mode.
|
||||
|
||||
## [0.6.5] — 2026-07-19
|
||||
|
||||
### Added
|
||||
- **Chat message sounds.** Successful outgoing messages and admitted incoming
|
||||
messages now have distinct notification chimes, each with its own enable
|
||||
toggle and optional custom WAV path in Notifications settings.
|
||||
- **Contact presence sounds.** The home-screen contacts list now announces a
|
||||
contact becoming online or offline. Initial online contacts are announced;
|
||||
initial offline results stay silent. Both events have independent toggles and
|
||||
optional custom WAV paths.
|
||||
- **Notification sound browser.** Every notification event now has a native
|
||||
Browse button for choosing a custom WAV instead of typing its path manually.
|
||||
|
||||
### Changed
|
||||
- **Tidier per-participant audio controls.** The equalizer bands and noise gate
|
||||
for each participant now live behind an **"Advanced audio"** foldout instead
|
||||
of being expanded all the time, so a call with several people no longer fills
|
||||
the panel with sliders. The controls themselves are unchanged.
|
||||
|
||||
### Fixed
|
||||
- **Low-latency screen sharing stays near the live edge again.** mpv's
|
||||
timestamp pacing could let stale frames accumulate across the reliable
|
||||
PixelPass transport until a share was 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
|
||||
|
||||
Generated
+1
-1
@@ -4871,7 +4871,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "peerspeak"
|
||||
version = "0.6.4"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "peerspeak"
|
||||
version = "0.6.4"
|
||||
version = "0.6.6"
|
||||
edition = "2024"
|
||||
description = "Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
||||
license = "MIT"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed).
|
||||
|
||||
#define MyAppName "PeerSpeak"
|
||||
#define MyAppVersion "0.6.4"
|
||||
#define MyAppVersion "0.6.6"
|
||||
#define MyAppPublisher "mollusk"
|
||||
#define MyAppExeName "peerspeak.exe"
|
||||
|
||||
|
||||
+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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
//! Live-edge catch-up for the screen-share viewer.
|
||||
//!
|
||||
//! PixelPass carries the share as MPEG-TS over a reliable, ordered transport. On
|
||||
//! a lossy link (satellite handovers are the pathological case) every loss burst
|
||||
//! becomes retransmission plus head-of-line blocking, and the viewer absorbs the
|
||||
//! stall as buffered latency. Nothing in the chain ever trims that buffer back,
|
||||
//! so the picture ends up seconds behind the host and stays there.
|
||||
//!
|
||||
//! Measured on a `tc netem` rig that simulates a satellite link (40 ms +/- 20 ms
|
||||
//! jitter, 0.5% loss, a 250 ms/30%-loss handover burst every 15 s): a viewer with
|
||||
//! ordinary timestamp pacing settles ~1.24 s behind. mpv's `--untimed` does NOT
|
||||
//! help (~1.38 s, marginally worse) because it only removes pacing at
|
||||
//! *presentation* while audio still drains at 1x the DAC rate, so an accumulated
|
||||
//! buffer never shrinks. Returning to the live edge requires consuming the
|
||||
//! backlog faster than it arrives.
|
||||
//!
|
||||
//! So we nudge playback slightly faster than realtime while the buffer is deep,
|
||||
//! and drop back to 1x once it has drained. mpv's default pitch correction
|
||||
//! (`scaletempo2`) keeps a 5% speedup inaudible, and because audio and video are
|
||||
//! sped up together A/V sync is preserved — unlike `--untimed`.
|
||||
//!
|
||||
//! The control law and the JSON-IPC message handling are pure functions with
|
||||
//! tests; the only I/O is [`drive`], which talks to mpv's `--input-ipc-server`
|
||||
//! socket.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Buffer depth (seconds) above which we start draining.
|
||||
pub const CACHE_HIGH_S: f64 = 1.0;
|
||||
/// Buffer depth (seconds) below which we return to realtime.
|
||||
pub const CACHE_LOW_S: f64 = 0.4;
|
||||
/// The buffer depth we aim to sit at; the drain rate is proportional to how far
|
||||
/// above this the buffer actually is.
|
||||
pub const CACHE_TARGET_S: f64 = 0.5;
|
||||
/// Extra playback rate per second of excess buffer.
|
||||
pub const CATCHUP_GAIN: f64 = 0.05;
|
||||
/// Hard ceiling on the drain rate. Beyond this the speedup stops being
|
||||
/// unnoticeable, and a share that far behind is better served by the operator
|
||||
/// restarting it than by a chipmunk impression.
|
||||
pub const MAX_CATCHUP_SPEED: f64 = 1.15;
|
||||
/// Normal realtime playback.
|
||||
pub const NORMAL_SPEED: f64 = 1.0;
|
||||
/// How often we sample the buffer depth.
|
||||
pub const POLL_INTERVAL: Duration = Duration::from_millis(500);
|
||||
/// Smallest rate change worth sending to the player.
|
||||
pub const SPEED_EPSILON: f64 = 0.005;
|
||||
|
||||
/// The property we watch on the viewer.
|
||||
const CACHE_PROPERTY: &str = "demuxer-cache-duration";
|
||||
|
||||
/// Decide the playback rate for the next interval.
|
||||
///
|
||||
/// Proportional, because a fixed small speedup cannot recover a large backlog in
|
||||
/// any reasonable time: draining 6 s at 1.05x takes two minutes, which a viewer
|
||||
/// experiences as "still broken". The drain rate instead scales with how deep
|
||||
/// the buffer is, so a bad handover is cleared in tens of seconds while a small
|
||||
/// excursion still gets only a gentle, inaudible nudge.
|
||||
///
|
||||
/// Deliberately hysteretic: between [`CACHE_LOW_S`] and [`CACHE_HIGH_S`] the
|
||||
/// current rate is held, so a buffer hovering near a single threshold cannot
|
||||
/// oscillate the speed (and with it the audio pitch) every poll. Pure.
|
||||
///
|
||||
/// A non-finite reading (mpv reports `null` before playback starts, and the
|
||||
/// caller maps that to NaN) holds the current rate rather than guessing.
|
||||
pub fn catchup_speed(cache_s: f64, current: f64) -> f64 {
|
||||
if !cache_s.is_finite() {
|
||||
return current;
|
||||
}
|
||||
if cache_s < CACHE_LOW_S {
|
||||
return NORMAL_SPEED;
|
||||
}
|
||||
if cache_s <= CACHE_HIGH_S {
|
||||
return current;
|
||||
}
|
||||
let excess = cache_s - CACHE_TARGET_S;
|
||||
(NORMAL_SPEED + CATCHUP_GAIN * excess).clamp(NORMAL_SPEED, MAX_CATCHUP_SPEED)
|
||||
}
|
||||
|
||||
/// Where mpv should create its IPC socket. Kept separate from the runtime
|
||||
/// lookup so tests can pin a directory. Pure.
|
||||
pub fn socket_path(dir: &Path, token: u64) -> PathBuf {
|
||||
dir.join(format!("peerspeak-mpv-{token}.sock"))
|
||||
}
|
||||
|
||||
/// The directory for the IPC socket: the XDG runtime dir when the session
|
||||
/// provides one (tmpfs, user-private, cleaned at logout), else the temp dir.
|
||||
pub fn socket_dir() -> PathBuf {
|
||||
std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(std::env::temp_dir)
|
||||
}
|
||||
|
||||
/// A `get_property` request for the buffer depth. Pure.
|
||||
pub fn get_cache_request(request_id: u64) -> String {
|
||||
format!(r#"{{"command":["get_property","{CACHE_PROPERTY}"],"request_id":{request_id}}}"#)
|
||||
}
|
||||
|
||||
/// A `set_property` request for the playback rate. Pure.
|
||||
pub fn set_speed_request(request_id: u64, speed: f64) -> String {
|
||||
format!(r#"{{"command":["set_property","speed",{speed}],"request_id":{request_id}}}"#)
|
||||
}
|
||||
|
||||
/// Extract the buffer depth from one line of mpv's IPC output.
|
||||
///
|
||||
/// mpv interleaves unsolicited event lines with command replies, so a line is
|
||||
/// only ours when it carries the matching `request_id`. Returns:
|
||||
/// - `Some(Some(secs))` — our reply, with a usable number,
|
||||
/// - `Some(None)` — our reply, but no number (mpv sends `"data":null` before
|
||||
/// playback starts, and reports `error` while the demuxer has no cache yet),
|
||||
/// - `None` — not our reply (an event, or another command's response).
|
||||
///
|
||||
/// Pure.
|
||||
pub fn parse_cache_response(line: &str, request_id: u64) -> Option<Option<f64>> {
|
||||
let value: serde_json::Value = serde_json::from_str(line.trim()).ok()?;
|
||||
let id = value.get("request_id")?.as_u64()?;
|
||||
if id != request_id {
|
||||
return None;
|
||||
}
|
||||
if value.get("error").and_then(|e| e.as_str()) != Some("success") {
|
||||
return Some(None);
|
||||
}
|
||||
Some(value.get("data").and_then(|d| d.as_f64()))
|
||||
}
|
||||
|
||||
/// Drive one mpv viewer's playback rate over its JSON IPC socket.
|
||||
///
|
||||
/// Runs until mpv exits (the socket dies), so it is spawned detached alongside
|
||||
/// the player and needs no shutdown signal. Every failure path just ends the
|
||||
/// task: catch-up is an optimization, and a viewer that never gets it still
|
||||
/// plays, exactly as before this existed.
|
||||
#[cfg(unix)]
|
||||
pub async fn drive(socket: PathBuf) {
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::UnixStream;
|
||||
|
||||
// mpv creates the socket a moment after exec, so the first connects race it.
|
||||
let mut stream = None;
|
||||
for _ in 0..40 {
|
||||
match UnixStream::connect(&socket).await {
|
||||
Ok(s) => {
|
||||
stream = Some(s);
|
||||
break;
|
||||
}
|
||||
Err(_) => tokio::time::sleep(Duration::from_millis(250)).await,
|
||||
}
|
||||
}
|
||||
let Some(stream) = stream else {
|
||||
crate::log_msg("livesync: mpv IPC socket never appeared; catch-up disabled");
|
||||
return;
|
||||
};
|
||||
|
||||
let (read_half, mut write_half) = stream.into_split();
|
||||
let mut lines = BufReader::new(read_half).lines();
|
||||
let mut request_id: u64 = 0;
|
||||
let mut speed = NORMAL_SPEED;
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
|
||||
request_id += 1;
|
||||
let query = format!("{}\n", get_cache_request(request_id));
|
||||
if write_half.write_all(query.as_bytes()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
|
||||
// Skip event lines until our reply arrives.
|
||||
let cache = loop {
|
||||
match lines.next_line().await {
|
||||
Ok(Some(line)) => {
|
||||
if let Some(value) = parse_cache_response(&line, request_id) {
|
||||
break value;
|
||||
}
|
||||
}
|
||||
// Socket closed or unreadable: mpv is gone.
|
||||
_ => return,
|
||||
}
|
||||
};
|
||||
|
||||
let cache = cache.unwrap_or(f64::NAN);
|
||||
let next = catchup_speed(cache, speed);
|
||||
// A proportional law would otherwise re-send on every wobble of the
|
||||
// reading; only a change worth hearing is worth a round trip.
|
||||
if (next - speed).abs() > SPEED_EPSILON {
|
||||
speed = next;
|
||||
request_id += 1;
|
||||
let set = format!("{}\n", set_speed_request(request_id, speed));
|
||||
if write_half.write_all(set.as_bytes()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
crate::log_msg(&format!(
|
||||
"livesync: cache {cache:.2}s -> playback speed {speed}x"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deep_buffer_speeds_up_and_drained_buffer_returns_to_realtime() {
|
||||
assert!(catchup_speed(1.5, NORMAL_SPEED) > NORMAL_SPEED);
|
||||
assert_eq!(catchup_speed(0.1, MAX_CATCHUP_SPEED), NORMAL_SPEED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_rate_scales_with_how_far_behind_we_are() {
|
||||
// The point of the proportional law: a small excursion gets a gentle
|
||||
// nudge, a deep backlog gets real recovery.
|
||||
let small = catchup_speed(1.5, NORMAL_SPEED);
|
||||
let large = catchup_speed(4.0, NORMAL_SPEED);
|
||||
assert!(
|
||||
large > small,
|
||||
"deeper buffer must drain faster: {small} vs {large}"
|
||||
);
|
||||
assert!(
|
||||
(small - 1.05).abs() < 1e-9,
|
||||
"1.5s buffer -> 1.05x, got {small}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_rate_is_capped_so_it_never_sounds_absurd() {
|
||||
// The ~6 s standing buffer measured on the netem rig, and far worse.
|
||||
assert_eq!(catchup_speed(6.0, NORMAL_SPEED), MAX_CATCHUP_SPEED);
|
||||
assert_eq!(catchup_speed(600.0, NORMAL_SPEED), MAX_CATCHUP_SPEED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hysteresis_band_holds_the_current_speed() {
|
||||
// Between the marks nothing changes, whichever side we came from —
|
||||
// this is what stops the rate (and audio pitch) oscillating.
|
||||
for cache in [CACHE_LOW_S, 0.7, CACHE_HIGH_S] {
|
||||
assert_eq!(catchup_speed(cache, NORMAL_SPEED), NORMAL_SPEED);
|
||||
assert_eq!(catchup_speed(cache, MAX_CATCHUP_SPEED), MAX_CATCHUP_SPEED);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_cache_holds_the_current_speed() {
|
||||
assert_eq!(
|
||||
catchup_speed(f64::NAN, MAX_CATCHUP_SPEED),
|
||||
MAX_CATCHUP_SPEED
|
||||
);
|
||||
assert_eq!(catchup_speed(f64::INFINITY, NORMAL_SPEED), NORMAL_SPEED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_handover_cycle_drains_then_settles() {
|
||||
// Buffer grows through a loss burst, then drains as we play faster.
|
||||
let mut speed = NORMAL_SPEED;
|
||||
for cache in [0.2, 0.5, 1.2, 3.4, 1.4, 0.9, 0.6, 0.3, 0.2] {
|
||||
speed = catchup_speed(cache, speed);
|
||||
}
|
||||
assert_eq!(
|
||||
speed, NORMAL_SPEED,
|
||||
"should be back at realtime once drained"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requests_are_valid_json_with_their_ids() {
|
||||
let get: serde_json::Value = serde_json::from_str(&get_cache_request(7)).unwrap();
|
||||
assert_eq!(get["request_id"], 7);
|
||||
assert_eq!(get["command"][0], "get_property");
|
||||
assert_eq!(get["command"][1], CACHE_PROPERTY);
|
||||
|
||||
let set: serde_json::Value = serde_json::from_str(&set_speed_request(8, 1.05)).unwrap();
|
||||
assert_eq!(set["request_id"], 8);
|
||||
assert_eq!(set["command"][0], "set_property");
|
||||
assert_eq!(set["command"][1], "speed");
|
||||
assert_eq!(set["command"][2], 1.05);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_our_reply_only() {
|
||||
assert_eq!(
|
||||
parse_cache_response(r#"{"error":"success","data":1.25,"request_id":3}"#, 3),
|
||||
Some(Some(1.25))
|
||||
);
|
||||
// Another command's reply, and an unsolicited event, are not ours.
|
||||
assert_eq!(
|
||||
parse_cache_response(r#"{"error":"success","data":1.25,"request_id":4}"#, 3),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
parse_cache_response(r#"{"event":"playback-restart"}"#, 3),
|
||||
None
|
||||
);
|
||||
assert_eq!(parse_cache_response("not json", 3), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_without_a_usable_number_is_ours_but_empty() {
|
||||
// mpv before playback starts, and while the demuxer has no cache.
|
||||
assert_eq!(
|
||||
parse_cache_response(r#"{"error":"success","data":null,"request_id":1}"#, 1),
|
||||
Some(None)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_cache_response(r#"{"error":"property unavailable","request_id":1}"#, 1),
|
||||
Some(None)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn socket_path_is_scoped_to_its_token() {
|
||||
let a = socket_path(Path::new("/run/user/1000"), 42);
|
||||
assert_eq!(a, Path::new("/run/user/1000/peerspeak-mpv-42.sock"));
|
||||
assert_ne!(a, socket_path(Path::new("/run/user/1000"), 43));
|
||||
}
|
||||
}
|
||||
+156
-22
@@ -21,6 +21,8 @@ use std::time::Duration;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::{Child, Command};
|
||||
|
||||
pub mod livesync;
|
||||
|
||||
use crate::config::{ScreenShareSettings, ShareBuffering, SharePlayer, ShareQuality};
|
||||
|
||||
/// The binary we shell out to. Looked up on `$PATH` unless a config override
|
||||
@@ -45,6 +47,12 @@ const MAX_TICKET_LEN: usize = 512;
|
||||
/// are short ("Firefox", "mpv"); this only guards against a pathological value.
|
||||
const MAX_APP_NAME_LEN: usize = 256;
|
||||
|
||||
/// Ceiling on the viewer's demuxer byte cache in the Low latency posture. The
|
||||
/// cache is a *byte* budget, so at a given bitrate it sets the worst-case
|
||||
/// backlog in seconds; keeping it tight is what stops a lossy link parking the
|
||||
/// viewer seconds behind before [`livesync`] even gets a chance to drain it.
|
||||
const LOW_LATENCY_CACHE_CAP_MB: u32 = 1;
|
||||
|
||||
/// How long to wait for the host to emit its ticket / the viewer to connect
|
||||
/// before giving up and killing the child. Startup is normally sub-second; this
|
||||
/// is only a safety net so a hung pixelpass can't wedge the caller forever.
|
||||
@@ -607,16 +615,28 @@ 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
|
||||
/// keeps the viewer at the live edge: mpv gets an IPC socket and [`livesync`]
|
||||
/// drains a lagging buffer by playing slightly fast (pitch-corrected, so A/V
|
||||
/// sync is preserved). Smooth leaves a deeper buffer alone, trading live
|
||||
/// latency for immunity to jitter. 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);
|
||||
// One socket per viewer launch, so overlapping shares can't collide on it.
|
||||
// Unix only: mpv's IPC is a named pipe on Windows, which `livesync` does not
|
||||
// speak, and an unusable socket path on the argv would help nobody.
|
||||
#[cfg(unix)]
|
||||
let ipc_socket = Some(livesync::socket_path(
|
||||
&livesync::socket_dir(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
.unwrap_or(0),
|
||||
));
|
||||
#[cfg(not(unix))]
|
||||
let ipc_socket: Option<PathBuf> = None;
|
||||
|
||||
let mpv_args = mpv_args(settings, ipc_socket.as_deref());
|
||||
let vlc_args = vlc_args(settings);
|
||||
let first = match settings.player {
|
||||
SharePlayer::Mpv => ("mpv", &mpv_args),
|
||||
@@ -627,15 +647,32 @@ fn launch_player(url: &str, settings: &ScreenShareSettings) -> std::io::Result<(
|
||||
SharePlayer::Vlc => ("mpv", &mpv_args),
|
||||
};
|
||||
|
||||
let child = match spawn_player(first.0, first.1, url) {
|
||||
Ok(c) => c,
|
||||
Err(_) => spawn_player(second.0, second.1, url).map_err(|_| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"no media player found — install mpv or vlc to watch screen shares",
|
||||
)
|
||||
})?,
|
||||
let (launched, child) = match spawn_player(first.0, first.1, url) {
|
||||
Ok(c) => (first.0, c),
|
||||
Err(_) => (
|
||||
second.0,
|
||||
spawn_player(second.0, second.1, url).map_err(|_| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"no media player found — install mpv or vlc to watch screen shares",
|
||||
)
|
||||
})?,
|
||||
),
|
||||
};
|
||||
|
||||
// Only when the socket actually reached the argv: mpv (VLC has no
|
||||
// equivalent IPC) in the Low latency posture. The driver ends by itself when
|
||||
// the player exits, so it needs no shutdown path.
|
||||
#[cfg(unix)]
|
||||
if launched == "mpv"
|
||||
&& settings.buffering == ShareBuffering::LowLatency
|
||||
&& let Some(socket) = ipc_socket
|
||||
{
|
||||
tokio::spawn(livesync::drive(socket));
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = launched;
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut child = child;
|
||||
let _ = child.wait().await;
|
||||
@@ -643,11 +680,23 @@ fn launch_player(url: &str, settings: &ScreenShareSettings) -> std::io::Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn mpv_args(settings: &ScreenShareSettings) -> Vec<String> {
|
||||
/// Build the argv for an mpv viewer.
|
||||
///
|
||||
/// `ipc_socket` is where mpv should expose its JSON IPC socket so [`livesync`]
|
||||
/// can drain a lagging buffer. It is wired up for Low latency only: Smooth
|
||||
/// deliberately holds a ~2 s readahead, which the catch-up thresholds would
|
||||
/// fight on every poll.
|
||||
pub fn mpv_args(settings: &ScreenShareSettings, ipc_socket: Option<&Path>) -> Vec<String> {
|
||||
let mut args = Vec::new();
|
||||
match settings.buffering {
|
||||
ShareBuffering::LowLatency => {
|
||||
args.push("--profile=low-latency".to_string());
|
||||
// Pixelpass carries MPEG-TS through reliable ordered QUIC/TCP, so a
|
||||
// lossy link turns every retransmission into buffered latency that
|
||||
// nothing trims back. `--untimed` does NOT fix that (measured
|
||||
// marginally worse: it only unpaces *presentation*, while audio
|
||||
// still drains at 1x, so the backlog never shrinks) — the viewer
|
||||
// instead drains it by playing slightly fast, see `livesync`.
|
||||
args.push("--audio-buffer=0.2".to_string());
|
||||
args.push("--demuxer-readahead-secs=0.5".to_string());
|
||||
}
|
||||
@@ -656,10 +705,25 @@ pub fn mpv_args(settings: &ScreenShareSettings) -> Vec<String> {
|
||||
args.push("--demuxer-readahead-secs=2".to_string());
|
||||
}
|
||||
}
|
||||
args.push(format!("--demuxer-max-bytes={}M", settings.cache_mb));
|
||||
// The byte cap is what bounds how far behind a viewer can silently fall:
|
||||
// a demuxer allowed 2 MiB will happily sit on ~6 s of a 2.5 Mbps share (as
|
||||
// measured on the netem rig) and call it a buffer. Low latency therefore
|
||||
// gets a tighter ceiling than the user's Smooth-oriented setting, so the
|
||||
// catch-up has less to claw back after a bad patch of link.
|
||||
let cache_mb = match settings.buffering {
|
||||
ShareBuffering::LowLatency => settings.cache_mb.min(LOW_LATENCY_CACHE_CAP_MB),
|
||||
ShareBuffering::Smooth => settings.cache_mb,
|
||||
};
|
||||
args.push(format!("--demuxer-max-bytes={cache_mb}M"));
|
||||
if settings.hardware_decode {
|
||||
args.push("--hwdec=auto".to_string());
|
||||
}
|
||||
if let Some(socket) = ipc_socket
|
||||
&& settings.buffering == ShareBuffering::LowLatency
|
||||
{
|
||||
args.push(format!("--input-ipc-server={}", socket.display()));
|
||||
}
|
||||
// Extra args stay last so a user override wins over everything above.
|
||||
args.extend(split_extra_args(&settings.extra_mpv_args));
|
||||
args
|
||||
}
|
||||
@@ -825,16 +889,86 @@ mod tests {
|
||||
#[test]
|
||||
fn mpv_args_default_matches_low_latency_software_decode() {
|
||||
assert_eq!(
|
||||
mpv_args(&ScreenShareSettings::default()),
|
||||
mpv_args(&ScreenShareSettings::default(), None),
|
||||
vec![
|
||||
"--profile=low-latency",
|
||||
"--audio-buffer=0.2",
|
||||
"--demuxer-readahead-secs=0.5",
|
||||
"--demuxer-max-bytes=2M",
|
||||
"--demuxer-max-bytes=1M",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_latency_gets_the_ipc_socket_for_live_edge_catch_up() {
|
||||
let args = mpv_args(
|
||||
&ScreenShareSettings::default(),
|
||||
Some(Path::new("/run/user/1000/peerspeak-mpv-1.sock")),
|
||||
);
|
||||
assert!(
|
||||
args.contains(&"--input-ipc-server=/run/user/1000/peerspeak-mpv-1.sock".to_string()),
|
||||
"low latency drains a lagging buffer over mpv IPC: {args:?}"
|
||||
);
|
||||
// The flag that used to hold this posture at the live edge measured no
|
||||
// better than pacing, and cost A/V sync — it must not come back.
|
||||
assert!(!args.contains(&"--untimed".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooth_keeps_its_deep_buffer_and_gets_no_ipc_socket() {
|
||||
let settings = ScreenShareSettings {
|
||||
buffering: ShareBuffering::Smooth,
|
||||
..ScreenShareSettings::default()
|
||||
};
|
||||
let args = mpv_args(
|
||||
&settings,
|
||||
Some(Path::new("/run/user/1000/peerspeak-mpv-1.sock")),
|
||||
);
|
||||
assert!(
|
||||
!args.iter().any(|a| a.starts_with("--input-ipc-server")),
|
||||
"catch-up would fight Smooth's deliberate ~2s readahead: {args:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_latency_caps_the_byte_cache_but_smooth_keeps_the_user_value() {
|
||||
// The cache is a byte budget, so at a given bitrate it sets the
|
||||
// worst-case backlog: 2 MiB held ~6 s of a 2.5 Mbps share on the rig.
|
||||
let generous = ScreenShareSettings {
|
||||
cache_mb: 32,
|
||||
..ScreenShareSettings::default()
|
||||
};
|
||||
assert!(
|
||||
mpv_args(&generous, None)
|
||||
.contains(&format!("--demuxer-max-bytes={LOW_LATENCY_CACHE_CAP_MB}M")),
|
||||
"low latency must bound how far behind the viewer can silently fall"
|
||||
);
|
||||
|
||||
let smooth = ScreenShareSettings {
|
||||
cache_mb: 32,
|
||||
buffering: ShareBuffering::Smooth,
|
||||
..ScreenShareSettings::default()
|
||||
};
|
||||
assert!(
|
||||
mpv_args(&smooth, None).contains(&"--demuxer-max-bytes=32M".to_string()),
|
||||
"smooth is the posture where the user asked for a deep buffer"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_extra_args_still_come_last() {
|
||||
let settings = ScreenShareSettings {
|
||||
extra_mpv_args: "--no-osc".to_string(),
|
||||
..ScreenShareSettings::default()
|
||||
};
|
||||
let args = mpv_args(&settings, Some(Path::new("/tmp/s.sock")));
|
||||
assert_eq!(
|
||||
args.last().map(String::as_str),
|
||||
Some("--no-osc"),
|
||||
"a user override has to win over everything we add: {args:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mpv_args_smooth_hwdecode_and_extra_args_last() {
|
||||
let settings = ScreenShareSettings {
|
||||
@@ -846,7 +980,7 @@ mod tests {
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
mpv_args(&settings),
|
||||
mpv_args(&settings, None),
|
||||
vec![
|
||||
"--cache=yes",
|
||||
"--demuxer-readahead-secs=2",
|
||||
|
||||
Reference in New Issue
Block a user