Mercurial > libervia-backend
annotate libervia/cli/call_gui.py @ 4309:b56b1eae7994
component email gateway: add multicasting:
XEP-0033 multicasting is now supported both for incoming and outgoing messages. XEP-0033
metadata are converted to suitable Email headers and vice versa.
Email address and JID are both supported, and delivery is done by the gateway when
suitable on incoming messages.
rel 450
author | Goffi <goffi@goffi.org> |
---|---|
date | Thu, 26 Sep 2024 16:12:01 +0200 |
parents | 0d7bb4df2343 |
children | 00837fa13e5a |
rev | line source |
---|---|
4206 | 1 #!/usr/bin/env python3 |
2 | |
3 # Libervia CLI | |
4 # Copyright (C) 2009-2024 Jérôme Poisson (goffi@goffi.org) | |
5 | |
6 # This program is free software: you can redistribute it and/or modify | |
7 # it under the terms of the GNU Affero General Public License as published by | |
8 # the Free Software Foundation, either version 3 of the License, or | |
9 # (at your option) any later version. | |
10 | |
11 # This program is distributed in the hope that it will be useful, | |
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
14 # GNU Affero General Public License for more details. | |
15 | |
16 # You should have received a copy of the GNU Affero General Public License | |
17 # along with this program. If not, see <http://www.gnu.org/licenses/>. | |
18 | |
19 import asyncio | |
20 from functools import partial | |
21 from pathlib import Path | |
22 from typing import Callable, cast | |
23 | |
24 from PyQt6.QtCore import QPoint, QSize, Qt | |
25 from PyQt6.QtGui import ( | |
26 QCloseEvent, | |
27 QColor, | |
28 QIcon, | |
29 QImage, | |
30 QPainter, | |
31 QPen, | |
32 QPixmap, | |
33 QResizeEvent, | |
34 QTransform, | |
35 ) | |
36 from PyQt6.QtWidgets import ( | |
37 QApplication, | |
38 QDialog, | |
39 QDialogButtonBox, | |
40 QHBoxLayout, | |
41 QLabel, | |
42 QListWidget, | |
43 QListWidgetItem, | |
44 QMainWindow, | |
45 QPushButton, | |
46 QSizePolicy, | |
47 QVBoxLayout, | |
48 QWidget, | |
49 ) | |
4210
9218d4331bb2
cli (call): `tui` output implementation:
Goffi <goffi@goffi.org>
parents:
4206
diff
changeset
|
50 import gi |
9218d4331bb2
cli (call): `tui` output implementation:
Goffi <goffi@goffi.org>
parents:
4206
diff
changeset
|
51 |
9218d4331bb2
cli (call): `tui` output implementation:
Goffi <goffi@goffi.org>
parents:
4206
diff
changeset
|
52 from libervia.backend.core.i18n import _ |
9218d4331bb2
cli (call): `tui` output implementation:
Goffi <goffi@goffi.org>
parents:
4206
diff
changeset
|
53 from libervia.frontends.tools import aio, display_servers, webrtc |
4270
0d7bb4df2343
Reformatted code base using black.
Goffi <goffi@goffi.org>
parents:
4240
diff
changeset
|
54 |
0d7bb4df2343
Reformatted code base using black.
Goffi <goffi@goffi.org>
parents:
4240
diff
changeset
|
55 gi.require_versions({"Gst": "1.0", "GstWebRTC": "1.0"}) |
4206 | 56 from gi.repository import Gst |
57 | |
58 | |
59 ICON_SIZE = QSize(45, 45) | |
60 BUTTON_SIZE = QSize(50, 50) | |
61 running = False | |
62 | |
63 | |
4233
d01b8d002619
cli (call, file), frontends: implement webRTC data channel transfer:
Goffi <goffi@goffi.org>
parents:
4210
diff
changeset
|
64 aio.install_glib_asyncio_iteration() |
d01b8d002619
cli (call, file), frontends: implement webRTC data channel transfer:
Goffi <goffi@goffi.org>
parents:
4210
diff
changeset
|
65 |
d01b8d002619
cli (call, file), frontends: implement webRTC data channel transfer:
Goffi <goffi@goffi.org>
parents:
4210
diff
changeset
|
66 |
4206 | 67 class ActivableButton(QPushButton): |
68 def __init__(self, text, parent=None): | |
69 super().__init__(parent) | |
70 self._activated = True | |
71 self._activated_colour = "#47c68e" | |
72 self._deactivated_colour = "#ffe089" | |
73 self._line_colour = "#ff0000" | |
74 self._update_background_color() | |
75 | |
76 @property | |
77 def activated_colour(self) -> str: | |
78 return self._activated_colour | |
79 | |
80 @activated_colour.setter | |
81 def activated_colour(self, new_colour: str) -> None: | |
82 if new_colour != self._activated_colour: | |
83 self._activated_colour = new_colour | |
84 self._update_background_color() | |
85 | |
86 @property | |
87 def deactivated_colour(self) -> str: | |
88 return self._deactivated_colour | |
89 | |
90 @deactivated_colour.setter | |
91 def deactivated_colour(self, new_colour: str) -> None: | |
92 if new_colour != self._deactivated_colour: | |
93 self._deactivated_colour = new_colour | |
94 self._update_background_color() | |
95 | |
96 @property | |
97 def line_colour(self) -> str: | |
98 return self._line_colour | |
99 | |
100 @line_colour.setter | |
101 def line_colour(self, new_colour: str) -> None: | |
102 if new_colour != self._line_colour: | |
103 self._line_colour = new_colour | |
104 self.update() | |
105 | |
106 def paintEvent(self, a0): | |
107 super().paintEvent(a0) | |
108 | |
109 if not self._activated: | |
110 painter = QPainter(self) | |
111 painter.setRenderHint(QPainter.RenderHint.Antialiasing) | |
112 | |
113 line_color = QColor(self._line_colour) | |
114 line_width = 4 | |
115 cap_style = Qt.PenCapStyle.RoundCap | |
116 | |
117 pen = QPen(line_color, line_width, Qt.PenStyle.SolidLine) | |
118 pen.setCapStyle(cap_style) | |
119 painter.setPen(pen) | |
120 | |
121 margin = 5 | |
122 start_point = QPoint(margin, self.height() - margin) | |
123 end_point = QPoint(self.width() - margin, margin) | |
124 painter.drawLine(start_point, end_point) | |
125 | |
126 def _update_background_color(self): | |
127 if self._activated: | |
128 self.setStyleSheet(f"background-color: {self._activated_colour};") | |
129 else: | |
130 self.setStyleSheet(f"background-color: {self._deactivated_colour};") | |
131 self.update() | |
132 | |
133 @property | |
134 def activated(self): | |
135 return self._activated | |
136 | |
137 @activated.setter | |
138 def activated(self, value): | |
139 if self._activated != value: | |
140 self._activated = value | |
141 self._update_background_color() | |
142 | |
143 | |
144 class X11DesktopScreenDialog(QDialog): | |
145 def __init__(self, windows_data, parent=None): | |
146 super().__init__(parent) | |
147 self.__a_result = asyncio.get_running_loop().create_future() | |
148 self.setWindowTitle("Please select a window to share:") | |
149 self.resize(400, 300) | |
150 self.list_widget = QListWidget(self) | |
151 for window_data in windows_data: | |
152 item = QListWidgetItem(window_data["title"]) | |
153 item.setData(Qt.ItemDataRole.UserRole, window_data) | |
154 self.list_widget.addItem(item) | |
155 | |
156 self.buttonBox = QDialogButtonBox( | |
157 QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel | |
158 ) | |
159 self.buttonBox.accepted.connect(self.on_accepted) | |
160 self.buttonBox.rejected.connect(self.on_rejected) | |
161 | |
162 layout = QVBoxLayout(self) | |
163 layout.addWidget(self.list_widget) | |
164 layout.addWidget(self.buttonBox) | |
165 | |
166 def get_selected_window(self) -> dict | None: | |
167 selectedItem = self.list_widget.currentItem() | |
168 if selectedItem: | |
169 return selectedItem.data(Qt.ItemDataRole.UserRole) | |
170 return None | |
171 | |
172 def on_accepted(self): | |
173 self.__a_result.set_result(self.get_selected_window()) | |
174 self.close() | |
175 | |
176 def on_rejected(self): | |
177 self.__a_result.set_result(None) | |
178 self.close() | |
179 | |
180 def closeEvent(self, a0): | |
181 super().closeEvent(a0) | |
182 if not self.__a_result.done(): | |
183 self.__a_result.set_result(None) | |
184 | |
185 async def a_show(self) -> dict | None: | |
186 self.open() | |
187 return await self.__a_result | |
188 | |
189 | |
4210
9218d4331bb2
cli (call): `tui` output implementation:
Goffi <goffi@goffi.org>
parents:
4206
diff
changeset
|
190 class AVCallUI(QMainWindow): |
4206 | 191 def __init__(self, host, icons_path: Path): |
192 super().__init__() | |
193 self.host = host | |
194 self.webrtc_call = None | |
195 self.icons_path = icons_path | |
196 self.initUI() | |
197 | |
198 @staticmethod | |
199 async def run_qt_loop(app): | |
200 while running: | |
201 app.sendPostedEvents() | |
202 await asyncio.sleep(0.1) | |
203 | |
204 @classmethod | |
4210
9218d4331bb2
cli (call): `tui` output implementation:
Goffi <goffi@goffi.org>
parents:
4206
diff
changeset
|
205 async def run(cls, parent, call_data): |
4206 | 206 """Run PyQt loop and show the app""" |
4210
9218d4331bb2
cli (call): `tui` output implementation:
Goffi <goffi@goffi.org>
parents:
4206
diff
changeset
|
207 media_dir = Path(await parent.host.bridge.config_get("", "media_dir")) |
9218d4331bb2
cli (call): `tui` output implementation:
Goffi <goffi@goffi.org>
parents:
4206
diff
changeset
|
208 icons_path = media_dir / "fonts/fontello/svg" |
4206 | 209 app = QApplication([]) |
210 av_call_gui = cls(parent.host, icons_path) | |
211 av_call_gui.show() | |
4233
d01b8d002619
cli (call, file), frontends: implement webRTC data channel transfer:
Goffi <goffi@goffi.org>
parents:
4210
diff
changeset
|
212 webrtc_call = await webrtc.WebRTCCall.make_webrtc_call( |
d01b8d002619
cli (call, file), frontends: implement webRTC data channel transfer:
Goffi <goffi@goffi.org>
parents:
4210
diff
changeset
|
213 parent.host.bridge, |
4210
9218d4331bb2
cli (call): `tui` output implementation:
Goffi <goffi@goffi.org>
parents:
4206
diff
changeset
|
214 parent.profile, |
4206 | 215 call_data, |
4240
79c8a70e1813
backend, frontend: prepare remote control:
Goffi <goffi@goffi.org>
parents:
4233
diff
changeset
|
216 sinks_data=webrtc.SinksApp( |
4206 | 217 local_video_cb=partial(av_call_gui.on_new_sample, video_stream="local"), |
218 remote_video_cb=partial(av_call_gui.on_new_sample, video_stream="remote"), | |
219 ), | |
4233
d01b8d002619
cli (call, file), frontends: implement webRTC data channel transfer:
Goffi <goffi@goffi.org>
parents:
4210
diff
changeset
|
220 # we want to be sure that call is ended if user presses `Ctrl + c` or anything |
d01b8d002619
cli (call, file), frontends: implement webRTC data channel transfer:
Goffi <goffi@goffi.org>
parents:
4210
diff
changeset
|
221 # else stops the session. |
d01b8d002619
cli (call, file), frontends: implement webRTC data channel transfer:
Goffi <goffi@goffi.org>
parents:
4210
diff
changeset
|
222 on_call_setup_cb=lambda sid, profile: parent.host.add_on_quit_callback( |
d01b8d002619
cli (call, file), frontends: implement webRTC data channel transfer:
Goffi <goffi@goffi.org>
parents:
4210
diff
changeset
|
223 parent.host.bridge.call_end, sid, "", profile |
d01b8d002619
cli (call, file), frontends: implement webRTC data channel transfer:
Goffi <goffi@goffi.org>
parents:
4210
diff
changeset
|
224 ), |
d01b8d002619
cli (call, file), frontends: implement webRTC data channel transfer:
Goffi <goffi@goffi.org>
parents:
4210
diff
changeset
|
225 on_call_ended_cb=lambda sid, profile: parent.host.a_quit(), |
4206 | 226 ) |
227 av_call_gui.webrtc_call = webrtc_call | |
228 | |
229 global running | |
230 running = True | |
231 await cls.run_qt_loop(app) | |
232 await parent.host.a_quit() | |
233 | |
234 def initUI(self): | |
235 self.setGeometry(100, 100, 800, 600) | |
236 self.setWindowTitle("Call") | |
237 | |
238 # Main layouts | |
239 self.background_widget = QWidget(self) | |
240 self.foreground_widget = QWidget(self) | |
241 self.setCentralWidget(self.background_widget) | |
242 back_layout = QVBoxLayout(self.background_widget) | |
243 front_layout = QVBoxLayout(self.foreground_widget) | |
244 | |
245 # Remote video | |
246 self.remote_video_widget = QLabel(self) | |
247 self.remote_video_widget.setSizePolicy( | |
248 QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored | |
249 ) | |
250 back_layout.addWidget(self.remote_video_widget) | |
251 | |
252 # Fullscreen button | |
253 fullscreen_layout = QHBoxLayout() | |
254 front_layout.addLayout(fullscreen_layout) | |
255 fullscreen_layout.addStretch() | |
256 self.fullscreen_btn = QPushButton("", self) | |
257 self.fullscreen_btn.setFixedSize(BUTTON_SIZE) | |
258 self.fullscreen_icon_normal = QIcon(str(self.icons_path / "resize-full.svg")) | |
259 self.fullscreen_icon_fullscreen = QIcon(str(self.icons_path / "resize-small.svg")) | |
260 self.fullscreen_btn.setIcon(self.fullscreen_icon_normal) | |
261 self.fullscreen_btn.setIconSize(ICON_SIZE) | |
262 self.fullscreen_btn.clicked.connect(self.toggle_fullscreen) | |
263 fullscreen_layout.addWidget(self.fullscreen_btn) | |
264 | |
265 # Control buttons | |
266 self.control_buttons_layout = QHBoxLayout() | |
267 self.control_buttons_layout.setSpacing(40) | |
268 self.toggle_video_btn = cast( | |
269 ActivableButton, self.add_control_button("videocam", self.toggle_video) | |
270 ) | |
271 self.toggle_audio_btn = cast( | |
272 ActivableButton, self.add_control_button("volume-up", self.toggle_audio) | |
273 ) | |
274 self.share_desktop_btn = cast( | |
275 ActivableButton, self.add_control_button("desktop", self.share_desktop) | |
276 ) | |
277 self.share_desktop_btn.deactivated_colour = "#47c68e" | |
278 self.share_desktop_btn.activated_colour = "#f24468" | |
279 self.share_desktop_btn.line_colour = "#666666" | |
280 self.share_desktop_btn.activated = False | |
281 self.hang_up_btn = self.add_control_button( | |
282 "phone", self.hang_up, rotate=135, background="red", activable=False | |
283 ) | |
284 | |
285 controls_widget = QWidget(self) | |
286 controls_widget.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) | |
287 controls_widget.setLayout(self.control_buttons_layout) | |
288 front_layout.addStretch() | |
289 | |
290 bottom_layout = QHBoxLayout() | |
291 bottom_layout.addStretch() | |
292 front_layout.addLayout(bottom_layout) | |
293 bottom_layout.addWidget(controls_widget, alignment=Qt.AlignmentFlag.AlignBottom) | |
294 | |
295 # Local video feedback | |
296 bottom_layout.addStretch() | |
297 self.local_video_widget = QLabel(self) | |
298 bottom_layout.addWidget(self.local_video_widget) | |
299 | |
300 # we update sizes on resize event | |
301 self.background_widget.resizeEvent = self.adjust_sizes | |
302 self.adjust_sizes() | |
303 | |
304 def add_control_button( | |
305 self, | |
306 icon_name: str, | |
307 callback: Callable, | |
308 rotate: float | None = None, | |
309 background: str | None = None, | |
310 activable: bool = True, | |
311 ) -> QPushButton | ActivableButton: | |
312 if activable: | |
313 button = ActivableButton("", self) | |
314 else: | |
315 button = QPushButton("", self) | |
316 icon_path = self.icons_path / f"{icon_name}.svg" | |
317 button.setIcon(QIcon(str(icon_path))) | |
318 button.setIconSize(ICON_SIZE) | |
319 button.setFixedSize(BUTTON_SIZE) | |
320 if rotate is not None: | |
321 pixmap = button.icon().pixmap(ICON_SIZE) | |
322 transform = QTransform() | |
323 transform.rotate(rotate) | |
324 rotated_pixmap = pixmap.transformed(transform) | |
325 button.setIcon(QIcon(rotated_pixmap)) | |
326 if background: | |
327 button.setStyleSheet(f"background-color: {background};") | |
328 button.clicked.connect(callback) | |
329 self.control_buttons_layout.addWidget(button) | |
330 return button | |
331 | |
332 def adjust_sizes(self, a0: QResizeEvent | None = None) -> None: | |
333 self.foreground_widget.setGeometry( | |
334 0, 0, self.background_widget.width(), self.background_widget.height() | |
335 ) | |
336 self.local_video_widget.setFixedSize(QSize(self.width() // 3, self.height() // 3)) | |
337 if a0 is not None: | |
338 super().resizeEvent(a0) | |
339 | |
340 def on_new_sample(self, video_sink, video_stream: str) -> bool: | |
341 sample = video_sink.emit("pull-sample") | |
342 if sample is None: | |
343 return False | |
344 | |
345 video_pad = video_sink.get_static_pad("sink") | |
346 assert video_pad is not None | |
347 s = video_pad.get_current_caps().get_structure(0) | |
348 stream_size = (s.get_value("width"), s.get_value("height")) | |
349 self.host.loop.loop.call_soon_threadsafe( | |
350 self.update_sample, sample, stream_size, video_stream | |
351 ) | |
352 | |
353 return False | |
354 | |
355 def update_sample(self, sample, stream_size, video_stream: str) -> None: | |
356 if sample is None: | |
357 return | |
358 | |
359 video_widget = ( | |
360 self.remote_video_widget | |
361 if video_stream == "remote" | |
362 else self.local_video_widget | |
363 ) | |
364 | |
365 buf = sample.get_buffer() | |
366 result, mapinfo = buf.map(Gst.MapFlags.READ) | |
367 if result: | |
368 buffer = mapinfo.data | |
369 width, height = stream_size | |
370 qimage = QImage(buffer, width, height, QImage.Format.Format_RGB888) | |
371 pixmap = QPixmap.fromImage(qimage).scaled( | |
372 QSize(video_widget.width(), video_widget.height()), | |
373 Qt.AspectRatioMode.KeepAspectRatio, | |
374 ) | |
375 video_widget.setPixmap(pixmap) | |
376 video_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) | |
377 | |
378 buf.unmap(mapinfo) | |
379 | |
380 def toggle_fullscreen(self): | |
381 fullscreen = not self.isFullScreen() | |
382 if fullscreen: | |
383 self.fullscreen_btn.setIcon(self.fullscreen_icon_fullscreen) | |
384 self.showFullScreen() | |
385 else: | |
386 self.fullscreen_btn.setIcon(self.fullscreen_icon_normal) | |
387 self.showNormal() | |
388 | |
4270
0d7bb4df2343
Reformatted code base using black.
Goffi <goffi@goffi.org>
parents:
4240
diff
changeset
|
389 def closeEvent(self, a0: QCloseEvent | None) -> None: |
4206 | 390 super().closeEvent(a0) |
391 global running | |
392 running = False | |
393 | |
394 def toggle_video(self): | |
395 assert self.webrtc_call is not None | |
396 self.webrtc_call.webrtc.video_muted = not self.webrtc_call.webrtc.video_muted | |
397 self.toggle_video_btn.activated = not self.webrtc_call.webrtc.video_muted | |
398 | |
399 def toggle_audio(self): | |
400 assert self.webrtc_call is not None | |
401 self.webrtc_call.webrtc.audio_muted = not self.webrtc_call.webrtc.audio_muted | |
402 self.toggle_audio_btn.activated = not self.webrtc_call.webrtc.audio_muted | |
403 | |
404 def share_desktop(self): | |
405 assert self.webrtc_call is not None | |
406 if self.webrtc_call.webrtc.desktop_sharing: | |
407 self.webrtc_call.webrtc.desktop_sharing = False | |
408 self.share_desktop_btn.activated = False | |
409 elif display_servers.detect() == display_servers.X11: | |
410 aio.run_async(self.show_X11_screen_dialog()) | |
411 else: | |
412 self.webrtc_call.webrtc.desktop_sharing = True | |
413 | |
414 def hang_up(self): | |
415 self.close() | |
416 | |
4210
9218d4331bb2
cli (call): `tui` output implementation:
Goffi <goffi@goffi.org>
parents:
4206
diff
changeset
|
417 @staticmethod |
9218d4331bb2
cli (call): `tui` output implementation:
Goffi <goffi@goffi.org>
parents:
4206
diff
changeset
|
418 def can_run(): |
9218d4331bb2
cli (call): `tui` output implementation:
Goffi <goffi@goffi.org>
parents:
4206
diff
changeset
|
419 # if a known display server is detected, we should be able to run |
9218d4331bb2
cli (call): `tui` output implementation:
Goffi <goffi@goffi.org>
parents:
4206
diff
changeset
|
420 return display_servers.detect() is not None |
9218d4331bb2
cli (call): `tui` output implementation:
Goffi <goffi@goffi.org>
parents:
4206
diff
changeset
|
421 |
4206 | 422 async def show_X11_screen_dialog(self): |
423 assert self.webrtc_call is not None | |
424 windows_data = display_servers.x11_list_windows() | |
425 dialog = X11DesktopScreenDialog(windows_data, self) | |
426 selected = await dialog.a_show() | |
427 if selected is not None: | |
428 xid = selected["id"] | |
429 self.webrtc_call.webrtc.desktop_sharing_data = {"xid": xid} | |
430 self.webrtc_call.webrtc.desktop_sharing = True | |
431 self.share_desktop_btn.activated = True |