fix(screenshare): per-app audio capture to kill call-loopback echo (A23)
Screen sharing spawned pixelpass with a bare `--host`, so pixelpass
captured the whole default-sink monitor — which contains peerspeak's own
call playout. A viewer therefore heard their OWN voice echoed back out of
the sharer's machine (confirmed live, backlog A23).
pixelpass already supports `--app <NAME>` (capture only one app's audio,
per-app PipeWire routing); peerspeak just never passed it. This wires that
flag through, peerspeak-side only — no pixelpass change.
- screenshare: pure `host_args(audio_app)` builds the host argv, appending
`--app=<name>` (single-token form so a hyphen-leading name can't be
reparsed as a flag) when an app is chosen; `sanitize_app_name` guards the
locally-chosen value; `list_audio_apps`/`parse_audio_apps` enumerate
currently-playing apps via `pactl -f json list sink-inputs` (mirroring how
pixelpass builds its own picker, so the names match what `--app` matches).
- core: `StartScreenShare { audio_app }` + `ListAudioApps`/`AudioAppsListed`.
- GUI: Share Screen now opens a small audio picker (radio-style modal) listing
the playing apps + "All system audio" (warned, = legacy whole-desktop);
picking one starts the share with `--app=<name>`. Reset on room leave.
+6 unit tests (host_args with/without/blank app, sanitize_app_name,
parse_audio_apps dedup + garbage). 430 lib tests, clippy --all-targets clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+240
-4
@@ -426,8 +426,16 @@ pub enum AppMessage {
|
||||
SetBackgroundDim(f32),
|
||||
/// Toggle the Chat drawer open/closed (drawer layout).
|
||||
ToggleDrawerChat,
|
||||
/// Start/stop sharing our own screen (spawns/kills a pixelpass host).
|
||||
/// Start/stop sharing our own screen. When not sharing, opens the audio
|
||||
/// picker (A23) instead of starting immediately; when sharing, stops.
|
||||
ToggleScreenShare,
|
||||
/// Close the screen-share audio picker without sharing.
|
||||
CloseSharePicker,
|
||||
/// Select which app's audio to share in the picker: `Some(name)` for one app,
|
||||
/// `None` for the whole desktop ("All system audio").
|
||||
SelectShareAudioApp(Option<String>),
|
||||
/// Confirm the picker: start the share with the currently selected audio app.
|
||||
ConfirmShareScreen,
|
||||
/// Watch a peer's screen share, identified by their pixelpass ticket.
|
||||
WatchShare(String),
|
||||
/// Result of asynchronously enqueueing the core shutdown command.
|
||||
@@ -565,6 +573,16 @@ pub struct AppState {
|
||||
hotkey_info_open: bool,
|
||||
/// Whether the pixelpass screen-share explainer popup is open (A11).
|
||||
pixelpass_help_open: bool,
|
||||
/// Whether the screen-share audio picker is open (A23). Opened by Share
|
||||
/// Screen when not already sharing; lets the user capture one app's audio
|
||||
/// instead of the whole desktop (which echoes the call back to viewers).
|
||||
share_picker_open: bool,
|
||||
/// Apps currently producing audio, shown in the share picker. Populated from
|
||||
/// `UiEvent::AudioAppsListed` after the picker requests an enumeration.
|
||||
share_audio_apps: Vec<String>,
|
||||
/// The picker's current selection: `Some(name)` = capture that app's audio,
|
||||
/// `None` = "All system audio" (whole desktop; may echo the call).
|
||||
share_audio_selection: Option<String>,
|
||||
/// Whether the Chat drawer is open (drawer layout only).
|
||||
drawer_chat_open: bool,
|
||||
/// Raw mic level (normalized RMS, `0.0..=1.0`) for the settings meter.
|
||||
@@ -634,6 +652,9 @@ impl AppState {
|
||||
self.call_started = None;
|
||||
self.mic_level = 0.0;
|
||||
self.self_sharing = false;
|
||||
self.share_picker_open = false;
|
||||
self.share_audio_apps.clear();
|
||||
self.share_audio_selection = None;
|
||||
}
|
||||
|
||||
fn custom_sound_path(&self, sound: Sound) -> &str {
|
||||
@@ -755,6 +776,9 @@ impl Default for AppState {
|
||||
layout_picker_open: false,
|
||||
hotkey_info_open: false,
|
||||
pixelpass_help_open: false,
|
||||
share_picker_open: false,
|
||||
share_audio_apps: Vec::new(),
|
||||
share_audio_selection: None,
|
||||
drawer_chat_open: false,
|
||||
mic_level: 0.0,
|
||||
mic_test_active: false,
|
||||
@@ -1139,10 +1163,31 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
if state.self_sharing {
|
||||
let _ = state.controller.send(CoreCommand::StopScreenShare);
|
||||
} else {
|
||||
let _ = state.controller.send(CoreCommand::StartScreenShare);
|
||||
state.status_message = "Starting screen share…".to_string();
|
||||
// Open the audio picker instead of sharing immediately, so the
|
||||
// user chooses which app's audio to capture rather than the whole
|
||||
// desktop (which echoes the call back to viewers, A23). Default
|
||||
// selection is "All system audio" (None). Kick off a fresh
|
||||
// enumeration so the list reflects what's playing right now.
|
||||
state.share_picker_open = true;
|
||||
state.share_audio_selection = None;
|
||||
state.share_audio_apps.clear();
|
||||
let _ = state.controller.send(CoreCommand::ListAudioApps);
|
||||
}
|
||||
}
|
||||
AppMessage::CloseSharePicker => {
|
||||
state.share_picker_open = false;
|
||||
}
|
||||
AppMessage::SelectShareAudioApp(app) => {
|
||||
state.share_audio_selection = app;
|
||||
}
|
||||
AppMessage::ConfirmShareScreen => {
|
||||
state.share_picker_open = false;
|
||||
let audio_app = state.share_audio_selection.clone();
|
||||
let _ = state
|
||||
.controller
|
||||
.send(CoreCommand::StartScreenShare { audio_app });
|
||||
state.status_message = "Starting screen share…".to_string();
|
||||
}
|
||||
AppMessage::WatchShare(ticket) => {
|
||||
let _ = state.controller.send(CoreCommand::ViewShare(ticket));
|
||||
state.status_message = "Opening screen share…".to_string();
|
||||
@@ -1298,6 +1343,20 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
state.attachments.insert(key, AttachmentState::Failed(error.clone()), None);
|
||||
state.status_message = format!("Attachment failed: {error}");
|
||||
}
|
||||
UiEvent::AudioAppsListed(apps) => {
|
||||
// Only meaningful while the picker is open; if the user
|
||||
// already cancelled, drop it. Keep the current selection if it
|
||||
// still exists in the refreshed list, else fall back to "All
|
||||
// system audio".
|
||||
if state.share_picker_open {
|
||||
if let Some(sel) = &state.share_audio_selection
|
||||
&& !apps.iter().any(|a| a == sel)
|
||||
{
|
||||
state.share_audio_selection = None;
|
||||
}
|
||||
state.share_audio_apps = apps;
|
||||
}
|
||||
}
|
||||
UiEvent::ScreenShareStarted => {
|
||||
state.self_sharing = true;
|
||||
state.status_message = "Sharing your screen".to_string();
|
||||
@@ -4753,7 +4812,10 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.style(c_style(root_bg, Color::TRANSPARENT, 0.0));
|
||||
|
||||
with_hotkey_info(
|
||||
with_pixelpass_help(with_layout_picker(room.into(), state), state),
|
||||
with_share_picker(
|
||||
with_pixelpass_help(with_layout_picker(room.into(), state), state),
|
||||
state,
|
||||
),
|
||||
state,
|
||||
)
|
||||
}
|
||||
@@ -5302,6 +5364,174 @@ fn with_pixelpass_help<'a>(
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Overlay the screen-share **audio picker** when open (A23). Lets the user
|
||||
/// capture a single app's audio instead of the whole desktop sink — the default
|
||||
/// whole-desktop capture contains our own call playout, so a viewer would
|
||||
/// otherwise hear themselves echoed back. "All system audio" keeps the legacy
|
||||
/// behavior (with a warning); picking an app passes `--app=<name>` to pixelpass.
|
||||
fn with_share_picker<'a>(
|
||||
base: Element<'a, AppMessage>,
|
||||
state: &'a AppState,
|
||||
) -> Element<'a, AppMessage> {
|
||||
if !state.share_picker_open {
|
||||
return base;
|
||||
}
|
||||
let pal = state.config.theme.palette();
|
||||
let crust = pal.crust;
|
||||
let mantle = pal.mantle;
|
||||
let surface = pal.surface;
|
||||
let text_c = pal.text;
|
||||
let subtext = pal.subtext;
|
||||
let blue = pal.blue;
|
||||
let yellow = pal.yellow;
|
||||
|
||||
let backdrop = mouse_area(
|
||||
container(horizontal_space())
|
||||
.width(iced::Length::Fill)
|
||||
.height(iced::Length::Fill)
|
||||
.style(move |_t: &Theme| container::Style {
|
||||
background: Some(Background::Color(Color { a: 0.55, ..crust })),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.on_press(AppMessage::CloseSharePicker);
|
||||
|
||||
// One selectable row: a radio-style dot + label. Highlighted when selected.
|
||||
let opt_row = |selected: bool, label: String, sublabel: Option<&'static str>, msg: AppMessage| {
|
||||
let dot = text(if selected { "●" } else { "○" })
|
||||
.size(14)
|
||||
.color(if selected { blue } else { subtext });
|
||||
let mut labels = column![text(label).size(13).color(text_c)].spacing(2);
|
||||
if let Some(s) = sublabel {
|
||||
labels = labels.push(text(s).size(11).color(yellow));
|
||||
}
|
||||
button(
|
||||
row![dot, labels]
|
||||
.spacing(10)
|
||||
.align_y(iced::alignment::Vertical::Center),
|
||||
)
|
||||
.on_press(msg)
|
||||
.width(iced::Length::Fill)
|
||||
.padding(8)
|
||||
.style(move |_t: &Theme, status: button::Status| {
|
||||
let bg = if selected {
|
||||
Some(Background::Color(surface))
|
||||
} else if matches!(status, button::Status::Hovered) {
|
||||
Some(Background::Color(Color { a: 0.5, ..surface }))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
button::Style {
|
||||
background: bg,
|
||||
text_color: text_c,
|
||||
border: Border { color: Color::TRANSPARENT, width: 0.0, radius: 6.0.into() },
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// "All system audio" first (the whole-desktop default — carries the echo
|
||||
// warning), then each currently-playing app.
|
||||
let mut options = column![opt_row(
|
||||
state.share_audio_selection.is_none(),
|
||||
"All system audio".to_string(),
|
||||
Some("⚠ may echo the call back to viewers"),
|
||||
AppMessage::SelectShareAudioApp(None),
|
||||
)]
|
||||
.spacing(4);
|
||||
for app in &state.share_audio_apps {
|
||||
let selected = state.share_audio_selection.as_deref() == Some(app.as_str());
|
||||
options = options.push(opt_row(
|
||||
selected,
|
||||
app.clone(),
|
||||
None,
|
||||
AppMessage::SelectShareAudioApp(Some(app.clone())),
|
||||
));
|
||||
}
|
||||
|
||||
let list: Element<'_, AppMessage> = if state.share_audio_apps.is_empty() {
|
||||
column![
|
||||
options,
|
||||
text("No other apps are playing audio right now.")
|
||||
.size(11)
|
||||
.color(subtext),
|
||||
]
|
||||
.spacing(8)
|
||||
.into()
|
||||
} else {
|
||||
scrollable(options).height(iced::Length::Shrink).into()
|
||||
};
|
||||
|
||||
let cancel_btn = button(text("Cancel").size(13).color(text_c))
|
||||
.on_press(AppMessage::CloseSharePicker)
|
||||
.style(move |_t: &Theme, status: button::Status| button::Style {
|
||||
background: Some(Background::Color(match status {
|
||||
button::Status::Hovered => surface,
|
||||
_ => mantle,
|
||||
})),
|
||||
text_color: text_c,
|
||||
border: Border { color: surface, width: 1.0, radius: 6.0.into() },
|
||||
..Default::default()
|
||||
})
|
||||
.padding(8);
|
||||
let share_btn = button(text("Share").size(13).color(crust))
|
||||
.on_press(AppMessage::ConfirmShareScreen)
|
||||
.style(move |_t: &Theme, status: button::Status| button::Style {
|
||||
background: Some(Background::Color(match status {
|
||||
button::Status::Hovered => pal.lavender,
|
||||
_ => blue,
|
||||
})),
|
||||
text_color: crust,
|
||||
border: Border { color: Color::TRANSPARENT, width: 0.0, radius: 6.0.into() },
|
||||
..Default::default()
|
||||
})
|
||||
.padding(8);
|
||||
|
||||
let dialog = container(
|
||||
column![
|
||||
row![
|
||||
text("Share screen audio").size(16).color(blue),
|
||||
horizontal_space(),
|
||||
button(text("✕").size(16).color(subtext))
|
||||
.on_press(AppMessage::CloseSharePicker)
|
||||
.style(|_t: &Theme, _s: button::Status| button::Style {
|
||||
background: None,
|
||||
..Default::default()
|
||||
})
|
||||
.padding(2),
|
||||
]
|
||||
.align_y(iced::alignment::Vertical::Center),
|
||||
text("Capture audio from:").size(13).color(text_c),
|
||||
list,
|
||||
row![
|
||||
horizontal_space(),
|
||||
cancel_btn,
|
||||
share_btn,
|
||||
]
|
||||
.spacing(8),
|
||||
]
|
||||
.spacing(14),
|
||||
)
|
||||
.style(move |_t: &Theme| container::Style {
|
||||
text_color: Some(text_c),
|
||||
background: Some(Background::Color(mantle)),
|
||||
border: Border { color: surface, width: 1.0, radius: 12.0.into() },
|
||||
..Default::default()
|
||||
})
|
||||
.padding(20)
|
||||
.width(iced::Length::Fixed(420.0))
|
||||
.max_height(460.0);
|
||||
|
||||
stack![
|
||||
base,
|
||||
backdrop,
|
||||
container(dialog)
|
||||
.center_x(iced::Length::Fill)
|
||||
.center_y(iced::Length::Fill),
|
||||
]
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Overlay the "Regenerate identity?" confirm dialog when open (W7). A
|
||||
/// destructive action — minting a new id discards the old one — so it's gated
|
||||
/// behind an explicit confirm with a clear warning.
|
||||
@@ -6059,6 +6289,9 @@ mod tests {
|
||||
state.call_started = Some(now);
|
||||
state.mic_level = 0.75;
|
||||
state.self_sharing = true;
|
||||
state.share_picker_open = true;
|
||||
state.share_audio_apps = vec!["Firefox".to_string()];
|
||||
state.share_audio_selection = Some("Firefox".to_string());
|
||||
state.clip_status.lock().unwrap().playing_id = Some(attachment_id);
|
||||
|
||||
state.reset_room_state();
|
||||
@@ -6079,6 +6312,9 @@ mod tests {
|
||||
assert!(state.call_started.is_none());
|
||||
assert_eq!(state.mic_level, 0.0);
|
||||
assert!(!state.self_sharing);
|
||||
assert!(!state.share_picker_open);
|
||||
assert!(state.share_audio_apps.is_empty());
|
||||
assert!(state.share_audio_selection.is_none());
|
||||
|
||||
for _ in 0..50 {
|
||||
if crate::audio::clip_player::status_snapshot(&state.clip_status).playing_id.is_none() {
|
||||
|
||||
Reference in New Issue
Block a user