comparison libervia/web/pages/calls/_browser/__init__.py @ 1616:6bfeb9f0fb84

browser (calls): conferences implementation: - Handle A/V conferences calls creation/joining by entering a conference room JID in the search box. - Group call box has been improved and is used both for group calls (small number of participants) and A/V conferences (larger number of participants). - Fullscreen button for group call is working. - Avatar/user nickname are shown in group call on peer user, as an overlay on video stream. - Use `user` metadata when present to display the right user avatar/name when receiving a stream from SFU (i.e. A/V conference). - Peer user have a new 3 dots menu with a `pin` item to (un)pin it (i.e. display it on full container with on top). - Updated webrtc to handle unidirectional streams correctly and to adapt to A/V conference specification. rel 448
author Goffi <goffi@goffi.org>
date Wed, 07 Aug 2024 00:01:57 +0200
parents 4a9679369856
children
comparison
equal deleted inserted replaced
1615:97ea776df74c 1616:6bfeb9f0fb84
11 from template import Template 11 from template import Template
12 from webrtc import WebRTC 12 from webrtc import WebRTC
13 13
14 log.warning = log.warn 14 log.warning = log.warn
15 profile = window.profile or "" 15 profile = window.profile or ""
16 own_jid = JID(window.own_jid)
16 bridge = Bridge() 17 bridge = Bridge()
17 GATHER_TIMEOUT = 10000 18 GATHER_TIMEOUT = 10000
18 ALLOWED_STATUSES = ( 19 ALLOWED_STATUSES = (
19 None, 20 None,
20 "dialing", 21 "dialing",
47 local_video_elt=document["local_video"], 48 local_video_elt=document["local_video"],
48 remote_video_elt=document["remote_video"] 49 remote_video_elt=document["remote_video"]
49 ) 50 )
50 # mapping of file sending 51 # mapping of file sending
51 self.files_webrtc: list[dict] = [] 52 self.files_webrtc: list[dict] = []
52 # WebRTC instances used for group calls.
53 self.mode = "search" 53 self.mode = "search"
54 self._status = None 54 self._status = None
55 self._callee: JID|None = None 55 self._callee: JID|None = None
56 self._group_call_room: JID|None = None 56 self._group_call_room: JID|None = None
57 self._group_call_peers: dict = {} 57 self._group_call_peers: dict = {}
58 self.is_conference = False
58 self.contacts_elt = document["contacts"] 59 self.contacts_elt = document["contacts"]
59 self.search_container_elt = document["search_container"] 60 self.search_container_elt = document["search_container"]
60 self.call_container_elt = document["call_container"] 61 self.call_container_elt = document["call_container"]
61 self.group_call_container_elt = document["group_call_container"] 62 self.group_call_container_elt = document["group_call_container"]
62 self.call_box_elt = document["call_box"] 63 self.call_box_elt = document["call_box"]
87 88
88 # other buttons 89 # other buttons
89 document["full_screen_btn"].bind("click", lambda __: self.toggle_fullscreen()) 90 document["full_screen_btn"].bind("click", lambda __: self.toggle_fullscreen())
90 document["exit_full_screen_btn"].bind( 91 document["exit_full_screen_btn"].bind(
91 "click", lambda __: self.toggle_fullscreen() 92 "click", lambda __: self.toggle_fullscreen()
93 )
94 document["group_full_screen_btn"].bind(
95 "click",
96 lambda __: self.toggle_fullscreen(group=True)
97 )
98 document["group_exit_full_screen_btn"].bind(
99 "click",
100 lambda __: self.toggle_fullscreen(group=True)
92 ) 101 )
93 document["mute_audio_btn"].bind("click", self.toggle_audio_mute) 102 document["mute_audio_btn"].bind("click", self.toggle_audio_mute)
94 document["mute_video_btn"].bind("click", self.toggle_video_mute) 103 document["mute_video_btn"].bind("click", self.toggle_video_mute)
95 self.share_desktop_col_elt = document["share_desktop_column"] 104 self.share_desktop_col_elt = document["share_desktop_column"]
96 if hasattr(window.navigator.mediaDevices, "getDisplayMedia"): 105 if hasattr(window.navigator.mediaDevices, "getDisplayMedia"):
240 aio.run(self.on_group_call_proposal(action_data, action_id)) 249 aio.run(self.on_group_call_proposal(action_data, action_id))
241 elif type_ != "call": 250 elif type_ != "call":
242 return 251 return
243 elif MUJI_PREFIX in action_data.get("from_jid", ""): 252 elif MUJI_PREFIX in action_data.get("from_jid", ""):
244 aio.run(self.on_group_call_join(action_data, action_id)) 253 aio.run(self.on_group_call_join(action_data, action_id))
254 elif self.is_conference and action_data["from_jid"] == self._callee:
255 aio.run(self.on_conference_call_join(action_data, action_id))
245 else: 256 else:
246 aio.run(self.on_action_new(action_data, action_id)) 257 aio.run(self.on_action_new(action_data, action_id))
247 258
248 def get_human_size(self, size: int|float) -> str: 259 def get_human_size(self, size: int|float) -> str:
249 """Return size in human-friendly size using SI units""" 260 """Return size in human-friendly size using SI units"""
385 if peer_jid.bare != self._group_call_room: 396 if peer_jid.bare != self._group_call_room:
386 log.warning( 397 log.warning(
387 f"Refusing group call join as were are not expecting any from this room.\n" 398 f"Refusing group call join as were are not expecting any from this room.\n"
388 f"{peer_jid.bare=} {self._group_call_room=}" 399 f"{peer_jid.bare=} {self._group_call_room=}"
389 ) 400 )
401 return
390 log.info(f"{peer_jid} joined the group call.") 402 log.info(f"{peer_jid} joined the group call.")
391 403
392 group_video_grid_elt = document["group_video_grid"] 404 group_video_grid_elt = document["group_video_grid"]
393 await cache.fill_identities([peer_jid]) 405 await cache.fill_identities([peer_jid])
394 406
395 group_peer_elt = self.group_peer_tpl.get_elt({ 407 group_peer_elt = self.group_peer_tpl.get_elt({
396 "entity": str(peer_jid), 408 "entity": str(peer_jid),
397 # "identities": cache.identities, 409 "identities": cache.identities,
398 }) 410 })
411 for pin_item in group_peer_elt.select('.action_pin'):
412 pin_item.bind('click', self.toggle_pin)
399 group_video_grid_elt <= group_peer_elt 413 group_video_grid_elt <= group_peer_elt
414
400 peer_video_stream_elt = group_peer_elt.select_one(".peer_video_stream") 415 peer_video_stream_elt = group_peer_elt.select_one(".peer_video_stream")
401 assert peer_video_stream_elt is not None 416 assert peer_video_stream_elt is not None
402 webrtc = WebRTC( 417 webrtc = WebRTC(
403 remote_video_elt=peer_video_stream_elt 418 remote_video_elt=peer_video_stream_elt
404 ) 419 )
408 "element": group_peer_elt, 423 "element": group_peer_elt,
409 "sid": sid 424 "sid": sid
410 } 425 }
411 426
412 log.debug(f"Call SID: {sid}") 427 log.debug(f"Call SID: {sid}")
428
429 await bridge.action_launch(action_id, json.dumps({"cancelled": False}))
430
431 async def on_conference_call_join(self, action_data: dict, action_id: str) -> None:
432 log.debug(f"on_conference_call_join {action_data=}")
433 peer_jid = JID(action_data["from_jid"])
434 try:
435 user_jid = JID(action_data["metadata"]["user"])
436 except KeyError:
437 user_jid = peer_jid
438 log.info(f"{user_jid} joined the conference call.")
439
440 group_video_grid_elt = document["group_video_grid"]
441 await cache.fill_identities([str(user_jid)])
442
443 group_peer_elt = self.group_peer_tpl.get_elt({
444 "entity": str(user_jid),
445 "identities": cache.identities,
446 })
447 for pin_item in group_peer_elt.select('.action_pin'):
448 pin_item.bind('click', self.toggle_pin)
449 group_video_grid_elt <= group_peer_elt
450 peer_video_stream_elt = group_peer_elt.select_one(".peer_video_stream")
451 assert peer_video_stream_elt is not None
452 log.debug(f"starting webrtc for {peer_jid} on {peer_video_stream_elt}")
453 webrtc = WebRTC(
454 remote_video_elt=peer_video_stream_elt
455 )
456 sid = webrtc.sid = action_data["session_id"]
457 self._group_call_peers[peer_jid] = {
458 "webrtc": webrtc,
459 "element": group_peer_elt,
460 "sid": sid
461 }
462
463 log.debug(f"Somebody joined conference call. SID: {sid}")
413 464
414 await bridge.action_launch(action_id, json.dumps({"cancelled": False})) 465 await bridge.action_launch(action_id, json.dumps({"cancelled": False}))
415 466
416 async def on_action_new(self, action_data: dict, action_id: str) -> None: 467 async def on_action_new(self, action_data: dict, action_id: str) -> None:
417 peer_jid = JID(action_data["from_jid"]).bare 468 peer_jid = JID(action_data["from_jid"]).bare
555 ) 606 )
556 return 607 return
557 if role == "initiator": 608 if role == "initiator":
558 await webrtc.accept_call(session_id, sdp, profile) 609 await webrtc.accept_call(session_id, sdp, profile)
559 elif role == "responder": 610 elif role == "responder":
560 await webrtc.answer_call(session_id, sdp, profile) 611 await webrtc.answer_call(
612 session_id, sdp, profile, conference=self.is_conference
613 )
561 else: 614 else:
562 dialog.notification.show( 615 dialog.notification.show(
563 f"Invalid role received during setup: {setup_data}", level="error" 616 f"Invalid role received during setup: {setup_data}", level="error"
564 ) 617 )
565 return 618 return
629 entity_jid = JID(entity_jid_s) 682 entity_jid = JID(entity_jid_s)
630 log.info(f"Calling {entity_jid_s}.") 683 log.info(f"Calling {entity_jid_s}.")
631 await cache.fill_identities([entity_jid]) 684 await cache.fill_identities([entity_jid])
632 group_peer_elt = self.group_peer_tpl.get_elt({ 685 group_peer_elt = self.group_peer_tpl.get_elt({
633 "entity": str(entity_jid), 686 "entity": str(entity_jid),
634 # "identities": cache.identities, 687 "identities": cache.identities,
635 }) 688 })
689 for pin_item in group_peer_elt.select('.action_pin'):
690 pin_item.bind('click', self.toggle_pin)
636 group_video_grid_elt <= group_peer_elt 691 group_video_grid_elt <= group_peer_elt
637 peer_video_stream_elt = group_peer_elt.select_one(".peer_video_stream") 692 peer_video_stream_elt = group_peer_elt.select_one(".peer_video_stream")
638 assert peer_video_stream_elt is not None 693 assert peer_video_stream_elt is not None
639 webrtc = WebRTC( 694 webrtc = WebRTC(
640 remote_video_elt=peer_video_stream_elt, 695 remote_video_elt=peer_video_stream_elt,
667 722
668 def on_reset_cb(self) -> None: 723 def on_reset_cb(self) -> None:
669 """Call when webRTC connection is reset, we reset buttons statuses""" 724 """Call when webRTC connection is reset, we reset buttons statuses"""
670 document["full_screen_btn"].classList.remove("is-hidden") 725 document["full_screen_btn"].classList.remove("is-hidden")
671 document["exit_full_screen_btn"].classList.add("is-hidden") 726 document["exit_full_screen_btn"].classList.add("is-hidden")
727 document["group_full_screen_btn"].classList.remove("is-hidden")
728 document["group_exit_full_screen_btn"].classList.add("is-hidden")
672 for btn_elt in document["mute_audio_btn"], document["mute_video_btn"]: 729 for btn_elt in document["mute_audio_btn"], document["mute_video_btn"]:
673 btn_elt.classList.remove(INACTIVE_CLASS, MUTED_CLASS, "is-warning") 730 btn_elt.classList.remove(INACTIVE_CLASS, MUTED_CLASS, "is-warning")
674 btn_elt.classList.add("is-success") 731 btn_elt.classList.add("is-success")
675 732
676 async def make_call( 733 async def make_call(
701 ) 758 )
702 return 759 return
703 760
704 self._callee = callee_jid 761 self._callee = callee_jid
705 await cache.fill_identities([callee_jid]) 762 await cache.fill_identities([callee_jid])
763
764 self.is_conference = False
765 try:
766 disco_identities = cache.identities[callee_jid]["identities"]
767 except KeyError:
768 pass
769 else:
770 for disco_identity in disco_identities:
771 if (
772 disco_identity.get("category") == "conference"
773 and disco_identity.get("type") == "audio-video"
774 ):
775 self.is_conference = True
776 log.info(f"{callee_jid} is an A/V Conference room.")
777
778
706 self.status = "dialing" 779 self.status = "dialing"
707 self.set_avatar(callee_jid) 780 self.set_avatar(callee_jid)
708 781
709 self.switch_mode("call") 782 self.switch_mode("group_call" if self.is_conference else "call" )
710 if remote: 783 if remote:
711 await self.webrtc.start_remote_control( 784 await self.webrtc.start_remote_control(
712 callee_jid, audio, video 785 callee_jid, audio, video
713 ) 786 )
714 else: 787 else:
715 await self.webrtc.make_call(callee_jid, audio, video) 788 if self.is_conference:
789 direction = "sendonly"
790 group_video_grid_elt = document["group_video_grid"]
791 await cache.fill_identities([str(own_jid.bare)])
792 group_peer_elt = self.group_peer_tpl.get_elt({
793 "entity": str(own_jid.bare),
794 "identities": cache.identities,
795 })
796 for pin_item in group_peer_elt.select('.action_pin'):
797 pin_item.bind('click', self.toggle_pin)
798 group_video_grid_elt <= group_peer_elt
799 peer_video_stream_elt = group_peer_elt.select_one(".peer_video_stream")
800 assert peer_video_stream_elt is not None
801 self.webrtc.local_video_elt = peer_video_stream_elt
802 else:
803 direction = "sendrecv"
804 self.webrtc.local_video_elt = document["local_video"]
805
806 await self.webrtc.make_call(
807 callee_jid,
808 audio,
809 video,
810 direction
811 )
716 812
717 async def make_group_call( 813 async def make_group_call(
718 self, 814 self,
719 ) -> None: 815 ) -> None:
720 """Start a group call. 816 """Start a group call.
876 for elt_id in to_hide: 972 for elt_id in to_hide:
877 document[elt_id].parent.classList.add("is-hidden") 973 document[elt_id].parent.classList.add("is-hidden")
878 for elt_id in to_show: 974 for elt_id in to_show:
879 document[elt_id].parent.classList.remove("is-hidden") 975 document[elt_id].parent.classList.remove("is-hidden")
880 976
881 def toggle_fullscreen(self, fullscreen: bool | None = None): 977 def toggle_fullscreen(self, fullscreen: bool | None = None, group=False):
882 """Toggle fullscreen mode for video elements. 978 """Toggle fullscreen mode for video elements.
883 979
884 @param fullscreen: if set, determine the fullscreen state; otherwise, 980 @param fullscreen: if set, determine the fullscreen state; otherwise,
885 the fullscreen mode will be toggled. 981 the fullscreen mode will be toggled.
886 """ 982 """
887 if fullscreen is None: 983 if fullscreen is None:
888 fullscreen = document.fullscreenElement is NULL 984 fullscreen = document.fullscreenElement is NULL
985 log.debug(f"toggle_fullscreen {fullscreen=} {group=}")
986
987 full_screen_cls = "full_screen_btn"
988 exit_full_screen_cls = "exit_full_screen_btn"
989 if group:
990 full_screen_cls = f"group_{full_screen_cls}"
991 exit_full_screen_cls = f"group_{exit_full_screen_cls}"
992 parent_elt = self.group_call_container_elt
993 else:
994 parent_elt = self.call_box_elt
889 995
890 try: 996 try:
891 if fullscreen: 997 if fullscreen:
892 if document.fullscreenElement is NULL: 998 if document.fullscreenElement is NULL:
893 self.call_box_elt.requestFullscreen() 999 parent_elt.requestFullscreen()
894 document["full_screen_btn"].classList.add("is-hidden") 1000 document[full_screen_cls].classList.add("is-hidden")
895 document["exit_full_screen_btn"].classList.remove("is-hidden") 1001 document[exit_full_screen_cls].classList.remove("is-hidden")
896 else: 1002 else:
897 if document.fullscreenElement is not NULL: 1003 if document.fullscreenElement is not NULL:
898 document.exitFullscreen() 1004 document.exitFullscreen()
899 document["full_screen_btn"].classList.remove("is-hidden") 1005 document[full_screen_cls].classList.remove("is-hidden")
900 document["exit_full_screen_btn"].classList.add("is-hidden") 1006 document[exit_full_screen_cls].classList.add("is-hidden")
901 1007
902 except Exception as e: 1008 except Exception as e:
903 dialog.notification.show( 1009 dialog.notification.show(
904 f"An error occurred while toggling fullscreen: {e}", level="error" 1010 f"An error occurred while toggling fullscreen: {e}", level="error"
905 ) 1011 )
1012
1013 def toggle_pin(self, event):
1014 peer_container = event.target.closest('.peer_video_container')
1015 is_pinned = peer_container.dataset.pinned == 'true'
1016
1017 # Unpin all peers
1018 for container in self.group_call_container_elt.select('.peer_video_container'):
1019 container.dataset.pinned = 'false'
1020 container.classList.remove('is-12')
1021 container.classList.add('is-3-widescreen', 'is-4-desktop', 'is-6-tablet')
1022
1023 if not is_pinned:
1024 # Pin the selected peer
1025 peer_container.dataset.pinned = 'true'
1026 peer_container.classList.remove('is-3-widescreen', 'is-4-desktop', 'is-6-tablet')
1027 peer_container.classList.add('is-12')
1028
1029 # Rearrange the grid
1030 grid = document['group_video_grid']
1031 pinned = [peer for peer in grid.children if peer.dataset.pinned == 'true']
1032 unpinned = [peer for peer in grid.children if peer.dataset.pinned != 'true']
1033
1034 grid.clear()
1035 for peer in pinned + unpinned:
1036 grid <= peer
906 1037
907 def toggle_audio_mute(self, evt): 1038 def toggle_audio_mute(self, evt):
908 is_muted = self.webrtc.toggle_audio_mute() 1039 is_muted = self.webrtc.toggle_audio_mute()
909 btn_elt = evt.currentTarget 1040 btn_elt = evt.currentTarget
910 if is_muted: 1041 if is_muted: