Mercurial > libervia-web
comparison libervia/web/pages/calls/_browser/__init__.py @ 1600:0a4433a343a3
browser (calls): implement WebRTC file sharing:
- Send file through WebRTC when the new `file` button is used during a call.
- Show a confirmation dialog and download file sent by WebRTC.
rel 442
author | Goffi <goffi@goffi.org> |
---|---|
date | Sat, 06 Apr 2024 13:06:17 +0200 |
parents | d282dbdd5ffd |
children | 6feac4a25e60 |
comparison
equal
deleted
inserted
replaced
1599:197350e8bf3b | 1600:0a4433a343a3 |
---|---|
2 | 2 |
3 from bridge import AsyncBridge as Bridge | 3 from bridge import AsyncBridge as Bridge |
4 from browser import aio, console as log, document, window | 4 from browser import aio, console as log, document, window |
5 from cache import cache | 5 from cache import cache |
6 import dialog | 6 import dialog |
7 from javascript import JSObject | |
7 from jid import JID | 8 from jid import JID |
8 from jid_search import JidSearch | 9 from jid_search import JidSearch |
9 import loading | 10 import loading |
10 from template import Template | 11 from template import Template |
11 from webrtc import WebRTC | 12 from webrtc import WebRTC |
40 on_reconnect_cb=self.on_reconnect, | 41 on_reconnect_cb=self.on_reconnect, |
41 on_connection_lost_cb=self.on_connection_lost, | 42 on_connection_lost_cb=self.on_connection_lost, |
42 on_video_devices=self.on_video_devices, | 43 on_video_devices=self.on_video_devices, |
43 on_reset_cb=self.on_reset_cb, | 44 on_reset_cb=self.on_reset_cb, |
44 ) | 45 ) |
46 # mapping of file sending | |
47 self.files_webrtc: list[dict] = [] | |
45 self.mode = "search" | 48 self.mode = "search" |
46 self._status = None | 49 self._status = None |
47 self._callee = None | 50 self._callee: JID|None = None |
48 self.contacts_elt = document["contacts"] | 51 self.contacts_elt = document["contacts"] |
49 self.search_container_elt = document["search_container"] | 52 self.search_container_elt = document["search_container"] |
50 self.call_container_elt = document["call_container"] | 53 self.call_container_elt = document["call_container"] |
51 self.call_box_elt = document["call_box"] | 54 self.call_box_elt = document["call_box"] |
52 self.call_avatar_wrapper_elt = document["call_avatar_wrapper"] | 55 self.call_avatar_wrapper_elt = document["call_avatar_wrapper"] |
80 # screen sharing is supported | 83 # screen sharing is supported |
81 document["share_desktop_btn"].bind("click", self.toggle_screen_sharing) | 84 document["share_desktop_btn"].bind("click", self.toggle_screen_sharing) |
82 else: | 85 else: |
83 self.share_desktop_col_elt.classList.add("is-hidden") | 86 self.share_desktop_col_elt.classList.add("is-hidden") |
84 document["switch_camera_btn"].bind("click", self.on_switch_camera) | 87 document["switch_camera_btn"].bind("click", self.on_switch_camera) |
88 document["send_file_btn"].bind("click", self.on_send_file) | |
89 document["send_file_input"].bind("change", self._on_send_input_change) | |
85 | 90 |
86 # search | 91 # search |
87 self.search_elt = document["search"] | 92 self.search_elt = document["search"] |
88 self.jid_search = JidSearch( | 93 self.jid_search = JidSearch( |
89 self.search_elt, | 94 self.search_elt, |
183 @param action_id: Unique identifier for the action | 188 @param action_id: Unique identifier for the action |
184 @param security_limit: Security limit for the action | 189 @param security_limit: Security limit for the action |
185 @param profile: Profile associated with the action | 190 @param profile: Profile associated with the action |
186 """ | 191 """ |
187 action_data = json.loads(action_data_s) | 192 action_data = json.loads(action_data_s) |
188 if action_data.get("type") != "call": | 193 if action_data.get("type") == "confirm" and action_data.get("subtype") == "file": |
189 return | 194 aio.run(self.on_file_preflight(action_data, action_id)) |
190 aio.run(self.on_action_new(action_data, action_id)) | 195 elif action_data.get("type") == "file": |
196 aio.run(self.on_file_proposal(action_data, action_id)) | |
197 elif action_data.get("type") != "call": | |
198 return | |
199 else: | |
200 aio.run(self.on_action_new(action_data, action_id)) | |
201 | |
202 def get_human_size(self, size: int|float) -> str: | |
203 """Return size in human-friendly size using SI units""" | |
204 units = ["o","Kio","Mio","Gio"] | |
205 for idx, unit in enumerate(units): | |
206 if size < 1024.0 or idx == len(units)-1: | |
207 return f"{size:.2f}{unit}" | |
208 size /= 1024.0 | |
209 raise Exception("Internal Error: this line should never be reached.") | |
210 | |
211 async def request_file_permission(self, action_data: dict) -> bool: | |
212 """Request permission to download a file.""" | |
213 peer_jid = JID(action_data["from_jid"]).bare | |
214 await cache.fill_identities([peer_jid]) | |
215 identity = cache.identities[peer_jid] | |
216 peer_name = identity["nicknames"][0] | |
217 | |
218 file_data = action_data.get("file_data", {}) | |
219 | |
220 file_name = file_data.get('name') | |
221 file_size = file_data.get('size') | |
222 | |
223 if file_name: | |
224 file_name_msg = 'wants to send you the file "{file_name}"'.format( | |
225 file_name=file_name | |
226 ) | |
227 else: | |
228 file_name_msg = 'wants to send you an unnamed file' | |
229 | |
230 if file_size is not None: | |
231 file_size_msg = "which has a size of {file_size_human}".format( | |
232 file_size_human=self.get_human_size(file_size) | |
233 ) | |
234 else: | |
235 file_size_msg = "which has an unknown size" | |
236 | |
237 file_description = file_data.get('desc') | |
238 if file_description: | |
239 description_msg = " Description: {}.".format(file_description) | |
240 else: | |
241 description_msg = "" | |
242 | |
243 file_data = action_data.get("file_data", {}) | |
244 | |
245 file_accept_dlg = dialog.Confirm( | |
246 "{peer_name} ({peer_jid}) {file_name_msg} {file_size_msg}.{description_msg} Do you " | |
247 "accept?".format( | |
248 peer_name=peer_name, | |
249 peer_jid=peer_jid, | |
250 file_name_msg=file_name_msg, | |
251 file_size_msg=file_size_msg, | |
252 description_msg=description_msg | |
253 ), | |
254 ok_label="Download", | |
255 cancel_label="Reject" | |
256 ) | |
257 return await file_accept_dlg.ashow() | |
258 | |
259 async def on_file_preflight(self, action_data: dict, action_id: str) -> None: | |
260 """Handle a file preflight (proposal made to all devices).""" | |
261 # FIXME: temporarily done in call page, will be moved to notifications handler to | |
262 # make it work anywhere. | |
263 accepted = await self.request_file_permission(action_data) | |
264 | |
265 await bridge.action_launch( | |
266 action_id, json.dumps({"answer": str(accepted).lower()}) | |
267 ) | |
268 | |
269 async def on_file_proposal(self, action_data: dict, action_id: str) -> None: | |
270 """Handle a file proposal. | |
271 | |
272 This is a proposal made specifically to this device, a opposed to | |
273 ``on_file_preflight``. File may already have been accepted during preflight. | |
274 """ | |
275 # FIXME: as for on_file_preflight, this will be moved to notification handler. | |
276 if not action_data.get("webrtc", False): | |
277 peer_jid = JID(action_data["from_jid"]).bare | |
278 # We try to do a not-too-technical warning about webrtc not being supported. | |
279 dialog.notification.show( | |
280 f"A file sending from {peer_jid} can't be accepted because it is not " | |
281 "compatible with web browser direct transfer (WebRTC).", | |
282 level="warning", | |
283 ) | |
284 # We don't explicitly refuse the file proposal, because it may be accepted and | |
285 # supported by other frontends. | |
286 # TODO: Check if any other frontend is connected for this profile, and refuse | |
287 # the file if none is. | |
288 return | |
289 if action_data.get("file_accepted", False): | |
290 # File proposal has already been accepted in preflight. | |
291 accepted = True | |
292 else: | |
293 accepted = await self.request_file_permission(action_data) | |
294 | |
295 if accepted: | |
296 sid = action_data["session_id"] | |
297 webrtc = WebRTC( | |
298 file_only=True, | |
299 extra_data={"file_data": action_data.get("file_data", {})} | |
300 ) | |
301 webrtc.sid = sid | |
302 self.files_webrtc.append({ | |
303 "webrtc": webrtc, | |
304 }) | |
305 | |
306 await bridge.action_launch( | |
307 action_id, json.dumps({"answer": str(accepted).lower()}) | |
308 ) | |
191 | 309 |
192 async def on_action_new(self, action_data: dict, action_id: str) -> None: | 310 async def on_action_new(self, action_data: dict, action_id: str) -> None: |
193 peer_jid = JID(action_data["from_jid"]).bare | 311 peer_jid = JID(action_data["from_jid"]).bare |
194 log.info(f"{peer_jid} wants to start a call ({action_data['sub_type']})") | 312 call_type = action_data["sub_type"] |
313 call_emoji = "📹" if call_type == VIDEO else "📞" | |
314 log.info(f"{peer_jid} wants to start a call ({call_type}).") | |
195 if self.sid is not None: | 315 if self.sid is not None: |
196 log.warning( | 316 log.warning( |
197 f"already in a call ({self.sid}), can't receive a new call from " | 317 f"already in a call ({self.sid}), can't receive a new call from " |
198 f"{peer_jid}" | 318 f"{peer_jid}" |
199 ) | 319 ) |
208 self.audio_player_elt.play() | 328 self.audio_player_elt.play() |
209 | 329 |
210 # and ask user if we take the call | 330 # and ask user if we take the call |
211 try: | 331 try: |
212 self.incoming_call_dialog_elt = dialog.Confirm( | 332 self.incoming_call_dialog_elt = dialog.Confirm( |
213 f"{peer_name} is calling you.", ok_label="Answer", cancel_label="Reject" | 333 f"{peer_name} is calling you ({call_emoji}{call_type}).", ok_label="Answer", cancel_label="Reject" |
214 ) | 334 ) |
215 accepted = await self.incoming_call_dialog_elt.ashow() | 335 accepted = await self.incoming_call_dialog_elt.ashow() |
216 except dialog.CancelError: | 336 except dialog.CancelError as e: |
217 log.info("Call has been cancelled") | 337 log.info("Call has been cancelled") |
218 self.incoming_call_dialog_elt = None | 338 self.incoming_call_dialog_elt = None |
219 self.sid = None | 339 self.sid = None |
220 dialog.notification.show(f"{peer_name} has cancelled the call", level="info") | 340 match e.reason: |
341 case "busy": | |
342 dialog.notification.show( | |
343 f"{peer_name} can't answer your call", | |
344 level="info", | |
345 ) | |
346 case "taken_by_other_device": | |
347 device = e.text | |
348 dialog.notification.show( | |
349 f"The call has been taken on another device ({device}).", | |
350 level="info", | |
351 ) | |
352 case _: | |
353 dialog.notification.show( | |
354 f"{peer_name} has cancelled the call", | |
355 level="info" | |
356 ) | |
221 return | 357 return |
222 | 358 |
223 self.incoming_call_dialog_elt = None | 359 self.incoming_call_dialog_elt = None |
224 | 360 |
225 # we stop the ring | 361 # we stop the ring |
228 | 364 |
229 if accepted: | 365 if accepted: |
230 log.debug(f"Call SID: {sid}") | 366 log.debug(f"Call SID: {sid}") |
231 | 367 |
232 # Answer the call | 368 # Answer the call |
369 self.call_mode = call_type | |
233 self.set_avatar(peer_jid) | 370 self.set_avatar(peer_jid) |
234 self.status = "connecting" | 371 self.status = "connecting" |
235 self.switch_mode("call") | 372 self.switch_mode("call") |
236 else: | 373 else: |
237 log.info(f"your are declining the call from {peer_jid}") | 374 log.info(f"your are declining the call from {peer_jid}") |
279 @param setup_data: Data with following keys: | 416 @param setup_data: Data with following keys: |
280 role: initiator or responser | 417 role: initiator or responser |
281 sdp: Session Description Protocol data | 418 sdp: Session Description Protocol data |
282 @param profile: Profile associated | 419 @param profile: Profile associated |
283 """ | 420 """ |
284 if self.sid != session_id: | 421 if self.sid == session_id: |
285 log.debug( | 422 webrtc = self.webrtc |
286 f"Call ignored due to different session ID ({self.sid=} {session_id=})" | 423 else: |
287 ) | 424 for file_webrtc in self.files_webrtc: |
288 return | 425 webrtc = file_webrtc["webrtc"] |
426 if webrtc.sid == session_id: | |
427 break | |
428 else: | |
429 log.debug( | |
430 f"Call ignored due to different session ID ({self.sid=} {session_id=})" | |
431 ) | |
432 return | |
289 try: | 433 try: |
290 role = setup_data["role"] | 434 role = setup_data["role"] |
291 sdp = setup_data["sdp"] | 435 sdp = setup_data["sdp"] |
292 except KeyError: | 436 except KeyError: |
293 dialog.notification.show( | 437 dialog.notification.show( |
294 f"Invalid setup data received: {setup_data}", level="error" | 438 f"Invalid setup data received: {setup_data}", level="error" |
295 ) | 439 ) |
296 return | 440 return |
297 if role == "initiator": | 441 if role == "initiator": |
298 await self.webrtc.accept_call(session_id, sdp, profile) | 442 await webrtc.accept_call(session_id, sdp, profile) |
299 elif role == "responder": | 443 elif role == "responder": |
300 await self.webrtc.answer_call(session_id, sdp, profile) | 444 await webrtc.answer_call(session_id, sdp, profile) |
301 else: | 445 else: |
302 dialog.notification.show( | 446 dialog.notification.show( |
303 f"Invalid role received during setup: {setup_data}", level="error" | 447 f"Invalid role received during setup: {setup_data}", level="error" |
304 ) | 448 ) |
305 return | 449 return |
356 async def end_call(self, data: dict) -> None: | 500 async def end_call(self, data: dict) -> None: |
357 """Stop streaming and clean instance""" | 501 """Stop streaming and clean instance""" |
358 # if there is any ringing, we stop it | 502 # if there is any ringing, we stop it |
359 self.audio_player_elt.pause() | 503 self.audio_player_elt.pause() |
360 self.audio_player_elt.currentTime = 0 | 504 self.audio_player_elt.currentTime = 0 |
505 reason = data.get("reason", "") | |
506 text = data.get("text", "") | |
361 | 507 |
362 if self.incoming_call_dialog_elt is not None: | 508 if self.incoming_call_dialog_elt is not None: |
363 self.incoming_call_dialog_elt.cancel() | 509 self.incoming_call_dialog_elt.cancel(reason, text) |
364 self.incoming_call_dialog_elt = None | 510 self.incoming_call_dialog_elt = None |
365 | 511 |
366 self.switch_mode("search") | 512 self.switch_mode("search") |
367 | 513 |
368 if data.get("reason") == "busy": | |
369 assert self._callee is not None | |
370 peer_name = cache.identities[self._callee]["nicknames"][0] | |
371 dialog.notification.show( | |
372 f"{peer_name} can't answer your call", | |
373 level="info", | |
374 ) | |
375 | 514 |
376 await self.webrtc.end_call() | 515 await self.webrtc.end_call() |
377 | 516 |
378 async def hang_up(self) -> None: | 517 async def hang_up(self) -> None: |
379 """Terminate the call""" | 518 """Terminate the call""" |
538 share_desktop_btn_elt.classList.add(INACTIVE_CLASS, SCREEN_OFF_CLASS) | 677 share_desktop_btn_elt.classList.add(INACTIVE_CLASS, SCREEN_OFF_CLASS) |
539 | 678 |
540 def on_switch_camera(self, __) -> None: | 679 def on_switch_camera(self, __) -> None: |
541 aio.run(self.webrtc.switch_camera()) | 680 aio.run(self.webrtc.switch_camera()) |
542 | 681 |
682 def on_send_file(self, __) -> None: | |
683 document["send_file_input"].click() | |
684 | |
685 def _on_send_input_change(self, evt) -> None: | |
686 aio.run(self.on_send_input_change(evt)) | |
687 | |
688 async def on_send_input_change(self, evt) -> None: | |
689 assert self._callee is not None | |
690 files = evt.currentTarget.files | |
691 for file in files: | |
692 webrtc = WebRTC(file_only=True) | |
693 self.files_webrtc.append({ | |
694 "file": file, | |
695 "webrtc": webrtc | |
696 }) | |
697 await webrtc.send_file(self._callee, file) | |
698 | |
699 | |
543 def _on_entity_click(self, item: dict) -> None: | 700 def _on_entity_click(self, item: dict) -> None: |
544 aio.run(self.on_entity_click(item)) | 701 aio.run(self.on_entity_click(item)) |
545 | 702 |
546 async def on_entity_click(self, item: dict) -> None: | 703 async def on_entity_click(self, item: dict) -> None: |
547 """Set entity JID to search bar, and start the call""" | 704 """Set entity JID to search bar, and start the call""" |