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"""