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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user