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"""