Mercurial > libervia-web
comparison libervia/web/pages/calls/_browser/__init__.py @ 1549:e47c24204449
browser (calls): update call to handle search, control buttons, and better UI/UX:
- adapt to backend changes
- UI and WebRTC parts are not separated
- users can now be searched
- add mute/fullscreen buttons
- ring
- cancellable dialog when a call is received
- status of the call
- animations
- various UI/UX improvments
rel 423
author | Goffi <goffi@goffi.org> |
---|---|
date | Wed, 09 Aug 2023 00:22:18 +0200 |
parents | eb00d593801d |
children | 83c2a6faa2ae |
comparison
equal
deleted
inserted
replaced
1548:66aa6e140ebb | 1549:e47c24204449 |
---|---|
1 import json | 1 import json |
2 import re | |
3 | 2 |
4 from bridge import AsyncBridge as Bridge | 3 from bridge import AsyncBridge as Bridge |
5 from browser import aio, console as log, document, timer, window | 4 from browser import aio, console as log, document, window |
6 import errors | 5 from cache import cache |
6 import dialog | |
7 from jid import JID | |
8 from jid_search import JidSearch | |
7 import loading | 9 import loading |
10 from template import Template | |
11 from webrtc import WebRTC | |
8 | 12 |
9 log.warning = log.warn | 13 log.warning = log.warn |
10 profile = window.profile or "" | 14 profile = window.profile or "" |
11 bridge = Bridge() | 15 bridge = Bridge() |
12 GATHER_TIMEOUT = 10000 | 16 GATHER_TIMEOUT = 10000 |
13 | 17 ALLOWED_STATUSES = (None, "dialing") |
14 | 18 AUDIO = 'audio' |
15 class WebRTCCall: | 19 VIDEO = 'video' |
20 ALLOWED_CALL_MODES = {AUDIO, VIDEO} | |
21 | |
22 | |
23 class CallUI: | |
16 | 24 |
17 def __init__(self): | 25 def __init__(self): |
18 self.reset_instance() | 26 self.webrtc = WebRTC() |
19 | 27 self.mode = "search" |
20 def reset_instance(self): | 28 self._status = None |
21 """Inits or resets the instance variables to their default state.""" | 29 self._callee = None |
22 self._peer_connection = None | 30 self.contacts_elt = document["contacts"] |
23 self._media_types = None | 31 self.search_container_elt = document["search_container"] |
24 self.sid = None | 32 self.call_container_elt = document["call_container"] |
25 self.local_candidates = None | 33 self.call_box_elt = document["call_box"] |
26 self.remote_stream = None | 34 self.call_avatar_wrapper_elt = document["call_avatar_wrapper"] |
27 self.candidates_buffer = { | 35 self.call_status_wrapper_elt = document["call_status_wrapper"] |
28 "audio": {"candidates": []}, | 36 self.call_avatar_tpl = Template("call/call_avatar.html") |
29 "video": {"candidates": []}, | 37 self.call_status_tpl = Template("call/call_status.html") |
30 } | 38 self.audio_player_elt = document["audio_player"] |
31 self.media_candidates = {} | 39 bridge.register_signal("action_new", self._on_action_new) |
32 self.candidates_gathered = aio.Future() | 40 bridge.register_signal("call_setup", self._on_call_setup) |
41 bridge.register_signal("call_ended", self._on_call_ended) | |
42 | |
43 # call/hang up buttons | |
44 self._call_mode = VIDEO | |
45 self.call_button_tpl = Template("call/call_button.html") | |
46 self._update_call_button() | |
47 document['toggle_call_mode_btn'].bind('click', self.switch_call_mode) | |
48 document["hangup_btn"].bind( | |
49 "click", | |
50 lambda __: aio.run(self.hang_up()) | |
51 ) | |
52 | |
53 # other buttons | |
54 document["full_screen_btn"].bind( | |
55 "click", | |
56 lambda __: self.toggle_fullscreen() | |
57 ) | |
58 document["exit_full_screen_btn"].bind( | |
59 "click", | |
60 lambda __: self.toggle_fullscreen() | |
61 ) | |
62 document["mute_audio_btn"].bind( | |
63 "click", | |
64 self.toggle_audio_mute | |
65 ) | |
66 document["mute_video_btn"].bind( | |
67 "click", | |
68 self.toggle_video_mute | |
69 ) | |
70 | |
71 # search | |
72 self.jid_search = JidSearch( | |
73 document["search"], | |
74 document["contacts"], | |
75 click_cb = self._on_entity_click | |
76 ) | |
77 | |
78 # incoming call dialog | |
79 self.incoming_call_dialog_elt = None | |
33 | 80 |
34 @property | 81 @property |
35 def media_types(self): | 82 def sid(self) -> str|None: |
36 if self._media_types is None: | 83 return self.webrtc.sid |
37 raise Exception("self._media_types should not be None!") | 84 |
38 return self._media_types | 85 @sid.setter |
39 | 86 def sid(self, new_sid) -> None: |
40 def get_sdp_mline_index(self, media_type): | 87 self.webrtc.sid = new_sid |
41 """Gets the sdpMLineIndex for a given media type. | 88 |
42 | 89 @property |
43 @param media_type: The type of the media. | 90 def status(self): |
44 """ | 91 return self._status |
45 for index, m_type in self.media_types.items(): | 92 |
46 if m_type == media_type: | 93 @status.setter |
47 return index | 94 def status(self, new_status): |
48 raise ValueError(f"Media type '{media_type}' not found") | 95 if new_status != self._status: |
49 | 96 if new_status not in ALLOWED_STATUSES: |
50 def extract_pwd_ufrag(self, sdp): | 97 raise Exception( |
51 """Retrieves ICE password and user fragment for SDP offer. | 98 f"INTERNAL ERROR: this status is not allowed: {new_status!r}" |
52 | 99 ) |
53 @param sdp: The Session Description Protocol offer string. | 100 tpl_data = { |
54 """ | 101 "entity": self._callee, |
55 ufrag_line = re.search(r"ice-ufrag:(\S+)", sdp) | 102 "status": new_status |
56 pwd_line = re.search(r"ice-pwd:(\S+)", sdp) | |
57 | |
58 if ufrag_line and pwd_line: | |
59 return ufrag_line.group(1), pwd_line.group(1) | |
60 else: | |
61 log.error(f"SDP with missing ice-ufrag or ice-pwd:\n{sdp}") | |
62 raise ValueError("Can't extract ice-ufrag and ice-pwd from SDP") | |
63 | |
64 def extract_fingerprint_data(self, sdp): | |
65 """Retrieves fingerprint data from an SDP offer. | |
66 | |
67 @param sdp: The Session Description Protocol offer string. | |
68 @return: A dictionary containing the fingerprint data. | |
69 """ | |
70 fingerprint_line = re.search(r"a=fingerprint:(\S+)\s+(\S+)", sdp) | |
71 if fingerprint_line: | |
72 algorithm, fingerprint = fingerprint_line.groups() | |
73 fingerprint_data = { | |
74 "hash": algorithm, | |
75 "fingerprint": fingerprint | |
76 } | 103 } |
77 | 104 if self._callee is not None: |
78 setup_line = re.search(r"a=setup:(\S+)", sdp) | 105 try: |
79 if setup_line: | 106 tpl_data["name"] = cache.identities[self._callee]["nicknames"][0] |
80 setup = setup_line.group(1) | 107 except (KeyError, IndexError): |
81 fingerprint_data["setup"] = setup | 108 tpl_data["name"] = str(self._callee) |
82 | 109 status_elt = self.call_status_tpl.get_elt(tpl_data) |
83 return fingerprint_data | 110 self.call_status_wrapper_elt.clear() |
84 else: | 111 self.call_status_wrapper_elt <= status_elt |
85 raise ValueError("fingerprint should not be missing") | 112 |
86 | 113 |
87 def parse_ice_candidate(self, candidate_string): | 114 self._status = new_status |
88 """Parses the ice candidate string. | 115 |
89 | 116 @property |
90 @param candidate_string: The ice candidate string to be parsed. | 117 def call_mode(self): |
91 """ | 118 return self._call_mode |
92 pattern = re.compile( | 119 |
93 r"candidate:(?P<foundation>\S+) (?P<component_id>\d+) (?P<transport>\S+) " | 120 @call_mode.setter |
94 r"(?P<priority>\d+) (?P<address>\S+) (?P<port>\d+) typ " | 121 def call_mode(self, mode): |
95 r"(?P<type>\S+)(?: raddr (?P<rel_addr>\S+) rport " | 122 if mode in ALLOWED_CALL_MODES: |
96 r"(?P<rel_port>\d+))?(?: generation (?P<generation>\d+))?" | 123 if self._call_mode == mode: |
97 ) | |
98 match = pattern.match(candidate_string) | |
99 if match: | |
100 candidate_dict = match.groupdict() | |
101 | |
102 # Apply the correct types to the dictionary values | |
103 candidate_dict["component_id"] = int(candidate_dict["component_id"]) | |
104 candidate_dict["priority"] = int(candidate_dict["priority"]) | |
105 candidate_dict["port"] = int(candidate_dict["port"]) | |
106 | |
107 if candidate_dict["rel_port"]: | |
108 candidate_dict["rel_port"] = int(candidate_dict["rel_port"]) | |
109 | |
110 if candidate_dict["generation"]: | |
111 candidate_dict["generation"] = candidate_dict["generation"] | |
112 | |
113 # Remove None values | |
114 return {k: v for k, v in candidate_dict.items() if v is not None} | |
115 else: | |
116 log.warning(f"can't parse candidate: {candidate_string!r}") | |
117 return None | |
118 | |
119 def build_ice_candidate(self, parsed_candidate): | |
120 """Builds ICE candidate | |
121 | |
122 @param parsed_candidate: Dictionary containing parsed ICE candidate | |
123 """ | |
124 base_format = ( | |
125 "candidate:{foundation} {component_id} {transport} {priority} " | |
126 "{address} {port} typ {type}" | |
127 ) | |
128 | |
129 if ((parsed_candidate.get('rel_addr') | |
130 and parsed_candidate.get('rel_port'))): | |
131 base_format += " raddr {rel_addr} rport {rel_port}" | |
132 | |
133 if parsed_candidate.get('generation'): | |
134 base_format += " generation {generation}" | |
135 | |
136 return base_format.format(**parsed_candidate) | |
137 | |
138 def on_ice_candidate(self, event): | |
139 """Handles ICE candidate event | |
140 | |
141 @param event: Event containing the ICE candidate | |
142 """ | |
143 log.debug(f"on ice candidate {event.candidate=}") | |
144 if event.candidate and event.candidate.candidate: | |
145 window.last_event = event | |
146 parsed_candidate = self.parse_ice_candidate(event.candidate.candidate) | |
147 if parsed_candidate is None: | |
148 return | 124 return |
149 try: | 125 self._call_mode = mode |
150 media_type = self.media_types[event.candidate.sdpMLineIndex] | 126 self._update_call_button() |
151 except (TypeError, IndexError): | 127 with_video = mode == VIDEO |
152 log.error( | 128 for elt in self.call_box_elt.select(".is-video-only"): |
153 f"Can't find media type.\n{event.candidate=}\n{self._media_types=}" | 129 if with_video: |
154 ) | 130 elt.classList.remove("is-hidden") |
155 return | 131 else: |
156 self.media_candidates.setdefault(media_type, []).append(parsed_candidate) | 132 elt.classList.add("is-hidden") |
157 log.debug(f"ICE candidate [{media_type}]: {event.candidate.candidate}") | 133 else: |
158 else: | 134 raise ValueError("Invalid call mode") |
159 log.debug("All ICE candidates gathered") | 135 |
160 | 136 def switch_call_mode(self, ev): |
161 def _set_media_types(self, offer): | 137 self.call_mode = AUDIO if self.call_mode == VIDEO else VIDEO |
162 """Sets media types from offer SDP | 138 |
163 | 139 def _update_call_button(self): |
164 @param offer: RTC session description containing the offer | 140 new_button = self.call_button_tpl.get_elt({"call_mode": self.call_mode}) |
165 """ | 141 new_button.bind( |
166 sdp_lines = offer.sdp.splitlines() | 142 "click", |
167 media_types = {} | 143 lambda __: aio.run(self.make_call(video=not self.call_mode == AUDIO)) |
168 mline_index = 0 | 144 ) |
169 | 145 document['call_btn'].replaceWith(new_button) |
170 for line in sdp_lines: | 146 |
171 if line.startswith("m="): | 147 def _on_action_new( |
172 media_types[mline_index] = line[2:line.find(" ")] | |
173 mline_index += 1 | |
174 | |
175 self._media_types = media_types | |
176 | |
177 def on_ice_gathering_state_change(self, event): | |
178 """Handles ICE gathering state change | |
179 | |
180 @param event: Event containing the ICE gathering state change | |
181 """ | |
182 connection = event.target | |
183 log.debug(f"on_ice_gathering_state_change {connection.iceGatheringState=}") | |
184 if connection.iceGatheringState == "complete": | |
185 log.info("ICE candidates gathering done") | |
186 self.candidates_gathered.set_result(None) | |
187 | |
188 async def _create_peer_connection( | |
189 self, | |
190 ): | |
191 """Creates peer connection""" | |
192 if self._peer_connection is not None: | |
193 raise Exception("create_peer_connection can't be called twice!") | |
194 | |
195 external_disco = json.loads(await bridge.external_disco_get("")) | |
196 ice_servers = [] | |
197 | |
198 for server in external_disco: | |
199 ice_server = {} | |
200 if server["type"] == "stun": | |
201 ice_server["urls"] = f"stun:{server['host']}:{server['port']}" | |
202 elif server["type"] == "turn": | |
203 ice_server["urls"] = ( | |
204 f"turn:{server['host']}:{server['port']}?transport={server['transport']}" | |
205 ) | |
206 ice_server["username"] = server["username"] | |
207 ice_server["credential"] = server["password"] | |
208 ice_servers.append(ice_server) | |
209 | |
210 rtc_configuration = {"iceServers": ice_servers} | |
211 | |
212 peer_connection = window.RTCPeerConnection.new(rtc_configuration) | |
213 peer_connection.addEventListener("track", self.on_track) | |
214 peer_connection.addEventListener("negotiationneeded", self.on_negotiation_needed) | |
215 peer_connection.addEventListener("icecandidate", self.on_ice_candidate) | |
216 peer_connection.addEventListener("icegatheringstatechange", self.on_ice_gathering_state_change) | |
217 | |
218 self._peer_connection = peer_connection | |
219 window.pc = self._peer_connection | |
220 | |
221 async def _get_user_media( | |
222 self, | |
223 audio: bool = True, | |
224 video: bool = True | |
225 ): | |
226 """Gets user media | |
227 | |
228 @param audio: True if an audio flux is required | |
229 @param video: True if a video flux is required | |
230 """ | |
231 media_constraints = {'audio': audio, 'video': video} | |
232 local_stream = await window.navigator.mediaDevices.getUserMedia(media_constraints) | |
233 document["local_video"].srcObject = local_stream | |
234 | |
235 for track in local_stream.getTracks(): | |
236 self._peer_connection.addTrack(track) | |
237 | |
238 async def _gather_ice_candidates(self, is_initiator: bool, remote_candidates=None): | |
239 """Get ICE candidates and wait to have them all before returning them | |
240 | |
241 @param is_initiator: Boolean indicating if the user is the initiator of the connection | |
242 @param remote_candidates: Remote ICE candidates, if any | |
243 """ | |
244 if self._peer_connection is None: | |
245 raise Exception("The peer connection must be created before gathering ICE candidates!") | |
246 | |
247 self.media_candidates.clear() | |
248 gather_timeout = timer.set_timeout( | |
249 lambda: self.candidates_gathered.set_exception( | |
250 errors.TimeoutError("ICE gathering time out") | |
251 ), | |
252 GATHER_TIMEOUT | |
253 ) | |
254 | |
255 if is_initiator: | |
256 offer = await self._peer_connection.createOffer() | |
257 self._set_media_types(offer) | |
258 await self._peer_connection.setLocalDescription(offer) | |
259 else: | |
260 answer = await self._peer_connection.createAnswer() | |
261 self._set_media_types(answer) | |
262 await self._peer_connection.setLocalDescription(answer) | |
263 | |
264 if not is_initiator: | |
265 log.debug(self._peer_connection.localDescription.sdp) | |
266 await self.candidates_gathered | |
267 log.debug(self._peer_connection.localDescription.sdp) | |
268 timer.clear_timeout(gather_timeout) | |
269 ufrag, pwd = self.extract_pwd_ufrag(self._peer_connection.localDescription.sdp) | |
270 return { | |
271 "ufrag": ufrag, | |
272 "pwd": pwd, | |
273 "candidates": self.media_candidates, | |
274 } | |
275 | |
276 def on_action_new( | |
277 self, action_data_s: str, action_id: str, security_limit: int, profile: str | 148 self, action_data_s: str, action_id: str, security_limit: int, profile: str |
278 ) -> None: | 149 ) -> None: |
279 """Called when a call is received | 150 """Called when a call is received |
280 | 151 |
281 @param action_data_s: Action data serialized | 152 @param action_data_s: Action data serialized |
284 @param profile: Profile associated with the action | 155 @param profile: Profile associated with the action |
285 """ | 156 """ |
286 action_data = json.loads(action_data_s) | 157 action_data = json.loads(action_data_s) |
287 if action_data.get("type") != "call": | 158 if action_data.get("type") != "call": |
288 return | 159 return |
160 aio.run(self.on_action_new(action_data, action_id)) | |
161 | |
162 async def on_action_new(self, action_data: dict, action_id: str) -> None: | |
289 peer_jid = action_data["from_jid"] | 163 peer_jid = action_data["from_jid"] |
290 log.info( | 164 log.info( |
291 f"{peer_jid} wants to start a call ({action_data['sub_type']})" | 165 f"{peer_jid} wants to start a call ({action_data['sub_type']})" |
292 ) | 166 ) |
293 if self.sid is not None: | 167 if self.sid is not None: |
294 log.warning( | 168 log.warning( |
295 f"already in a call ({self.sid}), can't receive a new call from " | 169 f"already in a call ({self.sid}), can't receive a new call from " |
296 f"{peer_jid}" | 170 f"{peer_jid}" |
297 ) | 171 ) |
298 return | 172 return |
299 self.sid = action_data["session_id"] | 173 sid = self.sid = action_data["session_id"] |
300 log.debug(f"Call SID: {self.sid}") | 174 await cache.fill_identities([peer_jid]) |
301 | 175 identity = cache.identities[peer_jid] |
302 # Answer the call | 176 peer_name = identity['nicknames'][0] |
303 offer_sdp = action_data["sdp"] | 177 |
304 aio.run(self.answer_call(offer_sdp, action_id)) | 178 # we start the ring |
305 | 179 self.audio_player_elt.play() |
306 def _on_call_accepted(self, session_id: str, sdp: str, profile: str) -> None: | 180 |
181 # and ask user if we take the call | |
182 try: | |
183 self.incoming_call_dialog_elt = dialog.Confirm( | |
184 f"{peer_name} is calling you.", | |
185 ok_label="Answer", | |
186 cancel_label="Reject" | |
187 ) | |
188 accepted = await self.incoming_call_dialog_elt.ashow() | |
189 except dialog.CancelError: | |
190 log.info("Call has been cancelled") | |
191 self.incoming_call_dialog_elt = None | |
192 self.sid = None | |
193 dialog.notification.show( | |
194 f"{peer_name} has cancelled the call", | |
195 level="info" | |
196 ) | |
197 return | |
198 | |
199 self.incoming_call_dialog_elt = None | |
200 | |
201 # we stop the ring | |
202 self.audio_player_elt.pause() | |
203 self.audio_player_elt.currentTime = 0 | |
204 | |
205 if accepted: | |
206 log.debug(f"Call SID: {sid}") | |
207 | |
208 # Answer the call | |
209 self.switch_mode("call") | |
210 else: | |
211 log.info(f"your are declining the call from {peer_jid}") | |
212 self.sid = None | |
213 await bridge.action_launch( | |
214 action_id, | |
215 json.dumps({"cancelled": not accepted}) | |
216 ) | |
217 | |
218 def _on_call_ended(self, session_id: str, data_s: str, profile: str) -> None: | |
219 """Call has been terminated | |
220 | |
221 @param session_id: Session identifier | |
222 @param data_s: Serialised additional data on why the call has ended | |
223 @param profile: Profile associated | |
224 """ | |
225 if self.sid is None: | |
226 log.debug("there are no calls in progress") | |
227 return | |
228 if session_id != self.sid: | |
229 log.debug( | |
230 f"ignoring call_ended not linked to our call ({self.sid}): {session_id}" | |
231 ) | |
232 return | |
233 aio.run(self.end_call(json.loads(data_s))) | |
234 | |
235 def _on_call_setup(self, session_id: str, setup_data_s: str, profile: str) -> None: | |
307 """Called when we have received answer SDP from responder | 236 """Called when we have received answer SDP from responder |
308 | 237 |
309 @param session_id: Session identifier | 238 @param session_id: Session identifier |
310 @param sdp: Session Description Protocol data | 239 @param sdp: Session Description Protocol data |
311 @param profile: Profile associated with the action | 240 @param profile: Profile associated with the action |
312 """ | 241 """ |
313 aio.run(self.on_call_accepted(session_id, sdp, profile)) | 242 aio.run(self.on_call_setup(session_id, json.loads(setup_data_s), profile)) |
314 | 243 |
315 def _on_call_ended(self, session_id: str, data_s: str, profile: str) -> None: | 244 async def on_call_setup(self, session_id: str, setup_data: dict, profile: str) -> None: |
316 """Call has been terminated | 245 """Call has been accepted, connection can be established |
317 | 246 |
318 @param session_id: Session identifier | 247 @param session_id: Session identifier |
319 @param data_s: Serialised additional data on why the call has ended | 248 @param setup_data: Data with following keys: |
320 @param profile: Profile associated | 249 role: initiator or responser |
321 """ | 250 sdp: Session Description Protocol data |
322 if self.sid is None: | |
323 log.debug("there are no calls in progress") | |
324 return | |
325 if session_id != self.sid: | |
326 log.debug( | |
327 f"ignoring call_ended not linked to our call ({self.sid}): {session_id}" | |
328 ) | |
329 return | |
330 aio.run(self.end_call()) | |
331 | |
332 async def on_call_accepted(self, session_id: str, sdp: str, profile: str) -> None: | |
333 """Call has been accepted, connection can be established | |
334 | |
335 @param session_id: Session identifier | |
336 @param sdp: Session Description Protocol data | |
337 @param profile: Profile associated | 251 @param profile: Profile associated |
338 """ | 252 """ |
339 if self.sid != session_id: | 253 if self.sid != session_id: |
340 log.debug( | 254 log.debug( |
341 f"Call ignored due to different session ID ({self.sid=} {session_id=})" | 255 f"Call ignored due to different session ID ({self.sid=} {session_id=})" |
342 ) | 256 ) |
343 return | 257 return |
344 await self._peer_connection.setRemoteDescription({ | 258 try: |
345 "type": "answer", | 259 role = setup_data["role"] |
346 "sdp": sdp | 260 sdp = setup_data["sdp"] |
347 }) | 261 except KeyError: |
348 await self.on_ice_candidates_new(self.candidates_buffer) | 262 dialog.notification.show( |
349 self.candidates_buffer.clear() | 263 f"Invalid setup data received: {setup_data}", |
350 | 264 level="error" |
351 def _on_ice_candidates_new(self, sid: str, candidates_s: str, profile: str) -> None: | 265 ) |
352 """Called when new ICE candidates are received | 266 return |
353 | 267 if role == "initiator": |
354 @param sid: Session identifier | 268 await self.webrtc.accept_call(session_id, sdp, profile) |
355 @param candidates_s: ICE candidates serialized | 269 elif role == "responder": |
356 @param profile: Profile associated with the action | 270 await self.webrtc.answer_call(session_id, sdp, profile) |
357 """ | 271 else: |
358 if sid != self.sid: | 272 dialog.notification.show( |
359 log.debug( | 273 f"Invalid role received during setup: {setup_data}", |
360 f"ignoring peer ice candidates for {sid=} ({self.sid=})." | 274 level="error" |
361 ) | 275 ) |
362 return | 276 return |
363 candidates = json.loads(candidates_s) | |
364 aio.run(self.on_ice_candidates_new(candidates)) | |
365 | |
366 async def on_ice_candidates_new(self, candidates: dict) -> None: | |
367 """Called when new ICE canidates are received from peer | |
368 | |
369 @param candidates: Dictionary containing new ICE candidates | |
370 """ | |
371 log.debug(f"new peer candidates received: {candidates}") | |
372 if ( | |
373 self._peer_connection is None | |
374 or self._peer_connection.remoteDescription is None | |
375 ): | |
376 for media_type in ("audio", "video"): | |
377 media_candidates = candidates.get(media_type) | |
378 if media_candidates: | |
379 buffer = self.candidates_buffer[media_type] | |
380 buffer["candidates"].extend(media_candidates["candidates"]) | |
381 return | |
382 for media_type, ice_data in candidates.items(): | |
383 for candidate in ice_data["candidates"]: | |
384 candidate_sdp = self.build_ice_candidate(candidate) | |
385 try: | |
386 sdp_mline_index = self.get_sdp_mline_index(media_type) | |
387 except Exception as e: | |
388 log.warning(e) | |
389 continue | |
390 ice_candidate = window.RTCIceCandidate.new({ | |
391 "candidate": candidate_sdp, | |
392 "sdpMLineIndex": sdp_mline_index | |
393 } | |
394 ) | |
395 await self._peer_connection.addIceCandidate(ice_candidate) | |
396 | |
397 def on_track(self, event): | |
398 """New track has been received from peer | |
399 | |
400 @param event: Event associated with the new track | |
401 """ | |
402 if event.streams and event.streams[0]: | |
403 remote_stream = event.streams[0] | |
404 document["remote_video"].srcObject = remote_stream | |
405 else: | |
406 if self.remote_stream is None: | |
407 self.remote_stream = window.MediaStream.new() | |
408 document["remote_video"].srcObject = self.remote_stream | |
409 self.remote_stream.addTrack(event.track) | |
410 | |
411 document["call_btn"].classList.add("is-hidden") | |
412 document["hangup_btn"].classList.remove("is-hidden") | |
413 | |
414 def on_negotiation_needed(self, event) -> None: | |
415 log.debug(f"on_negotiation_needed {event=}") | |
416 # TODO | |
417 | |
418 async def answer_call(self, offer_sdp: str, action_id: str): | |
419 """We respond to the call""" | |
420 log.debug("answering call") | |
421 await self._create_peer_connection() | |
422 | |
423 await self._peer_connection.setRemoteDescription({ | |
424 "type": "offer", | |
425 "sdp": offer_sdp | |
426 }) | |
427 await self.on_ice_candidates_new(self.candidates_buffer) | |
428 self.candidates_buffer.clear() | |
429 await self._get_user_media() | |
430 | |
431 # Gather local ICE candidates | |
432 local_ice_data = await self._gather_ice_candidates(False) | |
433 self.local_candidates = local_ice_data["candidates"] | |
434 | |
435 await bridge.action_launch( | |
436 action_id, | |
437 json.dumps({ | |
438 "sdp": self._peer_connection.localDescription.sdp, | |
439 }) | |
440 ) | |
441 | 277 |
442 async def make_call(self, audio: bool = True, video: bool = True) -> None: | 278 async def make_call(self, audio: bool = True, video: bool = True) -> None: |
443 """Start a WebRTC call | 279 """Start a WebRTC call |
444 | 280 |
445 @param audio: True if an audio flux is required | 281 @param audio: True if an audio flux is required |
446 @param video: True if a video flux is required | 282 @param video: True if a video flux is required |
447 """ | 283 """ |
448 await self._create_peer_connection() | 284 try: |
449 await self._get_user_media(audio, video) | 285 callee_jid = JID(document["search"].value.strip()) |
450 await self._gather_ice_candidates(True) | 286 if not callee_jid.is_valid: |
451 callee_jid = document["callee_jid"].value | 287 raise ValueError |
452 | 288 except ValueError: |
453 call_data = { | 289 dialog.notification.show( |
454 "sdp": self._peer_connection.localDescription.sdp | 290 "Invalid identifier, please use a valid callee identifier", |
455 } | 291 level="error" |
456 log.info(f"calling {callee_jid!r}") | 292 ) |
457 self.sid = await bridge.call_start( | 293 return |
458 callee_jid, | 294 |
459 json.dumps(call_data) | 295 self._callee = callee_jid |
460 ) | 296 await cache.fill_identities([callee_jid]) |
461 log.debug(f"Call SID: {self.sid}") | 297 self.status = "dialing" |
462 | 298 call_avatar_elt = self.call_avatar_tpl.get_elt({ |
463 async def end_call(self) -> None: | 299 "entity": str(callee_jid), |
300 "identities": cache.identities, | |
301 }) | |
302 self.call_avatar_wrapper_elt.clear() | |
303 self.call_avatar_wrapper_elt <= call_avatar_elt | |
304 | |
305 | |
306 self.switch_mode("call") | |
307 await self.webrtc.make_call(callee_jid, audio, video) | |
308 | |
309 async def end_call(self, data: dict) -> None: | |
464 """Stop streaming and clean instance""" | 310 """Stop streaming and clean instance""" |
465 document["hangup_btn"].classList.add("is-hidden") | 311 # if there is any ringing, we stop it |
466 document["call_btn"].classList.remove("is-hidden") | 312 self.audio_player_elt.pause() |
467 if self._peer_connection is None: | 313 self.audio_player_elt.currentTime = 0 |
468 log.debug("There is currently no call to end.") | 314 |
469 else: | 315 if self.incoming_call_dialog_elt is not None: |
470 self._peer_connection.removeEventListener("track", self.on_track) | 316 self.incoming_call_dialog_elt.cancel() |
471 self._peer_connection.removeEventListener("negotiationneeded", self.on_negotiation_needed) | 317 self.incoming_call_dialog_elt = None |
472 self._peer_connection.removeEventListener("icecandidate", self.on_ice_candidate) | 318 |
473 self._peer_connection.removeEventListener("icegatheringstatechange", self.on_ice_gathering_state_change) | 319 self.switch_mode("search") |
474 | 320 |
475 local_video = document["local_video"] | 321 if data.get("reason") == "busy": |
476 remote_video = document["remote_video"] | 322 assert self._callee is not None |
477 if local_video.srcObject: | 323 peer_name = cache.identities[self._callee]["nicknames"][0] |
478 for track in local_video.srcObject.getTracks(): | 324 dialog.notification.show( |
479 track.stop() | 325 f"{peer_name} can't answer your call", |
480 if remote_video.srcObject: | 326 level="info", |
481 for track in remote_video.srcObject.getTracks(): | 327 ) |
482 track.stop() | 328 |
483 | 329 await self.webrtc.end_call() |
484 self._peer_connection.close() | 330 |
485 self.reset_instance() | 331 async def hang_up(self) -> None: |
486 | |
487 async def hand_up(self) -> None: | |
488 """Terminate the call""" | 332 """Terminate the call""" |
489 session_id = self.sid | 333 session_id = self.sid |
490 await self.end_call() | 334 if not session_id: |
335 log.warning("Can't hand_up, not call in progress") | |
336 return | |
337 await self.end_call({"reason": "terminated"}) | |
491 await bridge.call_end( | 338 await bridge.call_end( |
492 session_id, | 339 session_id, |
493 "" | 340 "" |
494 ) | 341 ) |
495 | 342 |
496 | 343 def _handle_animation_end( |
497 webrtc_call = WebRTCCall() | 344 self, |
498 | 345 element, |
499 document["call_btn"].bind( | 346 remove = None, |
500 "click", | 347 add = None, |
501 lambda __: aio.run(webrtc_call.make_call()) | 348 ): |
502 ) | 349 """Return a handler that removes specified classes and the event handler. |
503 document["hangup_btn"].bind( | 350 |
504 "click", | 351 @param element: The element to operate on. |
505 lambda __: aio.run(webrtc_call.hand_up()) | 352 @param remove: List of class names to remove from the element. |
506 ) | 353 @param add: List of class names to add to the element. |
507 | 354 """ |
508 bridge.register_signal("action_new", webrtc_call.on_action_new) | 355 def handler(__, remove=remove, add=add): |
509 bridge.register_signal("call_accepted", webrtc_call._on_call_accepted) | 356 log.info(f"animation end OK {element=}") |
510 bridge.register_signal("call_ended", webrtc_call._on_call_ended) | 357 if add: |
511 bridge.register_signal("ice_candidates_new", webrtc_call._on_ice_candidates_new) | 358 if isinstance(add, str): |
512 | 359 add = [add] |
360 element.classList.add(*add) | |
361 if remove: | |
362 if isinstance(remove, str): | |
363 remove = [remove] | |
364 element.classList.remove(*remove) | |
365 element.unbind('animationend', handler) | |
366 | |
367 return handler | |
368 | |
369 def switch_mode(self, mode: str) -> None: | |
370 """Handles the user interface changes""" | |
371 if mode == self.mode: | |
372 return | |
373 if mode == "call": | |
374 # Hide contacts with fade-out animation and bring up the call box | |
375 self.search_container_elt.classList.add("fade-out-y") | |
376 self.search_container_elt.bind( | |
377 'animationend', | |
378 self._handle_animation_end( | |
379 self.search_container_elt, | |
380 remove="fade-out-y", | |
381 add="is-hidden" | |
382 ) | |
383 ) | |
384 self.call_container_elt.classList.remove("is-hidden") | |
385 self.call_container_elt.classList.add("slide-in") | |
386 self.call_container_elt.bind( | |
387 'animationend', | |
388 self._handle_animation_end( | |
389 self.call_container_elt, | |
390 remove="slide-in" | |
391 ) | |
392 ) | |
393 self.mode = mode | |
394 elif mode == "search": | |
395 self.toggle_fullscreen(False) | |
396 self.search_container_elt.classList.add("fade-out-y", "animation-reverse") | |
397 self.search_container_elt.classList.remove("is-hidden") | |
398 self.search_container_elt.bind( | |
399 'animationend', | |
400 self._handle_animation_end( | |
401 self.search_container_elt, | |
402 remove=["fade-out-y", "animation-reverse"], | |
403 ) | |
404 ) | |
405 self.call_container_elt.classList.add("slide-in", "animation-reverse") | |
406 self.call_container_elt.bind( | |
407 'animationend', | |
408 self._handle_animation_end( | |
409 self.call_container_elt, | |
410 remove=["slide-in", "animation-reverse"], | |
411 add="is-hidden" | |
412 ) | |
413 ) | |
414 self.mode = mode | |
415 else: | |
416 log.error(f"Internal Error: Unknown call mode: {mode}") | |
417 | |
418 def toggle_fullscreen(self, fullscreen: bool|None = None): | |
419 """Toggle fullscreen mode for video elements. | |
420 | |
421 @param fullscreen: if set, determine the fullscreen state; otherwise, | |
422 the fullscreen mode will be toggled. | |
423 """ | |
424 do_fullscreen = ( | |
425 document.fullscreenElement is None if fullscreen is None else fullscreen | |
426 ) | |
427 | |
428 try: | |
429 if do_fullscreen: | |
430 if document.fullscreenElement is None: | |
431 self.call_box_elt.requestFullscreen() | |
432 document["full_screen_btn"].classList.add("is-hidden") | |
433 document["exit_full_screen_btn"].classList.remove("is-hidden") | |
434 else: | |
435 if document.fullscreenElement is not None: | |
436 document.exitFullscreen() | |
437 document["full_screen_btn"].classList.remove("is-hidden") | |
438 document["exit_full_screen_btn"].classList.add("is-hidden") | |
439 | |
440 except Exception as e: | |
441 dialog.notification.show( | |
442 f"An error occurred while toggling fullscreen: {e}", | |
443 level="error" | |
444 ) | |
445 | |
446 def toggle_audio_mute(self, evt): | |
447 is_muted = self.webrtc.toggle_audio_mute() | |
448 btn_elt = evt.currentTarget | |
449 if is_muted: | |
450 btn_elt.classList.remove("is-success") | |
451 btn_elt.classList.add("muted", "is-warning") | |
452 dialog.notification.show( | |
453 f"audio is now muted", | |
454 level="info", | |
455 delay=2, | |
456 ) | |
457 else: | |
458 btn_elt.classList.remove("muted", "is-warning") | |
459 btn_elt.classList.add("is-success") | |
460 | |
461 def toggle_video_mute(self, evt): | |
462 is_muted = self.webrtc.toggle_video_mute() | |
463 btn_elt = evt.currentTarget | |
464 if is_muted: | |
465 btn_elt.classList.remove("is-success") | |
466 btn_elt.classList.add("muted", "is-warning") | |
467 dialog.notification.show( | |
468 f"video is now muted", | |
469 level="info", | |
470 delay=2, | |
471 ) | |
472 else: | |
473 btn_elt.classList.remove("muted", "is-warning") | |
474 btn_elt.classList.add("is-success") | |
475 | |
476 def _on_entity_click(self, item: dict) -> None: | |
477 aio.run(self.on_entity_click(item)) | |
478 | |
479 async def on_entity_click(self, item: dict) -> None: | |
480 """Set entity JID to search bar, and start the call""" | |
481 document["search"].value = item["entity"] | |
482 | |
483 await self.make_call() | |
484 | |
485 | |
486 CallUI() | |
513 loading.remove_loading_screen() | 487 loading.remove_loading_screen() |