diff --git a/yew-ui/src/components/attendants.rs b/yew-ui/src/components/attendants.rs index 89bb4f83..d4fb6b2e 100644 --- a/yew-ui/src/components/attendants.rs +++ b/yew-ui/src/components/attendants.rs @@ -1,15 +1,12 @@ -use super::icons::push_pin::PushPinIcon; -use crate::constants::{USERS_ALLOWED_TO_STREAM, WEBTRANSPORT_HOST}; +use crate::components::{canvas_generator, peer_list::PeerList}; +use crate::constants::{CANVAS_LIMIT, USERS_ALLOWED_TO_STREAM, WEBTRANSPORT_HOST}; use crate::{components::host::Host, constants::ACTIX_WEBSOCKET}; use log::{error, warn}; -use std::rc::Rc; use types::protos::media_packet::media_packet::MediaType; use videocall_client::{MediaDeviceAccess, VideoCallClient, VideoCallClientOptions}; -use wasm_bindgen::JsCast; use wasm_bindgen::JsValue; use web_sys::*; use yew::prelude::*; -use yew::virtual_dom::VNode; use yew::{html, Component, Context, Html}; #[derive(Debug)] @@ -31,11 +28,16 @@ pub enum MeetingAction { ToggleVideoOnOff, } +pub enum UserScreenAction { + TogglePeerList, +} + pub enum Msg { WsAction(WsAction), MeetingAction(MeetingAction), OnPeerAdded(String), OnFirstFrame((String, MediaType)), + UserScreenAction(UserScreenAction), } impl From for Msg { @@ -44,6 +46,12 @@ impl From for Msg { } } +impl From for Msg { + fn from(action: UserScreenAction) -> Self { + Msg::UserScreenAction(action) + } +} + impl From for Msg { fn from(action: MeetingAction) -> Self { Msg::MeetingAction(action) @@ -69,6 +77,7 @@ pub struct AttendantsComponent { pub share_screen: bool, pub mic_enabled: bool, pub video_enabled: bool, + pub peer_list_open: bool, pub error: Option, } @@ -133,6 +142,7 @@ impl Component for AttendantsComponent { share_screen: false, mic_enabled: false, video_enabled: false, + peer_list_open: false, error: None, } } @@ -197,162 +207,93 @@ impl Component for AttendantsComponent { } true } + Msg::UserScreenAction(action) => { + match action { + UserScreenAction::TogglePeerList => { + self.peer_list_open = !self.peer_list_open; + } + } + true + } } } fn view(&self, ctx: &Context) -> Html { let email = ctx.props().email.clone(); let media_access_granted = self.media_device_access.is_granted(); - let rows: Vec = self - .client - .sorted_peer_keys() - .iter() - .map(|key| { - if !USERS_ALLOWED_TO_STREAM.is_empty() - && !USERS_ALLOWED_TO_STREAM.iter().any(|host| host == key) - { - return html! {}; - } - let screen_share_css = if self.client.is_awaiting_peer_screen_frame(key) { - "grid-item hidden" - } else { - "grid-item" - }; - let screen_share_div_id = Rc::new(format!("screen-share-{}-div", &key)); - let peer_video_div_id = Rc::new(format!("peer-video-{}-div", &key)); - html! { - <> -
- // Canvas for Screen share. -
- -

{format!("{}-screen", &key)}

- -
-
-
- // One canvas for the User Video -
- -

{key.clone()}

- -
-
- - } - }) - .collect(); + + let toggle_peer_list = ctx.link().callback(|_| UserScreenAction::TogglePeerList); + + let peers = self.client.sorted_peer_keys(); + let rows = canvas_generator::generate( + &self.client, + peers.iter().take(CANVAS_LIMIT).cloned().collect(), + ); + html! { -
- { self.error.as_ref().map(|error| html! {

{ error }

}) } - { rows } - { - if USERS_ALLOWED_TO_STREAM.iter().any(|host| host == &email) || USERS_ALLOWED_TO_STREAM.is_empty() { - html! { -
+ { + if media_access_granted { + html! {} + } else { + html! {<>} + } } - } -

{email}

+

{email}

- {if !self.client.is_connected() { - html! {

{"Connecting"}

} - } else { - html! {

{"Connected"}

} - }} + {if !self.client.is_connected() { + html! {

{"Connecting"}

} + } else { + html! {

{"Connected"}

} + }} - {if ctx.props().e2ee_enabled { - html! {

{"End to End Encryption Enabled"}

} - } else { - html! {

{"End to End Encryption Disabled"}

} - }} - + {if ctx.props().e2ee_enabled { + html! {

{"End to End Encryption Enabled"}

} + } else { + html! {

{"End to End Encryption Disabled"}

} + }} + + } + } else { + error!("User not allowed to stream"); + error!("allowed users {}", USERS_ALLOWED_TO_STREAM.join(", ")); + html! {} } - } else { - error!("User not allowed to stream"); - error!("allowed users {}", USERS_ALLOWED_TO_STREAM.join(", ")); - html! {} } - } + +
+ +
} } } - -// props for the video component -#[derive(Properties, Debug, PartialEq)] -pub struct UserVideoProps { - pub id: String, -} - -// user video functional component -#[function_component(UserVideo)] -fn user_video(props: &UserVideoProps) -> Html { - // create use_effect hook that gets called only once and sets a thumbnail - // for the user video - let video_ref = use_state(NodeRef::default); - let video_ref_clone = video_ref.clone(); - use_effect_with_deps( - move |_| { - // Set thumbnail for the video - let video = (*video_ref_clone).cast::().unwrap(); - let ctx = video - .get_context("2d") - .unwrap() - .unwrap() - .unchecked_into::(); - ctx.clear_rect(0.0, 0.0, video.width() as f64, video.height() as f64); - || () - }, - vec![props.id.clone()], - ); - - html! { - - } -} - -fn toggle_pinned_div(div_id: &str) { - if let Some(div) = window() - .and_then(|w| w.document()) - .and_then(|doc| doc.get_element_by_id(div_id)) - { - // if the div does not have the grid-item-pinned css class, add it to it - if !div.class_list().contains("grid-item-pinned") { - div.class_list().add_1("grid-item-pinned").unwrap(); - } else { - // else remove it - div.class_list().remove_1("grid-item-pinned").unwrap(); - } - } -} diff --git a/yew-ui/src/components/canvas_generator.rs b/yew-ui/src/components/canvas_generator.rs new file mode 100644 index 00000000..d8fef729 --- /dev/null +++ b/yew-ui/src/components/canvas_generator.rs @@ -0,0 +1,106 @@ +use crate::components::icons::push_pin::PushPinIcon; +use crate::constants::USERS_ALLOWED_TO_STREAM; +use std::rc::Rc; +use videocall_client::VideoCallClient; +use wasm_bindgen::JsCast; +use web_sys::{window, CanvasRenderingContext2d, HtmlCanvasElement}; +use yew::prelude::*; +use yew::virtual_dom::VNode; +use yew::{html, Html}; + +pub fn generate(client: &VideoCallClient, peers: Vec) -> Vec { + peers + .iter() + .map(|key| { + if !USERS_ALLOWED_TO_STREAM.is_empty() + && !USERS_ALLOWED_TO_STREAM.iter().any(|host| host == key) + { + return html! {}; + } + let screen_share_css = if client.is_awaiting_peer_screen_frame(key) { + "grid-item hidden" + } else { + "grid-item" + }; + let screen_share_div_id = Rc::new(format!("screen-share-{}-div", &key)); + let peer_video_div_id = Rc::new(format!("peer-video-{}-div", &key)); + html! { + <> +
+ // Canvas for Screen share. +
+ +

{format!("{}-screen", &key)}

+ +
+
+
+ // One canvas for the User Video +
+ +

{key.clone()}

+ +
+
+ + } + }) + .collect() +} + +// props for the video component +#[derive(Properties, Debug, PartialEq)] +struct UserVideoProps { + pub id: String, +} + +// user video functional component +#[function_component(UserVideo)] +fn user_video(props: &UserVideoProps) -> Html { + // create use_effect hook that gets called only once and sets a thumbnail + // for the user video + let video_ref = use_state(NodeRef::default); + let video_ref_clone = video_ref.clone(); + use_effect_with_deps( + move |_| { + // Set thumbnail for the video + let video = (*video_ref_clone).cast::().unwrap(); + let ctx = video + .get_context("2d") + .unwrap() + .unwrap() + .unchecked_into::(); + ctx.clear_rect(0.0, 0.0, video.width() as f64, video.height() as f64); + || () + }, + vec![props.id.clone()], + ); + + html! { + + } +} + +fn toggle_pinned_div(div_id: &str) { + if let Some(div) = window() + .and_then(|w| w.document()) + .and_then(|doc| doc.get_element_by_id(div_id)) + { + // if the div does not have the grid-item-pinned css class, add it to it + if !div.class_list().contains("grid-item-pinned") { + div.class_list().add_1("grid-item-pinned").unwrap(); + } else { + // else remove it + div.class_list().remove_1("grid-item-pinned").unwrap(); + } + } +} diff --git a/yew-ui/src/components/icons/mod.rs b/yew-ui/src/components/icons/mod.rs index adcf254c..98f2b58e 100644 --- a/yew-ui/src/components/icons/mod.rs +++ b/yew-ui/src/components/icons/mod.rs @@ -1,3 +1,4 @@ pub mod discord; +pub mod peer; pub mod push_pin; pub mod youtube; diff --git a/yew-ui/src/components/icons/peer.rs b/yew-ui/src/components/icons/peer.rs new file mode 100644 index 00000000..75a3c704 --- /dev/null +++ b/yew-ui/src/components/icons/peer.rs @@ -0,0 +1,16 @@ +use yew::prelude::*; + +#[function_component(PeerIcon)] +pub fn peer_icon() -> Html { + html! { + + + + + + + } +} diff --git a/yew-ui/src/components/mod.rs b/yew-ui/src/components/mod.rs index 5bbde77e..2d551638 100644 --- a/yew-ui/src/components/mod.rs +++ b/yew-ui/src/components/mod.rs @@ -3,3 +3,7 @@ pub mod device_selector; pub mod host; pub mod icons; pub mod top_bar; + +mod canvas_generator; +mod peer_list; +mod peer_list_item; diff --git a/yew-ui/src/components/peer_list.rs b/yew-ui/src/components/peer_list.rs new file mode 100644 index 00000000..6e6d1b76 --- /dev/null +++ b/yew-ui/src/components/peer_list.rs @@ -0,0 +1,86 @@ +use crate::components::peer_list_item::PeerListItem; +use web_sys::HtmlInputElement; +use yew::prelude::*; +use yew::{html, Component, Context}; + +pub struct PeerList { + search_query: String, +} + +#[derive(Properties, Clone, PartialEq)] +pub struct PeerListProperties { + pub peers: Vec, + pub onclose: yew::Callback, +} + +pub enum PeerListMsg { + UpdateSearchQuery(String), +} + +impl Component for PeerList { + type Message = PeerListMsg; + + type Properties = PeerListProperties; + + fn create(_ctx: &Context) -> Self { + PeerList { + search_query: String::new(), + } + } + + fn update(&mut self, _ctx: &Context, msg: Self::Message) -> bool { + match msg { + PeerListMsg::UpdateSearchQuery(query) => { + self.search_query = query; + true + } + } + } + + fn view(&self, ctx: &Context) -> Html { + let filtered_peers: Vec<_> = ctx + .props() + .peers + .iter() + .filter(|peer| { + peer.to_lowercase() + .contains(&self.search_query.to_lowercase()) + }) + .cloned() + .collect(); + + let search_peers = ctx.link().callback(|e: InputEvent| { + let input: HtmlInputElement = e.target_unchecked_into(); + PeerListMsg::UpdateSearchQuery(input.value()) + }); + + html! { + <> +
+

{ "Attendants" }

+ +
+ +
+

{ "In call" }

+
+
+
    + { for filtered_peers.iter().map(|peer| + html!{ +
  • + }) + } +
+
+ + } + } +} diff --git a/yew-ui/src/components/peer_list_item.rs b/yew-ui/src/components/peer_list_item.rs new file mode 100644 index 00000000..a9c23a82 --- /dev/null +++ b/yew-ui/src/components/peer_list_item.rs @@ -0,0 +1,33 @@ +use crate::components::icons::peer::PeerIcon; +use yew::prelude::*; +use yew::{html, Component, Html}; + +pub struct PeerListItem {} + +#[derive(Properties, Clone, PartialEq)] +pub struct PeerListItemProps { + pub name: String, +} + +impl Component for PeerListItem { + type Message = (); + + type Properties = PeerListItemProps; + + fn create(_ctx: &Context) -> Self { + Self {} + } + + fn view(&self, ctx: &Context) -> Html { + html! { +
+
+ +
+
+ {ctx.props().name.clone()} +
+
+ } + } +} diff --git a/yew-ui/src/constants.rs b/yew-ui/src/constants.rs index d6d55959..2b9a6aa4 100644 --- a/yew-ui/src/constants.rs +++ b/yew-ui/src/constants.rs @@ -2,6 +2,7 @@ pub const LOGIN_URL: &str = std::env!("LOGIN_URL"); pub const ACTIX_WEBSOCKET: &str = concat!(std::env!("ACTIX_UI_BACKEND_URL"), "/lobby"); pub const WEBTRANSPORT_HOST: &str = concat!(std::env!("WEBTRANSPORT_HOST"), "/lobby"); +pub const CANVAS_LIMIT: usize = 20; pub fn truthy(s: Option<&str>) -> bool { if let Some(s) = s { diff --git a/yew-ui/static/style.css b/yew-ui/static/style.css index 434aea50..6d4a9228 100644 --- a/yew-ui/static/style.css +++ b/yew-ui/static/style.css @@ -28,9 +28,92 @@ canvas { margin: auto; } -.grid-container { - width: 100%; +#main-container { + display: flex; + height: 100vh; +} + +#peer-list-container { + width: 20%; height: 100%; + transition: width 0.3s; + flex-direction: column; + background-color: #f0f0f0; + display: none; + padding: 10px; + color: #333333; +} + +#peer-list-container.visible { + display: flex; +} + +#peer-list-container [type="text"] { + width: calc(100% - 20px); + box-sizing: border-box; + padding: 10px; + margin: 0px; + +} + +.peer-list-container * { + color: inherit; /* Ensure all children inherit the color */ +} + +#peer-list-container-header { + flex-shrink: 0; + display: flex; + justify-content: space-between; + align-items: center; + margin-top:10px; + margin-bottom: 10px; + margin-right: 20px; +} + +.search-box { + flex-shrink: 0; + padding: 10px; + background-color: #d0d0d0; +} + +.peer-list { + flex-grow: 1; + overflow-y: auto; + padding: 10; +} + +.peer-list ul{ + list-style-type: none; + margin: 0; + padding: 0; + border-right: 1px solid #ccc; +} + +.peer-list li { + margin-top: 20px; + height: 40px; + font-size: 20px; +} + +.peer_item{ + display: flex; + align-items: center; +} + +.peer_item_icon { + flex-shrink: 0; +} + +.peer_item_text { + flex-grow: 1; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + padding: 0 20px; +} + +#grid-container { + position: relative; margin: 16px 16px; display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); @@ -39,6 +122,9 @@ canvas { /* Center items vertically */ justify-content: center; /* Center items horizontally */ + transition: width 0.3s; + flex-grow: 1; + overflow: auto; } .grid-item { @@ -143,3 +229,4 @@ select { .grid-item:hover .pin-icon { visibility: visible; } +