# HG changeset patch # User Goffi # Date 1715428942 -7200 # Node ID 6feac4a25e60755a3a1f5f4a0ada322d91caa8af # Parent d07838fc9d99d6dbf88a7fabb11c6cb6b27ca2cf 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 diff -r d07838fc9d99 -r 6feac4a25e60 libervia/web/pages/_browser/browser_meta.json --- a/libervia/web/pages/_browser/browser_meta.json Sat May 11 13:57:49 2024 +0200 +++ b/libervia/web/pages/_browser/browser_meta.json Sat May 11 14:02:22 2024 +0200 @@ -7,7 +7,8 @@ "moment": "^2.29.4", "ogv": "^1.8.9", "tippy.js": "^6.3", - "emoji-picker-element": "^1.18" + "emoji-picker-element": "^1.18", + "cbor-x": "^1.5.9" } }, "brython_map": { @@ -33,6 +34,10 @@ "import_type": "module", "path": "emoji-picker-element/picker.js", "export": [] + }, + "cbor-x": { + "path": "cbor-x/dist/index.min.js", + "export": ["CBOR"] } } } diff -r d07838fc9d99 -r 6feac4a25e60 libervia/web/pages/calls/_browser/__init__.py --- a/libervia/web/pages/calls/_browser/__init__.py Sat May 11 13:57:49 2024 +0200 +++ b/libervia/web/pages/calls/_browser/__init__.py Sat May 11 14:02:22 2024 +0200 @@ -4,7 +4,7 @@ from browser import aio, console as log, document, window from cache import cache import dialog -from javascript import JSObject +from javascript import JSObject, NULL from jid import JID from jid_search import JidSearch import loading @@ -27,7 +27,8 @@ ) AUDIO = "audio" VIDEO = "video" -ALLOWED_CALL_MODES = {AUDIO, VIDEO} +REMOTE = "remote-control" +ALLOWED_CALL_MODES = {AUDIO, VIDEO, REMOTE} INACTIVE_CLASS = "inactive" MUTED_CLASS = "muted" SCREEN_OFF_CLASS = "screen-off" @@ -107,6 +108,9 @@ ".click-to-audio": lambda evt, item: aio.run( self.on_entity_action(evt, AUDIO, item) ), + ".click-to-remote-control": lambda evt, item: aio.run( + self.on_entity_action(evt, REMOTE, item) + ), }, }, ) @@ -155,13 +159,28 @@ if mode in ALLOWED_CALL_MODES: if self._call_mode == mode: return + log.debug("Switching to {mode} call mode.") self._call_mode = mode - with_video = mode == VIDEO - for elt in self.call_box_elt.select(".is-video-only"): - if with_video: + selector = ".is-video-only, .is-not-remote" + for elt in self.call_box_elt.select(selector): + if mode == VIDEO: + # In video, all elements are visible. elt.classList.remove("is-hidden") + elif mode == AUDIO: + # In audio, we hide video-only elements. + if elt.classList.contains("is-video-only"): + elt.classList.add("is-hidden") + else: + elt.classList.remove("is-hidden") + elif mode == REMOTE: + # In remote, we show all video element, except if they are + # `is-not-remote` + if elt.classList.contains("is-not-remote"): + elt.classList.add("is-hidden") + else: + elt.classList.remove("is-hidden") else: - elt.classList.add("is-hidden") + raise Exception("This line should never be reached.") else: raise ValueError("Invalid call mode") @@ -190,7 +209,10 @@ @param profile: Profile associated with the action """ action_data = json.loads(action_data_s) - if action_data.get("type") == "confirm" and action_data.get("subtype") == "file": + if ( + action_data.get("type") in ("confirm", "not_in_roster_leak") + and action_data.get("subtype") == "file" + ): aio.run(self.on_file_preflight(action_data, action_id)) elif action_data.get("type") == "file": aio.run(self.on_file_proposal(action_data, action_id)) @@ -286,7 +308,7 @@ # TODO: Check if any other frontend is connected for this profile, and refuse # the file if none is. return - if action_data.get("file_accepted", False): + if action_data.get("pre_accepted", False): # File proposal has already been accepted in preflight. accepted = True else: @@ -472,13 +494,24 @@ btn_elt.classList.remove(INACTIVE_CLASS, MUTED_CLASS, "is-warning") btn_elt.classList.add("is-success") - async def make_call(self, audio: bool = True, video: bool = True) -> None: + async def make_call( + self, + audio: bool = True, + video: bool = True, + remote: bool = False + ) -> None: """Start a WebRTC call @param audio: True if an audio flux is required @param video: True if a video flux is required + @param remote: True if this is a Remote Control session. """ - self.call_mode = VIDEO if video else AUDIO + if remote: + self.call_mode = REMOTE + elif video: + self.call_mode = VIDEO + else: + self.call_mode = AUDIO try: callee_jid = JID(self.search_elt.value.strip()) if not callee_jid.is_valid: @@ -495,7 +528,12 @@ self.set_avatar(callee_jid) self.switch_mode("call") - await self.webrtc.make_call(callee_jid, audio, video) + if remote: + await self.webrtc.start_remote_control( + callee_jid, audio, video + ) + else: + await self.webrtc.make_call(callee_jid, audio, video) async def end_call(self, data: dict) -> None: """Stop streaming and clean instance""" @@ -612,18 +650,17 @@ @param fullscreen: if set, determine the fullscreen state; otherwise, the fullscreen mode will be toggled. """ - do_fullscreen = ( - document.fullscreenElement is None if fullscreen is None else fullscreen - ) + if fullscreen is None: + fullscreen = document.fullscreenElement is NULL try: - if do_fullscreen: - if document.fullscreenElement is None: + if fullscreen: + if document.fullscreenElement is NULL: self.call_box_elt.requestFullscreen() document["full_screen_btn"].classList.add("is-hidden") document["exit_full_screen_btn"].classList.remove("is-hidden") else: - if document.fullscreenElement is not None: + if document.fullscreenElement is not NULL: document.exitFullscreen() document["full_screen_btn"].classList.remove("is-hidden") document["exit_full_screen_btn"].classList.add("is-hidden") @@ -711,11 +748,15 @@ evt.stopPropagation() if action == "menu": evt.currentTarget.parent.classList.toggle("is-active") - elif action in (VIDEO, AUDIO): + elif action in (VIDEO, AUDIO, REMOTE): self.search_elt.value = item["entity"] # we want the dropdown to be inactive evt.currentTarget.closest(".dropdown").classList.remove("is-active") - await self.make_call(video=action == VIDEO) + if action == REMOTE: + await self.make_call(audio=False, video=True, remote=True) + + else: + await self.make_call(video=action == VIDEO) CallUI() diff -r d07838fc9d99 -r 6feac4a25e60 libervia/web/pages/calls/_browser/webrtc.py --- a/libervia/web/pages/calls/_browser/webrtc.py Sat May 11 13:57:49 2024 +0200 +++ b/libervia/web/pages/calls/_browser/webrtc.py Sat May 11 14:02:22 2024 +0200 @@ -2,10 +2,11 @@ import re from bridge import AsyncBridge as Bridge -from browser import aio, console as log, document, window +from browser import aio, console as log, document, window, DOMNode import dialog -from javascript import JSObject +from javascript import JSObject, NULL import jid +from js_modules.cbor_x import CBOR log.warning = log.warn profile = window.profile or "" @@ -89,6 +90,189 @@ aio.run(bridge.call_end(self.session_id, "")) +class RemoteControler: + """Send input events to controlled device""" + + def __init__( + self, + session_id: str, + capture_elt: DOMNode, + data_channel: JSObject, + simulate_mouse: bool = True + ) -> None: + """Initialize a RemoteControler instance. + + @param session_id: ID of the Jingle Session + @param capture_elt: element where the input events are captured. + @param data_channel: WebRTCDataChannel instance linking to controlled device. + @simulate_mouse: if True, touch event will be converted to mouse events. + """ + self.session_id = session_id + self.capture_elt = capture_elt + self.capture_elt.bind("click", self._on_capture_elt_click) + self.data_channel = data_channel + data_channel.bind("open", self._on_open) + self.simulate_mouse = simulate_mouse + self.last_mouse_position = (0, 0) + + def _on_capture_elt_click(self, __): + self.capture_elt.focus() + + def _on_open(self, __): + log.info(f"Data channel open, starting to send inputs.") + self.start_capture() + + def start_capture(self) -> None: + """Start capturing input events to send them to the controlled device.""" + for event_name in [ + "mousedown", + "mouseup", + "mousemove", + "keydown", + "keyup", + "touchstart", + "touchend", + "touchmove", + "wheel", + ]: + self.capture_elt.bind(event_name, self._send_event) + self.capture_elt.focus() + + def get_stream_coordinates(self, client_x: float, client_y: float) -> tuple[float, float]: + """Calculate coordinates relative to the actual video stream. + + This method calculates the coordinates relative to the video content inside the