changeset 1604:4a9679369856

browser (call): implements group calls: rel 430
author Goffi <goffi@goffi.org>
date Wed, 15 May 2024 17:40:33 +0200
parents e105d7719479
children 9cde31a21a9b
files libervia/web/pages/_browser/jid_search.py libervia/web/pages/calls/_browser/__init__.py libervia/web/pages/calls/_browser/webrtc.py libervia/web/server/restricted_bridge.py libervia/web/server/server.py
diffstat 5 files changed, 451 insertions(+), 75 deletions(-) [+]
line wrap: on
line diff
--- a/libervia/web/pages/_browser/jid_search.py	Sat May 11 14:02:54 2024 +0200
+++ b/libervia/web/pages/_browser/jid_search.py	Wed May 15 17:40:33 2024 +0200
@@ -23,7 +23,10 @@
         click_cb=None,
         options: dict|None = None,
         submit_filter: bool = True,
-        template: str = "components/search_item.html"
+        template: str = "components/search_item.html",
+        allow_multiple_selection: bool = False,
+        selected_item_class: str = 'is-selected-search_item',
+        selection_state_callback=None
     ) -> None:
         """Initialize the JidSearch instance
 
@@ -42,6 +45,12 @@
                 "click" event, and will be called with the ``item`` as argument
         @param submit_filter: if True, only submit when a seemingly valid JID is entered
         @param template: template to use
+        @param allow_multiple_selection: If True, allows multiple search entities to be
+            selected with checkboxes.
+        @param selected_item_class: The CSS class to apply when an item is selected. This
+            class should define distinctive styles to highlight selected items.
+        @param selection_state_callback: A callback function to execute when selection
+            state changes. Takes one boolean parameter indicating if items are selected.
         """
         self.search_item_tpl = Template(template)
         self.search_elt = search_elt
@@ -74,12 +83,25 @@
 
         self.empty_cb = empty_cb or self.on_empty_search
 
+        self.allow_multiple_selection = allow_multiple_selection
+        self.selected_item_class = selected_item_class
+        if selection_state_callback is None:
+            selection_state_callback = self.default_selection_state_callback
+        self.selection_state_callback = selection_state_callback
+        self._selected_jids = set()
+        self.has_selection = False
+        self.update_selection_state()
+
         current_search = search_elt.value.strip() or None
         if not current_search:
             aio.run(self.empty_cb(self))
         else:
             aio.run(self.perform_search(current_search))
 
+    @property
+    def selected_jids(self) -> list[str]:
+        return list(self._selected_jids)
+
     def default_get_url(self, item):
         """Default method to get the URL for a given entity
 
@@ -87,6 +109,25 @@
         """
         return urljoin(f"{window.location.href}/", quote(item["entity"]))
 
+    def default_selection_state_callback(self, has_selection: bool) -> None:
+        """Default callback to handle selection state changes.
+
+        @param has_selection: Boolean indicating if any items are currently selected.
+        """
+        if has_selection:
+            self.container_elt.classList.add('has-selected-items')
+        else:
+            self.container_elt.classList.remove('has-selected-items')
+
+    def update_selection_state(self):
+        """
+        Checks the selection state and triggers the callback accordingly.
+        """
+        current_has_selection = bool(self._selected_jids)
+        if current_has_selection != self.has_selection:
+            self.has_selection = current_has_selection
+            self.selection_state_callback(self.has_selection)
+
     def show_items(self, items):
         """Display the search items in the specified container
 
@@ -98,21 +139,36 @@
             search_item_elt = self.search_item_tpl.get_elt({
                 "url": self.get_url(item),
                 "item": item,
+                "multiple_selection": self.allow_multiple_selection,
                 "identities": cache.identities,
                 "options": self.options,
             })
+
+            if self.allow_multiple_selection:
+                # Include a checkbox and manage its state
+                checkbox = search_item_elt.select('.search-item__checkbox')[0]
+                checkbox.checked = item['entity'] in self._selected_jids
+                checkbox.bind('change', lambda evt, item=item: self.on_checkbox_change(evt, item))
+                checkbox.bind('click', lambda evt: evt.stopPropagation())
+
+                # Highlight item if selected
+                if item['entity'] in self._selected_jids:
+                    card = search_item_elt.select('.card')[0]
+                    card.classList.add(self.selected_item_class)
+
             if self.click_cb is not None:
-                search_item_elt.bind('click', lambda evt, item=item: self.click_cb(item))
+                # Make the whole item clickable and perform the click callback function
+                search_item_elt.bind('click', lambda evt, item=item: self.click_cb(evt, item))
+
+            # Now append the whole element to the container
+            self.container_elt <= search_item_elt
+
+            # Apply extra callbacks if defined in options
             extra_cb = self.options.get("extra_cb")
             if extra_cb:
                 for selector, cb in extra_cb.items():
                     for elt in search_item_elt.select(selector):
-                        log.debug(f"binding {selector=} {elt=} {cb=} {id(cb)=}")
-                        elt.bind(
-                            "click",
-                            lambda evt, item=item, cb=cb: cb(evt, item)
-                        )
-            self.container_elt <= search_item_elt
+                        elt.bind("click", lambda evt, item=item, cb=cb: cb(evt, item))
 
     def on_search_input(self, evt):
         """Handle the 'input' event for the search element
@@ -135,10 +191,6 @@
             evt.preventDefault()
 
     async def perform_search(self, query):
-        """Perform the search operation for a given query
-
-        @param query: The search query
-        """
         if self.current_query is None:
             log.debug(f"performing search: {query=}")
             self.current_query = query
@@ -148,10 +200,23 @@
             self.last_query = query
             self.current_query = None
             current_query = self.search_elt.value.strip()
+
             if current_query != query:
                 await self.perform_search(current_query)
                 return
-            self.filter_cb(jid_items)
+
+            # Include selected items in the search result regardless of the filter
+            jid_items_set = {item['entity']: item for item in jid_items}
+            for jid in self._selected_jids:
+                if jid not in jid_items_set:
+                    jid_items_set[jid] = {"entity": jid}
+
+            # Union of search results and manually added selected items
+            filtered_items = [
+                jid_items_set[jid] for jid in jid_items_set
+            ]
+
+            self.filter_cb(filtered_items)
 
     async def on_empty_search(self, jid_search):
         """Handle the situation when the search box is empty"""
@@ -164,3 +229,17 @@
             for jid_, data in cache.roster.items()
         ]
         self.show_items(items)
+
+    def on_checkbox_change(self, evt, item):
+        log.debug(f"checkbox_change {evt.target=} {item=}")
+        evt.stopPropagation()
+        cb = evt.target
+
+        if cb.checked:
+            self._selected_jids.add(item['entity'])
+            cb.closest('.card').classList.add(self.selected_item_class)
+        else:
+            self._selected_jids.remove(item['entity'])
+            cb.closest('.card').classList.remove(self.selected_item_class)
+
+        self.update_selection_state()
--- 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"""
--- a/libervia/web/pages/calls/_browser/webrtc.py	Sat May 11 14:02:54 2024 +0200
+++ b/libervia/web/pages/calls/_browser/webrtc.py	Wed May 15 17:40:33 2024 +0200
@@ -285,6 +285,9 @@
         on_reset_cb=None,
         file_only: bool = False,
         extra_data: dict | None = None,
+        local_video_elt = None,
+        remote_video_elt = None,
+        local_stream = None
     ):
         """Initialise WebRTC instance.
 
@@ -324,11 +327,14 @@
             aio.run(self._populate_video_devices())
 
             # video elements
-            self.local_video_elt = document["local_video"]
-            self.remote_video_elt = document["remote_video"]
+            self.local_video_elt = local_video_elt
+            assert remote_video_elt is not None
+            self.remote_video_elt = remote_video_elt
         else:
             self.file_sender = None
 
+        self.local_stream = local_stream
+
         # muting
         self.is_audio_muted = None
         self.is_video_muted = None
@@ -650,15 +656,19 @@
         @param video: True if a video flux is required.
         """
         media_constraints = {"audio": audio, "video": video}
-        local_stream = await window.navigator.mediaDevices.getUserMedia(media_constraints)
+        if self.local_stream is None:
+            self.local_stream = await window.navigator.mediaDevices.getUserMedia(
+                media_constraints
+            )
 
-        if not local_stream:
+        if not self.local_stream:
             log.error("Failed to get the media stream.")
             return
 
-        self.local_video_elt.srcObject = local_stream
+        if self.local_video_elt is not None:
+            self.local_video_elt.srcObject = self.local_stream
 
-        for track in local_stream.getTracks():
+        for track in self.local_stream.getTracks():
             self._peer_connection.addTrack(track)
 
     async def _replace_user_video(
@@ -676,7 +686,7 @@
                 media_constraints
             )
         else:
-            if self.local_video_elt.srcObject:
+            if self.local_video_elt is not None and self.local_video_elt.srcObject:
                 for track in self.local_video_elt.srcObject.getTracks():
                     if track.kind == "video":
                         track.stop()
@@ -704,7 +714,10 @@
             return None
 
         # Retrieve the current local stream's video track.
-        local_stream = self.local_video_elt.srcObject
+        if self.local_video_elt is None:
+            local_stream = None
+        else:
+            local_stream = self.local_video_elt.srcObject
         if local_stream:
             local_video_tracks = [
                 track for track in local_stream.getTracks() if track.kind == "video"
@@ -778,7 +791,10 @@
             return
 
         # Update local video element's stream
-        local_stream = self.local_video_elt.srcObject
+        if self.local_video_elt is None:
+            local_stream = None
+        else:
+            local_stream = self.local_video_elt.srcObject
         if local_stream:
             local_video_tracks = [
                 track for track in local_stream.getTracks() if track.kind == "video"
@@ -979,6 +995,23 @@
             )
             self.local_candidates_buffer.clear()
 
+    async def prepare_call(
+        self,
+        audio: bool = True,
+        video: bool = True
+    ) -> dict:
+        """Prepare a call.
+
+        Create RTCPeerConnection instance, and get use media.
+
+        @param audio: True if an audio flux is required
+        @param video: True if a video flux is required
+        @return: Call Data
+        """
+        await self._create_peer_connection()
+        await self._get_user_media(audio, video)
+        return await self._get_call_data()
+
     async def make_call(
         self, callee_jid: jid.JID, audio: bool = True, video: bool = True
     ) -> None:
@@ -986,9 +1019,7 @@
         @param audio: True if an audio flux is required
         @param video: True if a video flux is required
         """
-        await self._create_peer_connection()
-        await self._get_user_media(audio, video)
-        call_data = await self._get_call_data()
+        call_data = await self.prepare_call(audio, video)
         log.info(f"calling {callee_jid!r}")
         self.sid = await bridge.call_start(str(callee_jid), json.dumps(call_data))
         log.debug(f"Call SID: {self.sid}")
@@ -1098,12 +1129,13 @@
                 "lEQVR42mP8/wcAAwAB/uzNq7sAAAAASUVORK5CYII="
             )
 
+            remote_video = self.remote_video_elt
             local_video = self.local_video_elt
-            remote_video = self.remote_video_elt
-            if local_video.srcObject:
-                for track in local_video.srcObject.getTracks():
-                    track.stop()
-            local_video.src = black_image_data
+            if local_video is not None:
+                if local_video.srcObject:
+                    for track in local_video.srcObject.getTracks():
+                        track.stop()
+                local_video.src = black_image_data
 
             if remote_video.srcObject:
                 for track in remote_video.srcObject.getTracks():
@@ -1124,7 +1156,7 @@
         local_video = self.local_video_elt
         is_muted_attr = f"is_{media_type}_muted"
 
-        if local_video.srcObject:
+        if local_video is not None and local_video.srcObject:
             track_getter = getattr(
                 local_video.srcObject, f"get{media_type.capitalize()}Tracks"
             )
--- a/libervia/web/server/restricted_bridge.py	Sat May 11 14:02:54 2024 +0200
+++ b/libervia/web/server/restricted_bridge.py	Wed May 15 17:40:33 2024 +0200
@@ -91,6 +91,28 @@
             "call_end", session_id, call_data, profile
         )
 
+    async def call_group_data_set(
+        self,
+        room_jid_s: str,
+        call_data_s: str,
+        profile: str
+    ) -> str:
+        self.no_service_profile(profile)
+        return await self.host.bridge_call(
+            "call_group_data_set", room_jid_s, call_data_s, profile
+        )
+
+    async def call_group_start(
+        self,
+        entities: list[str],
+        extra_s: str,
+        profile: str
+    ) -> str:
+        self.no_service_profile(profile)
+        return await self.host.bridge_call(
+            "call_group_start", entities, extra_s, profile
+        )
+
     async def contacts_get(self, profile):
         return await self.host.bridge_call("contacts_get", profile)
 
--- a/libervia/web/server/server.py	Sat May 11 14:02:54 2024 +0200
+++ b/libervia/web/server/server.py	Wed May 15 17:40:33 2024 +0200
@@ -588,6 +588,9 @@
             "call_ended", partial(self.on_signal, "call_ended"), "plugin"
         )
         self.bridge.register_signal(
+            "call_group_setup", partial(self.on_signal, "call_group_setup"), "plugin"
+        )
+        self.bridge.register_signal(
             "ice_candidates_new", partial(self.on_signal, "ice_candidates_new"), "plugin"
         )
         self.bridge.register_signal(