Mercurial > libervia-web
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: |