Mercurial > libervia-web
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() |