comparison libervia/web/pages/calls/_browser/__init__.py @ 1553:83c2a6faa2ae

browser (calls): screen sharing implementation: - the new screen sharing button toggle screen sharing state - the button reflect the screen sharing state (green crossed when not sharing, red uncrossed otherwise) - the screen sharing stream replaces the camera one, and vice versa. No re-negociation is needed. - stopping the sharing through browser's dialog is supported - the screen sharing button is only visibile if supported by the platform rel 432
author Goffi <goffi@goffi.org>
date Mon, 14 Aug 2023 16:49:02 +0200
parents e47c24204449
children 855729ef75f2
comparison
equal deleted inserted replaced
1552:c62027660ec1 1553:83c2a6faa2ae
13 log.warning = log.warn 13 log.warning = log.warn
14 profile = window.profile or "" 14 profile = window.profile or ""
15 bridge = Bridge() 15 bridge = Bridge()
16 GATHER_TIMEOUT = 10000 16 GATHER_TIMEOUT = 10000
17 ALLOWED_STATUSES = (None, "dialing") 17 ALLOWED_STATUSES = (None, "dialing")
18 AUDIO = 'audio' 18 AUDIO = "audio"
19 VIDEO = 'video' 19 VIDEO = "video"
20 ALLOWED_CALL_MODES = {AUDIO, VIDEO} 20 ALLOWED_CALL_MODES = {AUDIO, VIDEO}
21 INACTIVE_CLASS = "inactive"
22 MUTED_CLASS = "muted"
23 SCREEN_OFF_CLASS = "screen-off"
21 24
22 25
23 class CallUI: 26 class CallUI:
24
25 def __init__(self): 27 def __init__(self):
26 self.webrtc = WebRTC() 28 self.webrtc = WebRTC()
29 self.webrtc.screen_sharing_cb = self.on_sharing_screen
27 self.mode = "search" 30 self.mode = "search"
28 self._status = None 31 self._status = None
29 self._callee = None 32 self._callee = None
30 self.contacts_elt = document["contacts"] 33 self.contacts_elt = document["contacts"]
31 self.search_container_elt = document["search_container"] 34 self.search_container_elt = document["search_container"]
42 45
43 # call/hang up buttons 46 # call/hang up buttons
44 self._call_mode = VIDEO 47 self._call_mode = VIDEO
45 self.call_button_tpl = Template("call/call_button.html") 48 self.call_button_tpl = Template("call/call_button.html")
46 self._update_call_button() 49 self._update_call_button()
47 document['toggle_call_mode_btn'].bind('click', self.switch_call_mode) 50 document["toggle_call_mode_btn"].bind("click", self.switch_call_mode)
48 document["hangup_btn"].bind( 51 document["hangup_btn"].bind("click", lambda __: aio.run(self.hang_up()))
49 "click", 52
50 lambda __: aio.run(self.hang_up()) 53 # other buttons
54 document["full_screen_btn"].bind("click", lambda __: self.toggle_fullscreen())
55 document["exit_full_screen_btn"].bind(
56 "click", lambda __: self.toggle_fullscreen()
51 ) 57 )
52 58 document["mute_audio_btn"].bind("click", self.toggle_audio_mute)
53 # other buttons 59 document["mute_video_btn"].bind("click", self.toggle_video_mute)
54 document["full_screen_btn"].bind( 60 self.share_desktop_btn_elt = document["share_desktop_btn"]
55 "click", 61 if hasattr(window.navigator.mediaDevices, "getDisplayMedia"):
56 lambda __: self.toggle_fullscreen() 62 self.share_desktop_btn_elt.classList.remove("is-hidden-touch")
57 ) 63 # screen sharing is supported
58 document["exit_full_screen_btn"].bind( 64 self.share_desktop_btn_elt.bind("click", self.toggle_screen_sharing)
59 "click", 65 else:
60 lambda __: self.toggle_fullscreen() 66 self.share_desktop_btn_elt.classList.add("is-hidden")
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 67
71 # search 68 # search
72 self.jid_search = JidSearch( 69 self.jid_search = JidSearch(
73 document["search"], 70 document["search"], document["contacts"], click_cb=self._on_entity_click
74 document["contacts"],
75 click_cb = self._on_entity_click
76 ) 71 )
77 72
78 # incoming call dialog 73 # incoming call dialog
79 self.incoming_call_dialog_elt = None 74 self.incoming_call_dialog_elt = None
80 75
81 @property 76 @property
82 def sid(self) -> str|None: 77 def sid(self) -> str | None:
83 return self.webrtc.sid 78 return self.webrtc.sid
84 79
85 @sid.setter 80 @sid.setter
86 def sid(self, new_sid) -> None: 81 def sid(self, new_sid) -> None:
87 self.webrtc.sid = new_sid 82 self.webrtc.sid = new_sid
95 if new_status != self._status: 90 if new_status != self._status:
96 if new_status not in ALLOWED_STATUSES: 91 if new_status not in ALLOWED_STATUSES:
97 raise Exception( 92 raise Exception(
98 f"INTERNAL ERROR: this status is not allowed: {new_status!r}" 93 f"INTERNAL ERROR: this status is not allowed: {new_status!r}"
99 ) 94 )
100 tpl_data = { 95 tpl_data = {"entity": self._callee, "status": new_status}
101 "entity": self._callee,
102 "status": new_status
103 }
104 if self._callee is not None: 96 if self._callee is not None:
105 try: 97 try:
106 tpl_data["name"] = cache.identities[self._callee]["nicknames"][0] 98 tpl_data["name"] = cache.identities[self._callee]["nicknames"][0]
107 except (KeyError, IndexError): 99 except (KeyError, IndexError):
108 tpl_data["name"] = str(self._callee) 100 tpl_data["name"] = str(self._callee)
109 status_elt = self.call_status_tpl.get_elt(tpl_data) 101 status_elt = self.call_status_tpl.get_elt(tpl_data)
110 self.call_status_wrapper_elt.clear() 102 self.call_status_wrapper_elt.clear()
111 self.call_status_wrapper_elt <= status_elt 103 self.call_status_wrapper_elt <= status_elt
112
113 104
114 self._status = new_status 105 self._status = new_status
115 106
116 @property 107 @property
117 def call_mode(self): 108 def call_mode(self):
137 self.call_mode = AUDIO if self.call_mode == VIDEO else VIDEO 128 self.call_mode = AUDIO if self.call_mode == VIDEO else VIDEO
138 129
139 def _update_call_button(self): 130 def _update_call_button(self):
140 new_button = self.call_button_tpl.get_elt({"call_mode": self.call_mode}) 131 new_button = self.call_button_tpl.get_elt({"call_mode": self.call_mode})
141 new_button.bind( 132 new_button.bind(
142 "click", 133 "click", lambda __: aio.run(self.make_call(video=not self.call_mode == AUDIO))
143 lambda __: aio.run(self.make_call(video=not self.call_mode == AUDIO))
144 ) 134 )
145 document['call_btn'].replaceWith(new_button) 135 document["call_btn"].replaceWith(new_button)
146 136
147 def _on_action_new( 137 def _on_action_new(
148 self, action_data_s: str, action_id: str, security_limit: int, profile: str 138 self, action_data_s: str, action_id: str, security_limit: int, profile: str
149 ) -> None: 139 ) -> None:
150 """Called when a call is received 140 """Called when a call is received
159 return 149 return
160 aio.run(self.on_action_new(action_data, action_id)) 150 aio.run(self.on_action_new(action_data, action_id))
161 151
162 async def on_action_new(self, action_data: dict, action_id: str) -> None: 152 async def on_action_new(self, action_data: dict, action_id: str) -> None:
163 peer_jid = action_data["from_jid"] 153 peer_jid = action_data["from_jid"]
164 log.info( 154 log.info(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']})"
166 )
167 if self.sid is not None: 155 if self.sid is not None:
168 log.warning( 156 log.warning(
169 f"already in a call ({self.sid}), can't receive a new call from " 157 f"already in a call ({self.sid}), can't receive a new call from "
170 f"{peer_jid}" 158 f"{peer_jid}"
171 ) 159 )
172 return 160 return
173 sid = self.sid = action_data["session_id"] 161 sid = self.sid = action_data["session_id"]
174 await cache.fill_identities([peer_jid]) 162 await cache.fill_identities([peer_jid])
175 identity = cache.identities[peer_jid] 163 identity = cache.identities[peer_jid]
176 peer_name = identity['nicknames'][0] 164 peer_name = identity["nicknames"][0]
177 165
178 # we start the ring 166 # we start the ring
179 self.audio_player_elt.play() 167 self.audio_player_elt.play()
180 168
181 # and ask user if we take the call 169 # and ask user if we take the call
182 try: 170 try:
183 self.incoming_call_dialog_elt = dialog.Confirm( 171 self.incoming_call_dialog_elt = dialog.Confirm(
184 f"{peer_name} is calling you.", 172 f"{peer_name} is calling you.", ok_label="Answer", cancel_label="Reject"
185 ok_label="Answer",
186 cancel_label="Reject"
187 ) 173 )
188 accepted = await self.incoming_call_dialog_elt.ashow() 174 accepted = await self.incoming_call_dialog_elt.ashow()
189 except dialog.CancelError: 175 except dialog.CancelError:
190 log.info("Call has been cancelled") 176 log.info("Call has been cancelled")
191 self.incoming_call_dialog_elt = None 177 self.incoming_call_dialog_elt = None
192 self.sid = None 178 self.sid = None
193 dialog.notification.show( 179 dialog.notification.show(f"{peer_name} has cancelled the call", level="info")
194 f"{peer_name} has cancelled the call",
195 level="info"
196 )
197 return 180 return
198 181
199 self.incoming_call_dialog_elt = None 182 self.incoming_call_dialog_elt = None
200 183
201 # we stop the ring 184 # we stop the ring
208 # Answer the call 191 # Answer the call
209 self.switch_mode("call") 192 self.switch_mode("call")
210 else: 193 else:
211 log.info(f"your are declining the call from {peer_jid}") 194 log.info(f"your are declining the call from {peer_jid}")
212 self.sid = None 195 self.sid = None
213 await bridge.action_launch( 196 await bridge.action_launch(action_id, json.dumps({"cancelled": not accepted}))
214 action_id,
215 json.dumps({"cancelled": not accepted})
216 )
217 197
218 def _on_call_ended(self, session_id: str, data_s: str, profile: str) -> None: 198 def _on_call_ended(self, session_id: str, data_s: str, profile: str) -> None:
219 """Call has been terminated 199 """Call has been terminated
220 200
221 @param session_id: Session identifier 201 @param session_id: Session identifier
239 @param sdp: Session Description Protocol data 219 @param sdp: Session Description Protocol data
240 @param profile: Profile associated with the action 220 @param profile: Profile associated with the action
241 """ 221 """
242 aio.run(self.on_call_setup(session_id, json.loads(setup_data_s), profile)) 222 aio.run(self.on_call_setup(session_id, json.loads(setup_data_s), profile))
243 223
244 async def on_call_setup(self, session_id: str, setup_data: dict, profile: str) -> None: 224 async def on_call_setup(
225 self, session_id: str, setup_data: dict, profile: str
226 ) -> None:
245 """Call has been accepted, connection can be established 227 """Call has been accepted, connection can be established
246 228
247 @param session_id: Session identifier 229 @param session_id: Session identifier
248 @param setup_data: Data with following keys: 230 @param setup_data: Data with following keys:
249 role: initiator or responser 231 role: initiator or responser
258 try: 240 try:
259 role = setup_data["role"] 241 role = setup_data["role"]
260 sdp = setup_data["sdp"] 242 sdp = setup_data["sdp"]
261 except KeyError: 243 except KeyError:
262 dialog.notification.show( 244 dialog.notification.show(
263 f"Invalid setup data received: {setup_data}", 245 f"Invalid setup data received: {setup_data}", level="error"
264 level="error"
265 ) 246 )
266 return 247 return
267 if role == "initiator": 248 if role == "initiator":
268 await self.webrtc.accept_call(session_id, sdp, profile) 249 await self.webrtc.accept_call(session_id, sdp, profile)
269 elif role == "responder": 250 elif role == "responder":
270 await self.webrtc.answer_call(session_id, sdp, profile) 251 await self.webrtc.answer_call(session_id, sdp, profile)
271 else: 252 else:
272 dialog.notification.show( 253 dialog.notification.show(
273 f"Invalid role received during setup: {setup_data}", 254 f"Invalid role received during setup: {setup_data}", level="error"
274 level="error"
275 ) 255 )
276 return 256 return
277 257
278 async def make_call(self, audio: bool = True, video: bool = True) -> None: 258 async def make_call(self, audio: bool = True, video: bool = True) -> None:
279 """Start a WebRTC call 259 """Start a WebRTC call
285 callee_jid = JID(document["search"].value.strip()) 265 callee_jid = JID(document["search"].value.strip())
286 if not callee_jid.is_valid: 266 if not callee_jid.is_valid:
287 raise ValueError 267 raise ValueError
288 except ValueError: 268 except ValueError:
289 dialog.notification.show( 269 dialog.notification.show(
290 "Invalid identifier, please use a valid callee identifier", 270 "Invalid identifier, please use a valid callee identifier", level="error"
291 level="error"
292 ) 271 )
293 return 272 return
294 273
295 self._callee = callee_jid 274 self._callee = callee_jid
296 await cache.fill_identities([callee_jid]) 275 await cache.fill_identities([callee_jid])
297 self.status = "dialing" 276 self.status = "dialing"
298 call_avatar_elt = self.call_avatar_tpl.get_elt({ 277 call_avatar_elt = self.call_avatar_tpl.get_elt(
299 "entity": str(callee_jid), 278 {
300 "identities": cache.identities, 279 "entity": str(callee_jid),
301 }) 280 "identities": cache.identities,
281 }
282 )
302 self.call_avatar_wrapper_elt.clear() 283 self.call_avatar_wrapper_elt.clear()
303 self.call_avatar_wrapper_elt <= call_avatar_elt 284 self.call_avatar_wrapper_elt <= call_avatar_elt
304
305 285
306 self.switch_mode("call") 286 self.switch_mode("call")
307 await self.webrtc.make_call(callee_jid, audio, video) 287 await self.webrtc.make_call(callee_jid, audio, video)
308 288
309 async def end_call(self, data: dict) -> None: 289 async def end_call(self, data: dict) -> None:
333 session_id = self.sid 313 session_id = self.sid
334 if not session_id: 314 if not session_id:
335 log.warning("Can't hand_up, not call in progress") 315 log.warning("Can't hand_up, not call in progress")
336 return 316 return
337 await self.end_call({"reason": "terminated"}) 317 await self.end_call({"reason": "terminated"})
338 await bridge.call_end( 318 await bridge.call_end(session_id, "")
339 session_id,
340 ""
341 )
342 319
343 def _handle_animation_end( 320 def _handle_animation_end(
344 self, 321 self,
345 element, 322 element,
346 remove = None, 323 remove=None,
347 add = None, 324 add=None,
348 ): 325 ):
349 """Return a handler that removes specified classes and the event handler. 326 """Return a handler that removes specified classes and the event handler.
350 327
351 @param element: The element to operate on. 328 @param element: The element to operate on.
352 @param remove: List of class names to remove from the element. 329 @param remove: List of class names to remove from the element.
353 @param add: List of class names to add to the element. 330 @param add: List of class names to add to the element.
354 """ 331 """
332
355 def handler(__, remove=remove, add=add): 333 def handler(__, remove=remove, add=add):
356 log.info(f"animation end OK {element=}") 334 log.info(f"animation end OK {element=}")
357 if add: 335 if add:
358 if isinstance(add, str): 336 if isinstance(add, str):
359 add = [add] 337 add = [add]
360 element.classList.add(*add) 338 element.classList.add(*add)
361 if remove: 339 if remove:
362 if isinstance(remove, str): 340 if isinstance(remove, str):
363 remove = [remove] 341 remove = [remove]
364 element.classList.remove(*remove) 342 element.classList.remove(*remove)
365 element.unbind('animationend', handler) 343 element.unbind("animationend", handler)
366 344
367 return handler 345 return handler
368 346
369 def switch_mode(self, mode: str) -> None: 347 def switch_mode(self, mode: str) -> None:
370 """Handles the user interface changes""" 348 """Handles the user interface changes"""
372 return 350 return
373 if mode == "call": 351 if mode == "call":
374 # Hide contacts with fade-out animation and bring up the call box 352 # Hide contacts with fade-out animation and bring up the call box
375 self.search_container_elt.classList.add("fade-out-y") 353 self.search_container_elt.classList.add("fade-out-y")
376 self.search_container_elt.bind( 354 self.search_container_elt.bind(
377 'animationend', 355 "animationend",
378 self._handle_animation_end( 356 self._handle_animation_end(
379 self.search_container_elt, 357 self.search_container_elt, remove="fade-out-y", add="is-hidden"
380 remove="fade-out-y", 358 ),
381 add="is-hidden"
382 )
383 ) 359 )
384 self.call_container_elt.classList.remove("is-hidden") 360 self.call_container_elt.classList.remove("is-hidden")
385 self.call_container_elt.classList.add("slide-in") 361 self.call_container_elt.classList.add("slide-in")
386 self.call_container_elt.bind( 362 self.call_container_elt.bind(
387 'animationend', 363 "animationend",
388 self._handle_animation_end( 364 self._handle_animation_end(self.call_container_elt, remove="slide-in"),
389 self.call_container_elt,
390 remove="slide-in"
391 )
392 ) 365 )
393 self.mode = mode 366 self.mode = mode
394 elif mode == "search": 367 elif mode == "search":
395 self.toggle_fullscreen(False) 368 self.toggle_fullscreen(False)
396 self.search_container_elt.classList.add("fade-out-y", "animation-reverse") 369 self.search_container_elt.classList.add("fade-out-y", "animation-reverse")
397 self.search_container_elt.classList.remove("is-hidden") 370 self.search_container_elt.classList.remove("is-hidden")
398 self.search_container_elt.bind( 371 self.search_container_elt.bind(
399 'animationend', 372 "animationend",
400 self._handle_animation_end( 373 self._handle_animation_end(
401 self.search_container_elt, 374 self.search_container_elt,
402 remove=["fade-out-y", "animation-reverse"], 375 remove=["fade-out-y", "animation-reverse"],
403 ) 376 ),
404 ) 377 )
405 self.call_container_elt.classList.add("slide-in", "animation-reverse") 378 self.call_container_elt.classList.add("slide-in", "animation-reverse")
406 self.call_container_elt.bind( 379 self.call_container_elt.bind(
407 'animationend', 380 "animationend",
408 self._handle_animation_end( 381 self._handle_animation_end(
409 self.call_container_elt, 382 self.call_container_elt,
410 remove=["slide-in", "animation-reverse"], 383 remove=["slide-in", "animation-reverse"],
411 add="is-hidden" 384 add="is-hidden",
412 ) 385 ),
413 ) 386 )
414 self.mode = mode 387 self.mode = mode
415 else: 388 else:
416 log.error(f"Internal Error: Unknown call mode: {mode}") 389 log.error(f"Internal Error: Unknown call mode: {mode}")
417 390
418 def toggle_fullscreen(self, fullscreen: bool|None = None): 391 def toggle_fullscreen(self, fullscreen: bool | None = None):
419 """Toggle fullscreen mode for video elements. 392 """Toggle fullscreen mode for video elements.
420 393
421 @param fullscreen: if set, determine the fullscreen state; otherwise, 394 @param fullscreen: if set, determine the fullscreen state; otherwise,
422 the fullscreen mode will be toggled. 395 the fullscreen mode will be toggled.
423 """ 396 """
437 document["full_screen_btn"].classList.remove("is-hidden") 410 document["full_screen_btn"].classList.remove("is-hidden")
438 document["exit_full_screen_btn"].classList.add("is-hidden") 411 document["exit_full_screen_btn"].classList.add("is-hidden")
439 412
440 except Exception as e: 413 except Exception as e:
441 dialog.notification.show( 414 dialog.notification.show(
442 f"An error occurred while toggling fullscreen: {e}", 415 f"An error occurred while toggling fullscreen: {e}", level="error"
443 level="error"
444 ) 416 )
445 417
446 def toggle_audio_mute(self, evt): 418 def toggle_audio_mute(self, evt):
447 is_muted = self.webrtc.toggle_audio_mute() 419 is_muted = self.webrtc.toggle_audio_mute()
448 btn_elt = evt.currentTarget 420 btn_elt = evt.currentTarget
449 if is_muted: 421 if is_muted:
450 btn_elt.classList.remove("is-success") 422 btn_elt.classList.remove("is-success")
451 btn_elt.classList.add("muted", "is-warning") 423 btn_elt.classList.add(INACTIVE_CLASS, MUTED_CLASS, "is-warning")
452 dialog.notification.show( 424 dialog.notification.show(
453 f"audio is now muted", 425 f"audio is now muted",
454 level="info", 426 level="info",
455 delay=2, 427 delay=2,
456 ) 428 )
457 else: 429 else:
458 btn_elt.classList.remove("muted", "is-warning") 430 btn_elt.classList.remove(INACTIVE_CLASS, MUTED_CLASS, "is-warning")
459 btn_elt.classList.add("is-success") 431 btn_elt.classList.add("is-success")
460 432
461 def toggle_video_mute(self, evt): 433 def toggle_video_mute(self, evt):
462 is_muted = self.webrtc.toggle_video_mute() 434 is_muted = self.webrtc.toggle_video_mute()
463 btn_elt = evt.currentTarget 435 btn_elt = evt.currentTarget
464 if is_muted: 436 if is_muted:
465 btn_elt.classList.remove("is-success") 437 btn_elt.classList.remove("is-success")
466 btn_elt.classList.add("muted", "is-warning") 438 btn_elt.classList.add(INACTIVE_CLASS, MUTED_CLASS, "is-warning")
467 dialog.notification.show( 439 dialog.notification.show(
468 f"video is now muted", 440 f"video is now muted",
469 level="info", 441 level="info",
470 delay=2, 442 delay=2,
471 ) 443 )
472 else: 444 else:
473 btn_elt.classList.remove("muted", "is-warning") 445 btn_elt.classList.remove(INACTIVE_CLASS, MUTED_CLASS, "is-warning")
474 btn_elt.classList.add("is-success") 446 btn_elt.classList.add("is-success")
447
448 def toggle_screen_sharing(self, evt):
449 aio.run(self.webrtc.toggle_screen_sharing())
450
451 def on_sharing_screen(self, sharing: bool) -> None:
452 """Called when screen sharing state changes"""
453 share_desktop_btn_elt = self.share_desktop_btn_elt
454 if sharing:
455 share_desktop_btn_elt.classList.add("is-danger")
456 share_desktop_btn_elt.classList.remove(INACTIVE_CLASS, SCREEN_OFF_CLASS)
457 else:
458 share_desktop_btn_elt.classList.remove("is-danger")
459 share_desktop_btn_elt.classList.add(INACTIVE_CLASS, SCREEN_OFF_CLASS)
475 460
476 def _on_entity_click(self, item: dict) -> None: 461 def _on_entity_click(self, item: dict) -> None:
477 aio.run(self.on_entity_click(item)) 462 aio.run(self.on_entity_click(item))
478 463
479 async def on_entity_click(self, item: dict) -> None: 464 async def on_entity_click(self, item: dict) -> None: