comparison libervia/web/pages/calls/_browser/__init__.py @ 1602:6feac4a25e60

browser: Remote Control implementation: - Add `cbor-x` JS dependency. - In "Call" page, a Remote Control session can now be started. This is done by clicking on a search item 3 dots menu. Libervia Web will act as a controlling device. The call box is then adapted, and mouse/wheel and keyboard events are sent to remote, touch events are converted to mouse one. - Some Brython 3.12* related changes. rel 436
author Goffi <goffi@goffi.org>
date Sat, 11 May 2024 14:02:22 +0200
parents 0a4433a343a3
children 4a9679369856
comparison
equal deleted inserted replaced
1601:d07838fc9d99 1602:6feac4a25e60
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 javascript import JSObject, NULL
8 from jid import JID 8 from jid import JID
9 from jid_search import JidSearch 9 from jid_search import JidSearch
10 import loading 10 import loading
11 from template import Template 11 from template import Template
12 from webrtc import WebRTC 12 from webrtc import WebRTC
25 "connection-lost", 25 "connection-lost",
26 "reconnecting", 26 "reconnecting",
27 ) 27 )
28 AUDIO = "audio" 28 AUDIO = "audio"
29 VIDEO = "video" 29 VIDEO = "video"
30 ALLOWED_CALL_MODES = {AUDIO, VIDEO} 30 REMOTE = "remote-control"
31 ALLOWED_CALL_MODES = {AUDIO, VIDEO, REMOTE}
31 INACTIVE_CLASS = "inactive" 32 INACTIVE_CLASS = "inactive"
32 MUTED_CLASS = "muted" 33 MUTED_CLASS = "muted"
33 SCREEN_OFF_CLASS = "screen-off" 34 SCREEN_OFF_CLASS = "screen-off"
34 35
35 36
105 self.on_entity_action(evt, VIDEO, item) 106 self.on_entity_action(evt, VIDEO, item)
106 ), 107 ),
107 ".click-to-audio": lambda evt, item: aio.run( 108 ".click-to-audio": lambda evt, item: aio.run(
108 self.on_entity_action(evt, AUDIO, item) 109 self.on_entity_action(evt, AUDIO, item)
109 ), 110 ),
111 ".click-to-remote-control": lambda evt, item: aio.run(
112 self.on_entity_action(evt, REMOTE, item)
113 ),
110 }, 114 },
111 }, 115 },
112 ) 116 )
113 document["clear_search_btn"].bind("click", self.on_clear_search) 117 document["clear_search_btn"].bind("click", self.on_clear_search)
114 118
153 @call_mode.setter 157 @call_mode.setter
154 def call_mode(self, mode): 158 def call_mode(self, mode):
155 if mode in ALLOWED_CALL_MODES: 159 if mode in ALLOWED_CALL_MODES:
156 if self._call_mode == mode: 160 if self._call_mode == mode:
157 return 161 return
162 log.debug("Switching to {mode} call mode.")
158 self._call_mode = mode 163 self._call_mode = mode
159 with_video = mode == VIDEO 164 selector = ".is-video-only, .is-not-remote"
160 for elt in self.call_box_elt.select(".is-video-only"): 165 for elt in self.call_box_elt.select(selector):
161 if with_video: 166 if mode == VIDEO:
167 # In video, all elements are visible.
162 elt.classList.remove("is-hidden") 168 elt.classList.remove("is-hidden")
169 elif mode == AUDIO:
170 # In audio, we hide video-only elements.
171 if elt.classList.contains("is-video-only"):
172 elt.classList.add("is-hidden")
173 else:
174 elt.classList.remove("is-hidden")
175 elif mode == REMOTE:
176 # In remote, we show all video element, except if they are
177 # `is-not-remote`
178 if elt.classList.contains("is-not-remote"):
179 elt.classList.add("is-hidden")
180 else:
181 elt.classList.remove("is-hidden")
163 else: 182 else:
164 elt.classList.add("is-hidden") 183 raise Exception("This line should never be reached.")
165 else: 184 else:
166 raise ValueError("Invalid call mode") 185 raise ValueError("Invalid call mode")
167 186
168 def set_avatar(self, entity_jid: JID | str) -> None: 187 def set_avatar(self, entity_jid: JID | str) -> None:
169 """Set the avatar element from entity_jid 188 """Set the avatar element from entity_jid
188 @param action_id: Unique identifier for the action 207 @param action_id: Unique identifier for the action
189 @param security_limit: Security limit for the action 208 @param security_limit: Security limit for the action
190 @param profile: Profile associated with the action 209 @param profile: Profile associated with the action
191 """ 210 """
192 action_data = json.loads(action_data_s) 211 action_data = json.loads(action_data_s)
193 if action_data.get("type") == "confirm" and action_data.get("subtype") == "file": 212 if (
213 action_data.get("type") in ("confirm", "not_in_roster_leak")
214 and action_data.get("subtype") == "file"
215 ):
194 aio.run(self.on_file_preflight(action_data, action_id)) 216 aio.run(self.on_file_preflight(action_data, action_id))
195 elif action_data.get("type") == "file": 217 elif action_data.get("type") == "file":
196 aio.run(self.on_file_proposal(action_data, action_id)) 218 aio.run(self.on_file_proposal(action_data, action_id))
197 elif action_data.get("type") != "call": 219 elif action_data.get("type") != "call":
198 return 220 return
284 # We don't explicitly refuse the file proposal, because it may be accepted and 306 # We don't explicitly refuse the file proposal, because it may be accepted and
285 # supported by other frontends. 307 # supported by other frontends.
286 # TODO: Check if any other frontend is connected for this profile, and refuse 308 # TODO: Check if any other frontend is connected for this profile, and refuse
287 # the file if none is. 309 # the file if none is.
288 return 310 return
289 if action_data.get("file_accepted", False): 311 if action_data.get("pre_accepted", False):
290 # File proposal has already been accepted in preflight. 312 # File proposal has already been accepted in preflight.
291 accepted = True 313 accepted = True
292 else: 314 else:
293 accepted = await self.request_file_permission(action_data) 315 accepted = await self.request_file_permission(action_data)
294 316
470 document["exit_full_screen_btn"].classList.add("is-hidden") 492 document["exit_full_screen_btn"].classList.add("is-hidden")
471 for btn_elt in document["mute_audio_btn"], document["mute_video_btn"]: 493 for btn_elt in document["mute_audio_btn"], document["mute_video_btn"]:
472 btn_elt.classList.remove(INACTIVE_CLASS, MUTED_CLASS, "is-warning") 494 btn_elt.classList.remove(INACTIVE_CLASS, MUTED_CLASS, "is-warning")
473 btn_elt.classList.add("is-success") 495 btn_elt.classList.add("is-success")
474 496
475 async def make_call(self, audio: bool = True, video: bool = True) -> None: 497 async def make_call(
498 self,
499 audio: bool = True,
500 video: bool = True,
501 remote: bool = False
502 ) -> None:
476 """Start a WebRTC call 503 """Start a WebRTC call
477 504
478 @param audio: True if an audio flux is required 505 @param audio: True if an audio flux is required
479 @param video: True if a video flux is required 506 @param video: True if a video flux is required
480 """ 507 @param remote: True if this is a Remote Control session.
481 self.call_mode = VIDEO if video else AUDIO 508 """
509 if remote:
510 self.call_mode = REMOTE
511 elif video:
512 self.call_mode = VIDEO
513 else:
514 self.call_mode = AUDIO
482 try: 515 try:
483 callee_jid = JID(self.search_elt.value.strip()) 516 callee_jid = JID(self.search_elt.value.strip())
484 if not callee_jid.is_valid: 517 if not callee_jid.is_valid:
485 raise ValueError 518 raise ValueError
486 except ValueError: 519 except ValueError:
493 await cache.fill_identities([callee_jid]) 526 await cache.fill_identities([callee_jid])
494 self.status = "dialing" 527 self.status = "dialing"
495 self.set_avatar(callee_jid) 528 self.set_avatar(callee_jid)
496 529
497 self.switch_mode("call") 530 self.switch_mode("call")
498 await self.webrtc.make_call(callee_jid, audio, video) 531 if remote:
532 await self.webrtc.start_remote_control(
533 callee_jid, audio, video
534 )
535 else:
536 await self.webrtc.make_call(callee_jid, audio, video)
499 537
500 async def end_call(self, data: dict) -> None: 538 async def end_call(self, data: dict) -> None:
501 """Stop streaming and clean instance""" 539 """Stop streaming and clean instance"""
502 # if there is any ringing, we stop it 540 # if there is any ringing, we stop it
503 self.audio_player_elt.pause() 541 self.audio_player_elt.pause()
610 """Toggle fullscreen mode for video elements. 648 """Toggle fullscreen mode for video elements.
611 649
612 @param fullscreen: if set, determine the fullscreen state; otherwise, 650 @param fullscreen: if set, determine the fullscreen state; otherwise,
613 the fullscreen mode will be toggled. 651 the fullscreen mode will be toggled.
614 """ 652 """
615 do_fullscreen = ( 653 if fullscreen is None:
616 document.fullscreenElement is None if fullscreen is None else fullscreen 654 fullscreen = document.fullscreenElement is NULL
617 )
618 655
619 try: 656 try:
620 if do_fullscreen: 657 if fullscreen:
621 if document.fullscreenElement is None: 658 if document.fullscreenElement is NULL:
622 self.call_box_elt.requestFullscreen() 659 self.call_box_elt.requestFullscreen()
623 document["full_screen_btn"].classList.add("is-hidden") 660 document["full_screen_btn"].classList.add("is-hidden")
624 document["exit_full_screen_btn"].classList.remove("is-hidden") 661 document["exit_full_screen_btn"].classList.remove("is-hidden")
625 else: 662 else:
626 if document.fullscreenElement is not None: 663 if document.fullscreenElement is not NULL:
627 document.exitFullscreen() 664 document.exitFullscreen()
628 document["full_screen_btn"].classList.remove("is-hidden") 665 document["full_screen_btn"].classList.remove("is-hidden")
629 document["exit_full_screen_btn"].classList.add("is-hidden") 666 document["exit_full_screen_btn"].classList.add("is-hidden")
630 667
631 except Exception as e: 668 except Exception as e:
709 async def on_entity_action(self, evt, action: str, item: dict) -> None: 746 async def on_entity_action(self, evt, action: str, item: dict) -> None:
710 """Handle extra actions on search items""" 747 """Handle extra actions on search items"""
711 evt.stopPropagation() 748 evt.stopPropagation()
712 if action == "menu": 749 if action == "menu":
713 evt.currentTarget.parent.classList.toggle("is-active") 750 evt.currentTarget.parent.classList.toggle("is-active")
714 elif action in (VIDEO, AUDIO): 751 elif action in (VIDEO, AUDIO, REMOTE):
715 self.search_elt.value = item["entity"] 752 self.search_elt.value = item["entity"]
716 # we want the dropdown to be inactive 753 # we want the dropdown to be inactive
717 evt.currentTarget.closest(".dropdown").classList.remove("is-active") 754 evt.currentTarget.closest(".dropdown").classList.remove("is-active")
718 await self.make_call(video=action == VIDEO) 755 if action == REMOTE:
756 await self.make_call(audio=False, video=True, remote=True)
757
758 else:
759 await self.make_call(video=action == VIDEO)
719 760
720 761
721 CallUI() 762 CallUI()
722 loading.remove_loading_screen() 763 loading.remove_loading_screen()