Mercurial > libervia-web
diff libervia/web/pages/calls/_browser/__init__.py @ 1604:4a9679369856
browser (call): implements group calls:
rel 430
author | Goffi <goffi@goffi.org> |
---|---|
date | Wed, 15 May 2024 17:40:33 +0200 |
parents | 6feac4a25e60 |
children |
line wrap: on
line diff
--- a/libervia/web/pages/calls/_browser/__init__.py Sat May 11 14:02:54 2024 +0200 +++ b/libervia/web/pages/calls/_browser/__init__.py Wed May 15 17:40:33 2024 +0200 @@ -32,6 +32,7 @@ INACTIVE_CLASS = "inactive" MUTED_CLASS = "muted" SCREEN_OFF_CLASS = "screen-off" +MUJI_PREFIX = "_muji_" class CallUI: @@ -43,25 +44,33 @@ on_connection_lost_cb=self.on_connection_lost, on_video_devices=self.on_video_devices, on_reset_cb=self.on_reset_cb, + local_video_elt=document["local_video"], + remote_video_elt=document["remote_video"] ) # mapping of file sending self.files_webrtc: list[dict] = [] + # WebRTC instances used for group calls. self.mode = "search" self._status = None self._callee: JID|None = None + self._group_call_room: JID|None = None + self._group_call_peers: dict = {} self.contacts_elt = document["contacts"] self.search_container_elt = document["search_container"] self.call_container_elt = document["call_container"] + self.group_call_container_elt = document["group_call_container"] self.call_box_elt = document["call_box"] self.call_avatar_wrapper_elt = document["call_avatar_wrapper"] self.call_status_wrapper_elt = document["call_status_wrapper"] self.call_avatar_tpl = Template("call/call_avatar.html") self.call_status_tpl = Template("call/call_status.html") + self.group_peer_tpl = Template("call/group_peer.html") self.audio_player_elt = document["audio_player"] bridge.register_signal("action_new", self._on_action_new) bridge.register_signal("call_info", self._on_call_info) bridge.register_signal("call_setup", self._on_call_setup) bridge.register_signal("call_ended", self._on_call_ended) + bridge.register_signal("call_group_setup", self._on_call_group_setup) # call/hang up buttons self._call_mode = VIDEO @@ -69,7 +78,12 @@ document["audio_call_btn"].bind( "click", lambda __: aio.run(self.make_call(video=False)) ) + document["group_call_btn"].bind( + "click", + lambda __: aio.run(self.make_group_call()) + ) document["hangup_btn"].bind("click", lambda __: aio.run(self.hang_up())) + document["group_hangup_btn"].bind("click", lambda __: aio.run(self.group_hang_up())) # other buttons document["full_screen_btn"].bind("click", lambda __: self.toggle_fullscreen()) @@ -95,7 +109,9 @@ self.search_elt, document["contacts"], click_cb=self._on_entity_click, + allow_multiple_selection=True, template="call/search_item.html", + selection_state_callback=self._on_search_selection, options={ "no_group": True, "extra_cb": { @@ -209,15 +225,23 @@ @param profile: Profile associated with the action """ action_data = json.loads(action_data_s) - if ( - action_data.get("type") in ("confirm", "not_in_roster_leak") - and action_data.get("subtype") == "file" - ): + type_ = action_data.get("type") + subtype = action_data.get("subtype") + if type_ in ("confirm", "not_in_roster_leak") and subtype == "file": aio.run(self.on_file_preflight(action_data, action_id)) - elif action_data.get("type") == "file": + elif type_ == "file": aio.run(self.on_file_proposal(action_data, action_id)) - elif action_data.get("type") != "call": + elif ( + type_ == "confirm" + and subtype == "muc-invitation" + # FIXME: Q&D hack until there is a proper group call invitation solution. + and MUJI_PREFIX in action_data.get("room_jid", "") + ): + aio.run(self.on_group_call_proposal(action_data, action_id)) + elif type_ != "call": return + elif MUJI_PREFIX in action_data.get("from_jid", ""): + aio.run(self.on_group_call_join(action_data, action_id)) else: aio.run(self.on_action_new(action_data, action_id)) @@ -234,8 +258,12 @@ """Request permission to download a file.""" peer_jid = JID(action_data["from_jid"]).bare await cache.fill_identities([peer_jid]) - identity = cache.identities[peer_jid] - peer_name = identity["nicknames"][0] + try: + identity = cache.identities[peer_jid] + except KeyError: + peer_name = peer_jid.local + else: + peer_name = identity["nicknames"][0] file_data = action_data.get("file_data", {}) @@ -329,6 +357,62 @@ action_id, json.dumps({"answer": str(accepted).lower()}) ) + async def on_group_call_proposal(self, action_data: dict, action_id: str) -> None: + """Handle a group call proposal.""" + peer_jid = JID(action_data["from_jid"]).bare + await cache.fill_identities([peer_jid]) + identity = cache.identities[peer_jid] + peer_name = identity["nicknames"][0] + + group_call_accept_dlg = dialog.Confirm( + "{peer_name} ({peer_jid}) proposes a group call to you. Do you accept?" + .format( + peer_name=peer_name, + peer_jid=peer_jid, + ), + ok_label="Accept Group Call", + cancel_label="Reject" + ) + accepted = await group_call_accept_dlg.ashow() + if accepted: + self.switch_mode("group_call") + await bridge.action_launch( + action_id, json.dumps({"answer": str(accepted).lower()}) + ) + + async def on_group_call_join(self, action_data: dict, action_id: str) -> None: + peer_jid = JID(action_data["from_jid"]) + if peer_jid.bare != self._group_call_room: + log.warning( + f"Refusing group call join as were are not expecting any from this room.\n" + f"{peer_jid.bare=} {self._group_call_room=}" + ) + log.info(f"{peer_jid} joined the group call.") + + group_video_grid_elt = document["group_video_grid"] + await cache.fill_identities([peer_jid]) + + group_peer_elt = self.group_peer_tpl.get_elt({ + "entity": str(peer_jid), + # "identities": cache.identities, + }) + group_video_grid_elt <= group_peer_elt + peer_video_stream_elt = group_peer_elt.select_one(".peer_video_stream") + assert peer_video_stream_elt is not None + webrtc = WebRTC( + remote_video_elt=peer_video_stream_elt + ) + sid = webrtc.sid = action_data["session_id"] + self._group_call_peers[peer_jid] = { + "webrtc": webrtc, + "element": group_peer_elt, + "sid": sid + } + + log.debug(f"Call SID: {sid}") + + await bridge.action_launch(action_id, json.dumps({"cancelled": False})) + async def on_action_new(self, action_data: dict, action_id: str) -> None: peer_jid = JID(action_data["from_jid"]).bare call_type = action_data["sub_type"] @@ -342,9 +426,13 @@ return sid = self.sid = action_data["session_id"] await cache.fill_identities([peer_jid]) - identity = cache.identities[peer_jid] + try: + identity = cache.identities[peer_jid] + except KeyError: + peer_name = peer_jid.local + else: + peer_name = identity["nicknames"][0] self._callee = peer_jid - peer_name = identity["nicknames"][0] # we start the ring self.audio_player_elt.play() @@ -448,10 +536,16 @@ if webrtc.sid == session_id: break else: - log.debug( - f"Call ignored due to different session ID ({self.sid=} {session_id=})" - ) - return + for peer_data in self._group_call_peers.values(): + webrtc = peer_data["webrtc"] + if webrtc.sid == session_id: + break + else: + log.debug( + f"Call ignored due to different session ID ({self.sid=} " + f"{session_id=})" + ) + return try: role = setup_data["role"] sdp = setup_data["sdp"] @@ -470,6 +564,91 @@ ) return + def _on_call_group_setup( + self, + room_jid_s: str, + setup_data_s: str, + profile: str + ) -> None: + """Called when we are finishing preparation of a group call. + + @param room_jid_s: JID of the room used for group call coordination. + @param setup_data_s: serialised data of group call options, such as codec + restrictions. + @param profile: Profile associated with the action + """ + if setup_data_s: + setup_data = json.loads(setup_data_s) + else: + setup_data = {} + aio.run( + self.on_call_group_setup( + JID(room_jid_s), + setup_data, + profile + ) + ) + + async def on_call_group_setup( + self, room_jid: JID, setup_data: dict, profile: str + ) -> None: + """Call has been accepted, connection can be established + + @param session_id: Session identifier + @param setup_data: Data with following keys: + role: initiator or responser + sdp: Session Description Protocol data + @param profile: Profile associated + """ + log.info(f"Setting up group call at {room_jid}.") + try: + to_call = setup_data["to_call"] + except KeyError: + dialog.notification.show( + 'Internal error: missing "to_call" data.', level="error" + ) + return + + # we need a remote_video_elt to instantiate, but it won't be used. + webrtc = WebRTC(remote_video_elt=document["remote_video"]) + call_data = await webrtc.prepare_call() + + # we have just used this WebRTC instance to get calling data. + await webrtc.end_call() + del webrtc + await bridge.call_group_data_set( + str(room_jid), + json.dumps(call_data), + ) + # At this point, we can initiate the call. + # As per specification, we call each entity which was preparing when we started + # our own preparation. + group_video_grid_elt = document["group_video_grid"] + local_stream = None + for entity_jid_s in to_call: + entity_jid = JID(entity_jid_s) + log.info(f"Calling {entity_jid_s}.") + await cache.fill_identities([entity_jid]) + group_peer_elt = self.group_peer_tpl.get_elt({ + "entity": str(entity_jid), + # "identities": cache.identities, + }) + group_video_grid_elt <= group_peer_elt + peer_video_stream_elt = group_peer_elt.select_one(".peer_video_stream") + assert peer_video_stream_elt is not None + webrtc = WebRTC( + remote_video_elt=peer_video_stream_elt, + local_stream=local_stream + ) + + self._group_call_peers[JID(entity_jid)] = { + "webrtc": webrtc + } + await webrtc.make_call(entity_jid) + # we save the local stream to re-use it with next WebRTC instance. + if local_stream is None: + local_stream = webrtc.local_stream + def on_connection_established(self): self.status = "in-call" @@ -535,6 +714,26 @@ else: await self.webrtc.make_call(callee_jid, audio, video) + async def make_group_call( + self, + ) -> None: + """Start a group call. + + This will run a call for small group, using MUJI (XEP-0272). + """ + group_video_grid_elt = document["group_video_grid"] + group_video_grid_elt.clear() + self._group_call_peers.clear() + selected_jids = self.jid_search.selected_jids + + await cache.fill_identities(selected_jids) + + self.switch_mode("group_call") + group_call_data = json.loads( + await bridge.call_group_start(self.jid_search.selected_jids, "") + ) + self._group_call_room = JID(group_call_data["room_jid"]) + async def end_call(self, data: dict) -> None: """Stop streaming and clean instance""" # if there is any ringing, we stop it @@ -549,18 +748,28 @@ self.switch_mode("search") - await self.webrtc.end_call() async def hang_up(self) -> None: """Terminate the call""" session_id = self.sid if not session_id: - log.warning("Can't hand_up, not call in progress") + log.warning("Can't hand_up, no call in progress") return await self.end_call({"reason": "terminated"}) await bridge.call_end(session_id, "") + async def group_hang_up(self) -> None: + """Terminate the group_call""" + self.switch_mode("search") + group_video_grid_elt = document["group_video_grid"] + group_video_grid_elt.clear() + for peer_data in self._group_call_peers.values(): + webrtc = peer_data["webrtc"] + await webrtc.end_call() + self._group_call_peers.clear() + self._group_call_room = None + def _handle_animation_end( self, element, @@ -592,26 +801,40 @@ """Handles the user interface changes""" if mode == self.mode: return + + # Exiting from any other modes + exit_animate_list = [ + (self.search_container_elt, "fade-out-y", "is-hidden"), + (self.call_container_elt, "slide-in", "is-hidden"), + (self.group_call_container_elt, "slide-in", "is-hidden"), + ] + + for elt, anim, hide in exit_animate_list: + if not elt.classList.contains(hide): # Only animate if visible + elt.classList.add(anim) + elt.bind("animationend", self._handle_animation_end(elt, remove=anim, add=hide)) + + # Entering into the new mode if mode == "call": - # Hide contacts with fade-out animation and bring up the call box - self.search_container_elt.classList.add("fade-out-y") - self.search_container_elt.bind( - "animationend", - self._handle_animation_end( - self.search_container_elt, remove="fade-out-y", add="is-hidden" - ), - ) self.call_container_elt.classList.remove("is-hidden") self.call_container_elt.classList.add("slide-in") self.call_container_elt.bind( "animationend", self._handle_animation_end(self.call_container_elt, remove="slide-in"), ) - self.mode = mode + + elif mode == "group_call": + self.group_call_container_elt.classList.remove("is-hidden") + self.group_call_container_elt.classList.add("slide-in") + self.group_call_container_elt.bind( + "animationend", + self._handle_animation_end(self.group_call_container_elt, remove="slide-in"), + ) + elif mode == "search": self.toggle_fullscreen(False) + self.search_container_elt.classList.remove("is-hidden") self.search_container_elt.classList.add("fade-out-y", "animation-reverse") - self.search_container_elt.classList.remove("is-hidden") self.search_container_elt.bind( "animationend", self._handle_animation_end( @@ -619,18 +842,12 @@ remove=["fade-out-y", "animation-reverse"], ), ) - self.call_container_elt.classList.add("slide-in", "animation-reverse") - self.call_container_elt.bind( - "animationend", - self._handle_animation_end( - self.call_container_elt, - remove=["slide-in", "animation-reverse"], - add="is-hidden", - ), - ) - self.mode = mode + else: - log.error(f"Internal Error: Unknown call mode: {mode}") + log.error(f"Internal Error: Unknown mode: {mode}") + return + + self.mode = mode def on_clear_search(self, ev) -> None: """Clear the search input and trigger its 'input' event. @@ -644,6 +861,23 @@ # and dispatch the input event so items are updated self.search_elt.dispatchEvent(window.Event.new("input")) + def _on_search_selection(self, has_selection: bool) -> None: + """Hide show buttons from search bar according to selection. + + If at least one search item is selected, the "group call" button will be shown, + otherwise the "video call" and "audio call" button will be shown + """ + if has_selection: + to_hide = ["video_call_btn", "audio_call_btn"] + to_show = ["group_call_btn"] + else: + to_hide = ["group_call_btn"] + to_show = ["video_call_btn", "audio_call_btn"] + for elt_id in to_hide: + document[elt_id].parent.classList.add("is-hidden") + for elt_id in to_show: + document[elt_id].parent.classList.remove("is-hidden") + def toggle_fullscreen(self, fullscreen: bool | None = None): """Toggle fullscreen mode for video elements. @@ -733,15 +967,21 @@ }) await webrtc.send_file(self._callee, file) - - def _on_entity_click(self, item: dict) -> None: - aio.run(self.on_entity_click(item)) + def _on_entity_click(self, evt: JSObject, item: dict) -> None: + aio.run(self.on_entity_click(evt, item)) - async def on_entity_click(self, item: dict) -> None: + async def on_entity_click(self, evt: JSObject, item: dict) -> None: """Set entity JID to search bar, and start the call""" - self.search_elt.value = item["entity"] - - await self.make_call() + # we don't want to start a call when there is a selection, has a group call is + # expected, and a click may just be accidental. + if self.jid_search.has_selection: + checkbox_elt = evt.currentTarget.select_one("input[type='checkbox']") + if checkbox_elt is not None: + checkbox_elt.checked = not checkbox_elt.checked + checkbox_elt.dispatchEvent(window.Event.new("change")) + else: + self.search_elt.value = item["entity"] + await self.make_call() async def on_entity_action(self, evt, action: str, item: dict) -> None: """Handle extra actions on search items"""