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()