# HG changeset patch # User Goffi # Date 1715787633 -7200 # Node ID 4a967936985606383f083f50c922a54f82ec947c # Parent e105d7719479ea37cb8c70e7fa9d4473afeec743 browser (call): implements group calls: rel 430 diff -r e105d7719479 -r 4a9679369856 libervia/web/pages/_browser/jid_search.py --- 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() diff -r e105d7719479 -r 4a9679369856 libervia/web/pages/calls/_browser/__init__.py --- 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""" diff -r e105d7719479 -r 4a9679369856 libervia/web/pages/calls/_browser/webrtc.py --- 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" ) diff -r e105d7719479 -r 4a9679369856 libervia/web/server/restricted_bridge.py --- 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) diff -r e105d7719479 -r 4a9679369856 libervia/web/server/server.py --- 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(