WIP. New in-call controls

Conrad Irwin and Matt Miller created

Co-authored-by: Matt Miller <mattrx@gmail.com>

Change summary

assets/icons/audio.svg               |   8 
assets/themes/one/one.json           |   4 
crates/collab_ui/src/call_overlay.rs | 335 +++++++++++++++++++++++++++++
crates/icons/src/icons.rs            |   1 
4 files changed, 341 insertions(+), 7 deletions(-)

Detailed changes

assets/icons/audio.svg 🔗

@@ -0,0 +1,8 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M3.5 6.66666V8.66666" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5.5 5V11" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M7.5 3V13" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M9.5 5.33334V10" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M11.5 4V12" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M13.5 6.66666V8.66666" stroke="black" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

assets/themes/one/one.json 🔗

@@ -13,7 +13,7 @@
         "border.selected": "#293b5bff",
         "border.transparent": "#00000000",
         "border.disabled": "#414754ff",
-        "elevated_surface.background": "#2f343eff",
+        "elevated_surface.background": "#3F4550FF",
         "surface.background": "#2f343eff",
         "background": "#3b414dff",
         "element.background": "#2e343eff",
@@ -414,7 +414,7 @@
         "border.selected": "#cbcdf6ff",
         "border.transparent": "#00000000",
         "border.disabled": "#d3d3d4ff",
-        "elevated_surface.background": "#ebebecff",
+        "elevated_surface.background": "#ffffffff",
         "surface.background": "#ebebecff",
         "background": "#dcdcddff",
         "element.background": "#ebebecff",

crates/collab_ui/src/call_overlay.rs 🔗

@@ -1,20 +1,345 @@
-use gpui::AppContext;
-use ui::{App, Context, IntoElement, Render, Styled, Window, div, px};
+use std::rc::Rc;
+
+use call::{ActiveCall, Room};
+use channel::ChannelStore;
+use gpui::{AppContext, Entity, RenderOnce, WeakEntity};
+use project::Project;
+use ui::{
+    ActiveTheme, AnyElement, App, Avatar, Button, ButtonCommon, ButtonSize, ButtonStyle, Clickable,
+    Color, Context, ContextMenu, ContextMenuItem, Element, FluentBuilder, Icon, IconButton,
+    IconName, IconSize, IntoElement, Label, LabelCommon, LabelSize, ParentElement, PopoverMenu,
+    PopoverMenuHandle, Render, SelectableButton, SharedString, SplitButton, SplitButtonStyle,
+    Styled, StyledExt, TintColor, Toggleable, Tooltip, Window, div, h_flex, px, v_flex,
+};
 use workspace::Workspace;
 
-pub struct CallOverlay {}
+pub struct CallOverlay {
+    active_call: Entity<ActiveCall>,
+    channel_store: Entity<ChannelStore>,
+    project: Entity<Project>,
+    workspace: WeakEntity<Workspace>,
+    screen_share_popover_handle: PopoverMenuHandle<ContextMenu>,
+}
+
+impl CallOverlay {
+    pub(crate) fn render_call_controls(
+        &self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Vec<AnyElement> {
+        let Some(room) = self.active_call.read(cx).room() else {
+            return Vec::default();
+        };
+
+        let room = room.read(cx);
+        let project = self.project.read(cx);
+        let is_local = project.is_local() || project.is_via_remote_server();
+        let is_shared = is_local && project.is_shared();
+        let is_muted = room.is_muted();
+        let muted_by_user = room.muted_by_user();
+        let is_deafened = room.is_deafened().unwrap_or(false);
+        let is_screen_sharing = room.is_sharing_screen();
+        let can_use_microphone = room.can_use_microphone();
+        let can_share_projects = room.can_share_projects();
+        let screen_sharing_supported = cx.is_screen_capture_supported();
+        let is_connecting_to_project = self
+            .workspace
+            .update(cx, |workspace, cx| workspace.has_active_modal(window, cx))
+            .unwrap_or(false);
+
+        let mut children = Vec::new();
+
+        if can_use_microphone {
+            children.push(
+                IconButton::new(
+                    "mute-microphone",
+                    if is_muted {
+                        IconName::MicMute
+                    } else {
+                        IconName::Mic
+                    },
+                )
+                .tooltip(move |window, cx| {
+                    if is_muted {
+                        if is_deafened {
+                            Tooltip::with_meta(
+                                "Unmute Microphone",
+                                None,
+                                "Audio will be unmuted",
+                                window,
+                                cx,
+                            )
+                        } else {
+                            Tooltip::simple("Unmute Microphone", cx)
+                        }
+                    } else {
+                        Tooltip::simple("Mute Microphone", cx)
+                    }
+                })
+                .style(ButtonStyle::Subtle)
+                .icon_size(IconSize::Small)
+                .toggle_state(is_muted)
+                .selected_icon_color(Color::Error)
+                .on_click(move |_, _window, cx| {
+                    // toggle_mute(&Default::default(), cx);
+                    // todo!()
+                })
+                .into_any_element(),
+            );
+        }
+
+        children.push(
+            IconButton::new(
+                "mute-sound",
+                if is_deafened {
+                    IconName::AudioOff
+                } else {
+                    IconName::AudioOn
+                },
+            )
+            .style(ButtonStyle::Subtle)
+            .selected_icon_color(Color::Error)
+            .icon_size(IconSize::Small)
+            .toggle_state(is_deafened)
+            .tooltip(move |window, cx| {
+                if is_deafened {
+                    let label = "Unmute Audio";
+
+                    if !muted_by_user {
+                        Tooltip::with_meta(label, None, "Microphone will be unmuted", window, cx)
+                    } else {
+                        Tooltip::simple(label, cx)
+                    }
+                } else {
+                    let label = "Mute Audio";
+
+                    if !muted_by_user {
+                        Tooltip::with_meta(label, None, "Microphone will be muted", window, cx)
+                    } else {
+                        Tooltip::simple(label, cx)
+                    }
+                }
+            })
+            .on_click(move |_, _, cx| {
+                // toggle_deafen(&Default::default(), cx))
+                // todo!()
+            })
+            .into_any_element(),
+        );
+
+        if can_use_microphone && screen_sharing_supported {
+            children.push(
+                IconButton::new("screen-share", IconName::Screen)
+                    .style(ButtonStyle::Subtle)
+                    .icon_size(IconSize::Small)
+                    .toggle_state(is_screen_sharing)
+                    .selected_icon_color(Color::Error)
+                    .tooltip(Tooltip::text(if is_screen_sharing {
+                        "Stop Sharing Screen"
+                    } else {
+                        "Share Screen"
+                    }))
+                    .on_click(move |_, window, cx| {
+                        let should_share = ActiveCall::global(cx)
+                            .read(cx)
+                            .room()
+                            .is_some_and(|room| !room.read(cx).is_sharing_screen());
+
+                        // window
+                        //     .spawn(cx, async move |cx| {
+                        //         let screen = if should_share {
+                        //             // cx.update(|_, cx| {
+                        //             //     // pick_default_screen(cx)}
+                        //             //     // todo!()
+                        //             // })?
+                        //             // .await
+                        //         } else {
+                        //             Ok(None)
+                        //         };
+                        //         cx.update(|window, cx| {
+                        //             // toggle_screen_sharing(screen, window, cx)
+                        //             // todo!()
+                        //         })?;
+
+                        //         Result::<_, anyhow::Error>::Ok(())
+                        //     })
+                        //     .detach();
+                        // self.render_screen_list().into_any_element(),
+                    })
+                    .into_any_element(),
+            );
+
+            // children.push(
+            //     SplitButton::new(trigger.render(window, cx))
+            //         .style(SplitButtonStyle::Transparent)
+            //         .into_any_element(),
+            // );
+        }
+
+        children.push(div().pr_2().into_any_element());
+
+        children
+    }
+
+    fn render_screen_list(&self) -> impl IntoElement {
+        PopoverMenu::new("screen-share-screen-list")
+            .with_handle(self.screen_share_popover_handle.clone())
+            .trigger(
+                ui::ButtonLike::new_rounded_right("screen-share-screen-list-trigger")
+                    .child(
+                        h_flex()
+                            .mx_neg_0p5()
+                            .h_full()
+                            .justify_center()
+                            .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)),
+                    )
+                    .toggle_state(self.screen_share_popover_handle.is_deployed()),
+            )
+            .menu(|window, cx| {
+                let screens = cx.screen_capture_sources();
+                Some(ContextMenu::build(window, cx, |context_menu, _, cx| {
+                    cx.spawn(async move |this: WeakEntity<ContextMenu>, cx| {
+                        let screens = screens.await??;
+                        this.update(cx, |this, cx| {
+                            let active_screenshare_id = ActiveCall::global(cx)
+                                .read(cx)
+                                .room()
+                                .and_then(|room| room.read(cx).shared_screen_id());
+                            for screen in screens {
+                                let Ok(meta) = screen.metadata() else {
+                                    continue;
+                                };
+
+                                let label = meta
+                                    .label
+                                    .clone()
+                                    .unwrap_or_else(|| SharedString::from("Unknown screen"));
+                                let resolution = SharedString::from(format!(
+                                    "{} × {}",
+                                    meta.resolution.width.0, meta.resolution.height.0
+                                ));
+                                this.push_item(ContextMenuItem::CustomEntry {
+                                    entry_render: Box::new(move |_, _| {
+                                        h_flex()
+                                            .gap_2()
+                                            .child(
+                                                Icon::new(IconName::Screen)
+                                                    .size(IconSize::XSmall)
+                                                    .map(|this| {
+                                                        if active_screenshare_id == Some(meta.id) {
+                                                            this.color(Color::Accent)
+                                                        } else {
+                                                            this.color(Color::Muted)
+                                                        }
+                                                    }),
+                                            )
+                                            .child(Label::new(label.clone()))
+                                            .child(
+                                                Label::new(resolution.clone())
+                                                    .color(Color::Muted)
+                                                    .size(LabelSize::Small),
+                                            )
+                                            .into_any()
+                                    }),
+                                    selectable: true,
+                                    documentation_aside: None,
+                                    handler: Rc::new(move |_, window, cx| {
+                                        // toggle_screen_sharing(Ok(Some(screen.clone())), window, cx);
+                                    }),
+                                });
+                            }
+                        })
+                    })
+                    .detach_and_log_err(cx);
+                    context_menu
+                }))
+            })
+    }
+}
 
 impl Render for CallOverlay {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        div().w(px(100.)).h(px(100.)).bg(gpui::blue())
+        let Some(room) = self.active_call.read(cx).room() else {
+            return gpui::Empty.into_any_element();
+        };
+
+        let title = if let Some(channel_id) = room.read(cx).channel_id()
+            && let Some(channel) = self.channel_store.read(cx).channel_for_id(channel_id)
+        {
+            channel.name.clone()
+        } else {
+            "Unknown".into()
+        };
+
+        div()
+            .p_1()
+            .child(
+                v_flex()
+                    .elevation_3(cx)
+                    .bg(cx.theme().colors().editor_background)
+                    .p_2()
+                    .w_full()
+                    .gap_2()
+                    .child(
+                        h_flex()
+                            .justify_between()
+                            .child(
+                                h_flex()
+                                    .gap_1()
+                                    .child(
+                                        Icon::new(IconName::Audio)
+                                            .color(Color::VersionControlAdded),
+                                    )
+                                    .child(Label::new(title)),
+                            )
+                            .child(Icon::new(IconName::ChevronDown)),
+                    )
+                    .child(
+                        h_flex()
+                            .justify_between()
+                            .child(h_flex().children(self.render_call_controls(window, cx)))
+                            .child(
+                                h_flex()
+                                    .gap_1()
+                                    .child(
+                                        Button::new("leave-call", "Leave")
+                                            .icon(Some(IconName::Exit))
+                                            .label_size(LabelSize::Small)
+                                            .style(ButtonStyle::Tinted(TintColor::Error))
+                                            .tooltip(Tooltip::text("Leave Call"))
+                                            .icon_size(IconSize::Small)
+                                            .on_click(move |_, _window, cx| {
+                                                ActiveCall::global(cx)
+                                                    .update(cx, |call, cx| call.hang_up(cx))
+                                                    .detach_and_log_err(cx);
+                                            }),
+                                    )
+                                    .into_any_element(),
+                            ),
+                    ),
+            )
+            .into_any_element()
     }
 }
 
 pub fn init(cx: &App) {
     cx.observe_new(|workspace: &mut Workspace, _, cx| {
         let dock = workspace.dock_at_position(workspace::dock::DockPosition::Left);
+        let handle = cx.weak_entity();
+        let project = workspace.project().clone();
         dock.update(cx, |dock, cx| {
-            let overlay = cx.new(|_| CallOverlay {});
+            let overlay = cx.new(|cx| {
+                let active_call = ActiveCall::global(cx);
+                cx.observe(&active_call, |_, _, cx| cx.notify()).detach();
+                let channel_store = ChannelStore::global(cx);
+                CallOverlay {
+                    channel_store,
+                    active_call,
+                    workspace: handle,
+                    project,
+                    screen_share_popover_handle: PopoverMenuHandle::default(),
+                }
+            });
             dock.add_overlay(
                 cx,
                 Box::new(move |window, cx| {