feat: screen sharing via pixelpass (Discord-style, presence-borne ticket)

Surface pixelpass screen-sharing from inside a peerspeak room. peerspeak owns
voice, pixelpass owns pixels — they're never Cargo deps of each other; the
contract is pixelpass's CLI flags + its `--output json` stdout stream.

Modelled on Discord: multiple simultaneous sharers, a 🔴 Live badge + 👁 Watch
on each sharing peer's card, and in-progress shares visible to late joiners.

- New `src/screenshare` module: pure `parse_pixelpass_event` seam + `pixelpass_path`
  discovery (13 unit tests), async `spawn_host` (→ ticket) and `spawn_viewer`
  (→ parse connected{url} → open mpv, vlc fallback). No new deps.
- Sharing rides presence: `PeerState.sharing: Option<ticket>` (serde-defaulted),
  so the existing gossip re-announce delivers the offer to late joiners for free
  and a PeerUpdated fires on start/stop — no separate gossip message needed.
- core: Start/Stop/ViewShare commands; host + viewer children tracked in the
  session, killed on stop/leave (kill_on_drop backstop). Viewer limit left to
  pixelpass's bandwidth-measured cap.
- UI: Share/Stop button (graceful "needs pixelpass" disabled state), Live badge
  + Watch on peer cards, Sharing badge on the self card. Verified by screenshot.
- config: optional `pixelpass_path` override (hand-editable).

Tests-green; the 2-machine gossip/remote path is not yet field-verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 15:46:34 -04:00
co-authored by Claude Opus 4.8
parent 5dad86db57
commit d9e544607a
11 changed files with 703 additions and 4 deletions
+88
View File
@@ -152,6 +152,10 @@ pub enum AppMessage {
SelectRoomLayout(RoomLayout),
/// Toggle the Chat drawer open/closed (drawer layout).
ToggleDrawerChat,
/// Start/stop sharing our own screen (spawns/kills a pixelpass host).
ToggleScreenShare,
/// Watch a peer's screen share, identified by their pixelpass ticket.
WatchShare(String),
}
fn core_subscription() -> impl iced::futures::Stream<Item = UiEvent> {
@@ -217,6 +221,10 @@ pub struct AppState {
ever_connected: HashSet<EndpointId>,
controller: Arc<CoreController>,
current_screen: Screen,
/// Whether we're currently sharing our own screen (confirmed by the core).
self_sharing: bool,
/// Whether the `pixelpass` binary is available, gating the Share controls.
pixelpass_available: bool,
}
impl AppState {
@@ -254,6 +262,9 @@ impl Default for AppState {
let _ = controller.send(CoreCommand::SetInputVolume(config.input_volume));
let _ = controller.send(CoreCommand::SetOutputVolume(config.output_volume));
let _ = controller.send(CoreCommand::SetNetworkMode(config.network_mode));
let _ = controller.send(CoreCommand::SetPixelpassPath(config.pixelpass_path.clone()));
let pixelpass_available =
crate::screenshare::is_available(config.pixelpass_path.as_deref());
let all_devices = enumerate_audio_devices();
let input_devices: Vec<_> = all_devices.iter().filter(|d| d.is_input).cloned().collect();
let output_devices: Vec<_> = all_devices.iter().filter(|d| !d.is_input).cloned().collect();
@@ -298,6 +309,8 @@ impl Default for AppState {
ever_connected: HashSet::new(),
controller,
current_screen: Screen::Home,
self_sharing: false,
pixelpass_available,
}
}
}
@@ -407,6 +420,18 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
AppMessage::LeavePressed => {
let _ = state.controller.send(CoreCommand::Leave);
}
AppMessage::ToggleScreenShare => {
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();
}
}
AppMessage::WatchShare(ticket) => {
let _ = state.controller.send(CoreCommand::ViewShare(ticket));
state.status_message = "Opening screen share…".to_string();
}
AppMessage::ToggleMutePressed => {
let _ = state.controller.send(CoreCommand::ToggleMute);
state.is_muted = !state.is_muted;
@@ -445,6 +470,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.status_message = "Ready to connect".to_string();
state.current_screen = Screen::Home;
state.mic_level = 0.0;
state.self_sharing = false;
notify::play(Sound::SelfLeave, state.config.custom_sound_self_leave.as_deref());
}
UiEvent::PeerJoined { id, state: peer_state } => {
@@ -510,6 +536,14 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
push_chat(&mut state.chat_messages, ChatEntry { name, text, mine: false });
}
}
UiEvent::ScreenShareStarted => {
state.self_sharing = true;
state.status_message = "Sharing your screen".to_string();
}
UiEvent::ScreenShareStopped => {
state.self_sharing = false;
state.status_message = "Screen share stopped".to_string();
}
UiEvent::Error(err) => {
state.status_message = format!("Error: {}", err);
}
@@ -1270,6 +1304,15 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
}
]
.align_y(iced::alignment::Vertical::Center),
// Live "you're sharing" badge — only present while sharing.
{
let el: Element<'_, AppMessage> = if state.self_sharing {
text("🔴 Sharing your screen").size(13).color(color_red).into()
} else {
iced::widget::Space::new().width(0.0).height(0.0).into()
};
el
},
progress_bar(0.0..=0.3, state.mic_level)
.girth(8.0)
.style(move |_t: &Theme| iced::widget::progress_bar::Style {
@@ -1317,6 +1360,26 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.style(b_style(mute_bg, color_blue, mute_fg, 6.0))
.padding(6);
// Screen-share "Live" badge + Watch button when this peer is sharing.
// Watch is enabled only if pixelpass is installed locally.
let share_el: Element<'_, AppMessage> = if let Some(ticket) = peer.sharing.clone() {
let mut watch_btn = button(text("👁 Watch").size(13))
.style(b_style(color_blue, color_lavender, color_crust, 6.0))
.padding(6);
if state.pixelpass_available {
watch_btn = watch_btn.on_press(AppMessage::WatchShare(ticket));
}
row![
text("🔴 Live").size(13).color(color_red),
watch_btn,
]
.spacing(6)
.align_y(iced::alignment::Vertical::Center)
.into()
} else {
iced::widget::Space::new().width(0.0).height(0.0).into()
};
// VU meter colour: dim when locally muted (you don't hear them),
// green while speaking, faint otherwise.
let vu_color = if is_locally_muted {
@@ -1334,6 +1397,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
text(format!("ID: {}", &peer_id.to_string()[..8])).size(11).color(color_subtext)
],
horizontal_space(),
share_el,
mute_btn,
indicator
]
@@ -1444,6 +1508,30 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.padding(14)
.width(iced::Length::Fill)
},
vertical_space(20.0),
{
// Screen share. Disabled (no on_press) when pixelpass is absent,
// with the label saying so — a normal, handled state.
let (share_label, share_bg, share_hover, share_fg) = if !state.pixelpass_available {
("🖥 Needs pixelpass", color_surface, color_surface, color_subtext)
} else if state.self_sharing {
("🛑 Stop Sharing", color_red, color_maroon, color_crust)
} else {
("🖥 Share Screen", color_surface, color_blue, color_text)
};
let mut share_btn = button(
text(share_label)
.size(16)
.align_x(iced::alignment::Horizontal::Center),
)
.style(b_style(share_bg, share_hover, share_fg, 8.0))
.padding(14)
.width(iced::Length::Fill);
if state.pixelpass_available {
share_btn = share_btn.on_press(AppMessage::ToggleScreenShare);
}
share_btn
},
vertical_space(30.0),
button(
text("Leave Room")