Mercurial > libervia-web
comparison libervia/web/pages/calls/_browser/webrtc.py @ 1566:e65d2ef1ded4
browser (calls/webrtc): send ICE candidates when received:
- ICE candidates gathering is not waited for anymore
- if session is not ready, candidates are buffered and replayed when suitable
author | Goffi <goffi@goffi.org> |
---|---|
date | Mon, 21 Aug 2023 15:36:09 +0200 |
parents | d282dbdd5ffd |
children | 9ba532041a8e |
comparison
equal
deleted
inserted
replaced
1565:d282dbdd5ffd | 1566:e65d2ef1ded4 |
---|---|
9 import jid | 9 import jid |
10 | 10 |
11 log.warning = log.warn | 11 log.warning = log.warn |
12 profile = window.profile or "" | 12 profile = window.profile or "" |
13 bridge = Bridge() | 13 bridge = Bridge() |
14 GATHER_TIMEOUT = 10000 | |
15 | 14 |
16 | 15 |
17 class WebRTC: | 16 class WebRTC: |
18 def __init__( | 17 def __init__( |
19 self, | 18 self, |
73 """Inits or resets the instance variables to their default state.""" | 72 """Inits or resets the instance variables to their default state.""" |
74 self._peer_connection = None | 73 self._peer_connection = None |
75 self._media_types = None | 74 self._media_types = None |
76 self._media_types_inv = None | 75 self._media_types_inv = None |
77 self._callee = None | 76 self._callee = None |
77 self.ufrag = None | |
78 self.pwd = None | |
78 self.sid = None | 79 self.sid = None |
79 self.local_candidates = None | 80 self.local_candidates = None |
80 self.remote_stream = None | 81 self.remote_stream = None |
81 self.candidates_buffer = { | 82 self.remote_candidates_buffer = { |
82 "audio": {"candidates": []}, | 83 "audio": {"candidates": []}, |
83 "video": {"candidates": []}, | 84 "video": {"candidates": []}, |
84 } | 85 } |
86 self.local_candidates_buffer = {} | |
85 self.media_candidates = {} | 87 self.media_candidates = {} |
86 self.candidates_gathered = aio.Future() | |
87 if self.on_reset_cb is not None: | 88 if self.on_reset_cb is not None: |
88 self.on_reset_cb() | 89 self.on_reset_cb() |
89 | 90 |
90 async def _populate_video_devices(self): | 91 async def _populate_video_devices(self): |
91 devices = await window.navigator.mediaDevices.enumerateDevices() | 92 devices = await window.navigator.mediaDevices.enumerateDevices() |
136 for index, m_type in self.media_types.items(): | 137 for index, m_type in self.media_types.items(): |
137 if m_type == media_type: | 138 if m_type == media_type: |
138 return index | 139 return index |
139 raise ValueError(f"Media type '{media_type}' not found") | 140 raise ValueError(f"Media type '{media_type}' not found") |
140 | 141 |
141 def extract_pwd_ufrag(self, sdp): | 142 def extract_ufrag_pwd(self, sdp: str) -> tuple[str, str]: |
142 """Retrieves ICE password and user fragment for SDP offer. | 143 """Retrieves ICE password and user fragment for SDP offer. |
143 | 144 |
144 @param sdp: The Session Description Protocol offer string. | 145 @param sdp: The Session Description Protocol offer string. |
146 @return: ufrag and pwd | |
147 @raise ValueError: Can't extract ufrag and password | |
145 """ | 148 """ |
146 ufrag_line = re.search(r"ice-ufrag:(\S+)", sdp) | 149 ufrag_line = re.search(r"ice-ufrag:(\S+)", sdp) |
147 pwd_line = re.search(r"ice-pwd:(\S+)", sdp) | 150 pwd_line = re.search(r"ice-pwd:(\S+)", sdp) |
148 | 151 |
149 if ufrag_line and pwd_line: | 152 if ufrag_line and pwd_line: |
150 return ufrag_line.group(1), pwd_line.group(1) | 153 ufrag = self.ufrag = ufrag_line.group(1) |
154 pwd = self.pwd = pwd_line.group(1) | |
155 return ufrag, pwd | |
151 else: | 156 else: |
152 log.error(f"SDP with missing ice-ufrag or ice-pwd:\n{sdp}") | 157 log.error(f"SDP with missing ice-ufrag or ice-pwd:\n{sdp}") |
153 raise ValueError("Can't extract ice-ufrag and ice-pwd from SDP") | 158 raise ValueError("Can't extract ice-ufrag and ice-pwd from SDP") |
154 | 159 |
155 def extract_fingerprint_data(self, sdp): | 160 def extract_fingerprint_data(self, sdp): |
240 f"Can't find media type.\n{event.candidate=}\n{self._media_types=}" | 245 f"Can't find media type.\n{event.candidate=}\n{self._media_types=}" |
241 ) | 246 ) |
242 return | 247 return |
243 self.media_candidates.setdefault(media_type, []).append(parsed_candidate) | 248 self.media_candidates.setdefault(media_type, []).append(parsed_candidate) |
244 log.debug(f"ICE candidate [{media_type}]: {event.candidate.candidate}") | 249 log.debug(f"ICE candidate [{media_type}]: {event.candidate.candidate}") |
250 if self.sid is None: | |
251 log.debug("buffering candidate") | |
252 self.local_candidates_buffer.setdefault(media_type, []).append( | |
253 parsed_candidate | |
254 ) | |
255 else: | |
256 ufrag, pwd = self.extract_ufrag_pwd( | |
257 self._peer_connection.localDescription.sdp | |
258 ) | |
259 | |
260 ice_data = {"ufrag": ufrag, "pwd": pwd, "candidates": [parsed_candidate]} | |
261 aio.run( | |
262 bridge.ice_candidates_add( | |
263 self.sid, json.dumps({media_type: ice_data}) | |
264 ) | |
265 ) | |
266 | |
245 else: | 267 else: |
246 log.debug("All ICE candidates gathered") | 268 log.debug("All ICE candidates gathered") |
247 | 269 |
248 def on_ice_connection_state_change(self, event): | 270 def on_ice_connection_state_change(self, event): |
249 """Log ICE connection change, mainly used for debugging""" | 271 """Log ICE connection change, mainly used for debugging""" |
292 """ | 314 """ |
293 connection = event.target | 315 connection = event.target |
294 log.debug(f"on_ice_gathering_state_change {connection.iceGatheringState=}") | 316 log.debug(f"on_ice_gathering_state_change {connection.iceGatheringState=}") |
295 if connection.iceGatheringState == "complete": | 317 if connection.iceGatheringState == "complete": |
296 log.info("ICE candidates gathering done") | 318 log.info("ICE candidates gathering done") |
297 self.candidates_gathered.set_result(None) | |
298 | 319 |
299 async def _create_peer_connection( | 320 async def _create_peer_connection( |
300 self, | 321 self, |
301 ): | 322 ): |
302 """Creates peer connection""" | 323 """Creates peer connection""" |
493 ) | 514 ) |
494 | 515 |
495 if video_sender: | 516 if video_sender: |
496 await video_sender.replaceTrack(new_video_tracks[0]) | 517 await video_sender.replaceTrack(new_video_tracks[0]) |
497 | 518 |
498 | |
499 async def _gather_ice_candidates(self, is_initiator: bool, remote_candidates=None): | 519 async def _gather_ice_candidates(self, is_initiator: bool, remote_candidates=None): |
500 """Get ICE candidates and wait to have them all before returning them | 520 """Get ICE candidates and wait to have them all before returning them |
501 | 521 |
502 @param is_initiator: Boolean indicating if the user is the initiator of the connection | 522 @param is_initiator: Boolean indicating if the user is the initiator of the connection |
503 @param remote_candidates: Remote ICE candidates, if any | 523 @param remote_candidates: Remote ICE candidates, if any |
506 raise Exception( | 526 raise Exception( |
507 "The peer connection must be created before gathering ICE candidates!" | 527 "The peer connection must be created before gathering ICE candidates!" |
508 ) | 528 ) |
509 | 529 |
510 self.media_candidates.clear() | 530 self.media_candidates.clear() |
511 gather_timeout = timer.set_timeout( | |
512 lambda: self.candidates_gathered.set_exception( | |
513 errors.TimeoutError("ICE gathering time out") | |
514 ), | |
515 GATHER_TIMEOUT, | |
516 ) | |
517 | 531 |
518 if is_initiator: | 532 if is_initiator: |
519 offer = await self._peer_connection.createOffer() | 533 offer = await self._peer_connection.createOffer() |
520 self._set_media_types(offer) | 534 self._set_media_types(offer) |
521 await self._peer_connection.setLocalDescription(offer) | 535 await self._peer_connection.setLocalDescription(offer) |
524 self._set_media_types(answer) | 538 self._set_media_types(answer) |
525 await self._peer_connection.setLocalDescription(answer) | 539 await self._peer_connection.setLocalDescription(answer) |
526 | 540 |
527 if not is_initiator: | 541 if not is_initiator: |
528 log.debug(self._peer_connection.localDescription.sdp) | 542 log.debug(self._peer_connection.localDescription.sdp) |
529 await self.candidates_gathered | |
530 log.debug(self._peer_connection.localDescription.sdp) | 543 log.debug(self._peer_connection.localDescription.sdp) |
531 timer.clear_timeout(gather_timeout) | 544 ufrag, pwd = self.extract_ufrag_pwd(self._peer_connection.localDescription.sdp) |
532 ufrag, pwd = self.extract_pwd_ufrag(self._peer_connection.localDescription.sdp) | |
533 return { | 545 return { |
534 "ufrag": ufrag, | 546 "ufrag": ufrag, |
535 "pwd": pwd, | 547 "pwd": pwd, |
536 "candidates": self.media_candidates, | 548 "candidates": self.media_candidates, |
537 } | 549 } |
542 @param session_id: Session identifier | 554 @param session_id: Session identifier |
543 @param sdp: Session Description Protocol data | 555 @param sdp: Session Description Protocol data |
544 @param profile: Profile associated | 556 @param profile: Profile associated |
545 """ | 557 """ |
546 await self._peer_connection.setRemoteDescription({"type": "answer", "sdp": sdp}) | 558 await self._peer_connection.setRemoteDescription({"type": "answer", "sdp": sdp}) |
547 await self.on_ice_candidates_new(self.candidates_buffer) | 559 await self.on_ice_candidates_new(self.remote_candidates_buffer) |
548 self.candidates_buffer.clear() | 560 self.remote_candidates_buffer.clear() |
549 | 561 |
550 def _on_ice_candidates_new(self, sid: str, candidates_s: str, profile: str) -> None: | 562 def _on_ice_candidates_new(self, sid: str, candidates_s: str, profile: str) -> None: |
551 """Called when new ICE candidates are received | 563 """Called when new ICE candidates are received |
552 | 564 |
553 @param sid: Session identifier | 565 @param sid: Session identifier |
572 """Called when new ICE canidates are received from peer | 584 """Called when new ICE canidates are received from peer |
573 | 585 |
574 @param candidates: Dictionary containing new ICE candidates | 586 @param candidates: Dictionary containing new ICE candidates |
575 """ | 587 """ |
576 log.debug(f"new peer candidates received: {candidates}") | 588 log.debug(f"new peer candidates received: {candidates}") |
589 # FIXME: workaround for https://github.com/brython-dev/brython/issues/2227, the | |
590 # following test raise a JS exception | |
591 try: | |
592 remoteDescription_is_none = self._peer_connection.remoteDescription is None | |
593 except Exception as e: | |
594 log.debug("Workaround for Brython bug activated.") | |
595 remoteDescription_is_none = True | |
596 | |
577 if ( | 597 if ( |
578 self._peer_connection is None | 598 self._peer_connection is None |
579 or self._peer_connection.remoteDescription is None | 599 # or self._peer_connection.remoteDescription is None |
600 or remoteDescription_is_none | |
580 ): | 601 ): |
581 for media_type in ("audio", "video"): | 602 for media_type in ("audio", "video"): |
582 media_candidates = candidates.get(media_type) | 603 media_candidates = candidates.get(media_type) |
583 if media_candidates: | 604 if media_candidates: |
584 buffer = self.candidates_buffer[media_type] | 605 buffer = self.remote_candidates_buffer[media_type] |
585 buffer["candidates"].extend(media_candidates["candidates"]) | 606 buffer["candidates"].extend(media_candidates["candidates"]) |
586 return | 607 return |
587 for media_type, ice_data in candidates.items(): | 608 for media_type, ice_data in candidates.items(): |
588 for candidate in ice_data["candidates"]: | 609 for candidate in ice_data["candidates"]: |
589 candidate_sdp = self.build_ice_candidate(candidate) | 610 candidate_sdp = self.build_ice_candidate(candidate) |
623 await self._create_peer_connection() | 644 await self._create_peer_connection() |
624 | 645 |
625 await self._peer_connection.setRemoteDescription( | 646 await self._peer_connection.setRemoteDescription( |
626 {"type": "offer", "sdp": offer_sdp} | 647 {"type": "offer", "sdp": offer_sdp} |
627 ) | 648 ) |
628 await self.on_ice_candidates_new(self.candidates_buffer) | 649 await self.on_ice_candidates_new(self.remote_candidates_buffer) |
629 self.candidates_buffer.clear() | 650 self.remote_candidates_buffer.clear() |
630 await self._get_user_media() | 651 await self._get_user_media() |
631 | 652 |
632 # Gather local ICE candidates | 653 # Gather local ICE candidates |
633 local_ice_data = await self._gather_ice_candidates(False) | 654 local_ice_data = await self._gather_ice_candidates(False) |
634 self.local_candidates = local_ice_data["candidates"] | 655 self.local_candidates = local_ice_data["candidates"] |
649 | 670 |
650 call_data = {"sdp": self._peer_connection.localDescription.sdp} | 671 call_data = {"sdp": self._peer_connection.localDescription.sdp} |
651 log.info(f"calling {callee_jid!r}") | 672 log.info(f"calling {callee_jid!r}") |
652 self.sid = await bridge.call_start(str(callee_jid), json.dumps(call_data)) | 673 self.sid = await bridge.call_start(str(callee_jid), json.dumps(call_data)) |
653 log.debug(f"Call SID: {self.sid}") | 674 log.debug(f"Call SID: {self.sid}") |
675 | |
676 if self.local_candidates_buffer: | |
677 log.debug( | |
678 f"sending buffered local ICE candidates: {self.local_candidates_buffer}" | |
679 ) | |
680 assert self.pwd is not None | |
681 ice_data = {} | |
682 for media_type, candidates in self.local_candidates_buffer.items(): | |
683 ice_data[media_type] = { | |
684 "ufrag": self.ufrag, | |
685 "pwd": self.pwd, | |
686 "candidates": candidates | |
687 } | |
688 aio.run( | |
689 bridge.ice_candidates_add( | |
690 self.sid, | |
691 json.dumps( | |
692 ice_data | |
693 ), | |
694 ) | |
695 ) | |
696 self.local_candidates_buffer.clear() | |
654 | 697 |
655 async def end_call(self) -> None: | 698 async def end_call(self) -> None: |
656 """Stop streaming and clean instance""" | 699 """Stop streaming and clean instance""" |
657 if self._peer_connection is None: | 700 if self._peer_connection is None: |
658 log.debug("There is currently no call to end.") | 701 log.debug("There is currently no call to end.") |
692 self.reset_instance() | 735 self.reset_instance() |
693 | 736 |
694 def toggle_media_mute(self, media_type: str) -> bool: | 737 def toggle_media_mute(self, media_type: str) -> bool: |
695 """Toggle mute/unmute for media tracks. | 738 """Toggle mute/unmute for media tracks. |
696 | 739 |
697 @param media_type: 'audio' or 'video'. Determines which media tracks | 740 @param media_type: "audio" or "video". Determines which media tracks |
698 to process. | 741 to process. |
699 """ | 742 """ |
700 assert media_type in ("audio", "video"), "Invalid media type" | 743 assert media_type in ("audio", "video"), "Invalid media type" |
701 | 744 |
702 local_video = self.local_video_elt | 745 local_video = self.local_video_elt |
703 is_muted_attr = f"is_{media_type}_muted" | 746 is_muted_attr = f"is_{media_type}_muted" |
704 | 747 |
705 if local_video.srcObject: | 748 if local_video.srcObject: |
706 log.debug(f"{local_video.srcObject=}") | |
707 track_getter = getattr( | 749 track_getter = getattr( |
708 local_video.srcObject, f"get{media_type.capitalize()}Tracks" | 750 local_video.srcObject, f"get{media_type.capitalize()}Tracks" |
709 ) | 751 ) |
710 log.debug("track go") | |
711 for track in track_getter(): | 752 for track in track_getter(): |
712 log.debug(f"{track=}") | |
713 track.enabled = not track.enabled | 753 track.enabled = not track.enabled |
714 setattr(self, is_muted_attr, not track.enabled) | 754 setattr(self, is_muted_attr, not track.enabled) |
715 | 755 |
716 media_name = self.media_types_inv.get(media_type) | 756 media_name = self.media_types_inv.get(media_type) |
717 if media_name is not None: | 757 if media_name is not None: |