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 ) |
|
50 from gi.repository import Gst |
|
51 |
|
52 from libervia.backend.core.i18n import _ |
|
53 from libervia.frontends.tools import aio, display_servers, webrtc |
|
54 |
|
55 |
|
56 ICON_SIZE = QSize(45, 45) |
|
57 BUTTON_SIZE = QSize(50, 50) |
|
58 running = False |
|
59 |
|
60 |
|
61 class ActivableButton(QPushButton): |
|
62 def __init__(self, text, parent=None): |
|
63 super().__init__(parent) |
|
64 self._activated = True |
|
65 self._activated_colour = "#47c68e" |
|
66 self._deactivated_colour = "#ffe089" |
|
67 self._line_colour = "#ff0000" |
|
68 self._update_background_color() |
|
69 |
|
70 @property |
|
71 def activated_colour(self) -> str: |
|
72 return self._activated_colour |
|
73 |
|
74 @activated_colour.setter |
|
75 def activated_colour(self, new_colour: str) -> None: |
|
76 if new_colour != self._activated_colour: |
|
77 self._activated_colour = new_colour |
|
78 self._update_background_color() |
|
79 |
|
80 @property |
|
81 def deactivated_colour(self) -> str: |
|
82 return self._deactivated_colour |
|
83 |
|
84 @deactivated_colour.setter |
|
85 def deactivated_colour(self, new_colour: str) -> None: |
|
86 if new_colour != self._deactivated_colour: |
|
87 self._deactivated_colour = new_colour |
|
88 self._update_background_color() |
|
89 |
|
90 @property |
|
91 def line_colour(self) -> str: |
|
92 return self._line_colour |
|
93 |
|
94 @line_colour.setter |
|
95 def line_colour(self, new_colour: str) -> None: |
|
96 if new_colour != self._line_colour: |
|
97 self._line_colour = new_colour |
|
98 self.update() |
|
99 |
|
100 def paintEvent(self, a0): |
|
101 super().paintEvent(a0) |
|
102 |
|
103 if not self._activated: |
|
104 painter = QPainter(self) |
|
105 painter.setRenderHint(QPainter.RenderHint.Antialiasing) |
|
106 |
|
107 line_color = QColor(self._line_colour) |
|
108 line_width = 4 |
|
109 cap_style = Qt.PenCapStyle.RoundCap |
|
110 |
|
111 pen = QPen(line_color, line_width, Qt.PenStyle.SolidLine) |
|
112 pen.setCapStyle(cap_style) |
|
113 painter.setPen(pen) |
|
114 |
|
115 margin = 5 |
|
116 start_point = QPoint(margin, self.height() - margin) |
|
117 end_point = QPoint(self.width() - margin, margin) |
|
118 painter.drawLine(start_point, end_point) |
|
119 |
|
120 def _update_background_color(self): |
|
121 if self._activated: |
|
122 self.setStyleSheet(f"background-color: {self._activated_colour};") |
|
123 else: |
|
124 self.setStyleSheet(f"background-color: {self._deactivated_colour};") |
|
125 self.update() |
|
126 |
|
127 @property |
|
128 def activated(self): |
|
129 return self._activated |
|
130 |
|
131 @activated.setter |
|
132 def activated(self, value): |
|
133 if self._activated != value: |
|
134 self._activated = value |
|
135 self._update_background_color() |
|
136 |
|
137 |
|
138 class X11DesktopScreenDialog(QDialog): |
|
139 def __init__(self, windows_data, parent=None): |
|
140 super().__init__(parent) |
|
141 self.__a_result = asyncio.get_running_loop().create_future() |
|
142 self.setWindowTitle("Please select a window to share:") |
|
143 self.resize(400, 300) |
|
144 self.list_widget = QListWidget(self) |
|
145 for window_data in windows_data: |
|
146 item = QListWidgetItem(window_data["title"]) |
|
147 item.setData(Qt.ItemDataRole.UserRole, window_data) |
|
148 self.list_widget.addItem(item) |
|
149 |
|
150 self.buttonBox = QDialogButtonBox( |
|
151 QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel |
|
152 ) |
|
153 self.buttonBox.accepted.connect(self.on_accepted) |
|
154 self.buttonBox.rejected.connect(self.on_rejected) |
|
155 |
|
156 layout = QVBoxLayout(self) |
|
157 layout.addWidget(self.list_widget) |
|
158 layout.addWidget(self.buttonBox) |
|
159 |
|
160 def get_selected_window(self) -> dict | None: |
|
161 selectedItem = self.list_widget.currentItem() |
|
162 if selectedItem: |
|
163 return selectedItem.data(Qt.ItemDataRole.UserRole) |
|
164 return None |
|
165 |
|
166 def on_accepted(self): |
|
167 self.__a_result.set_result(self.get_selected_window()) |
|
168 self.close() |
|
169 |
|
170 def on_rejected(self): |
|
171 self.__a_result.set_result(None) |
|
172 self.close() |
|
173 |
|
174 def closeEvent(self, a0): |
|
175 super().closeEvent(a0) |
|
176 if not self.__a_result.done(): |
|
177 self.__a_result.set_result(None) |
|
178 |
|
179 async def a_show(self) -> dict | None: |
|
180 self.open() |
|
181 return await self.__a_result |
|
182 |
|
183 |
|
184 class AVCallGUI(QMainWindow): |
|
185 def __init__(self, host, icons_path: Path): |
|
186 super().__init__() |
|
187 self.host = host |
|
188 self.webrtc_call = None |
|
189 self.icons_path = icons_path |
|
190 self.initUI() |
|
191 |
|
192 @staticmethod |
|
193 async def run_qt_loop(app): |
|
194 while running: |
|
195 app.sendPostedEvents() |
|
196 await asyncio.sleep(0.1) |
|
197 |
|
198 @classmethod |
|
199 async def run(cls, parent, call_data, icons_path: Path): |
|
200 """Run PyQt loop and show the app""" |
|
201 print("Starting GUI...") |
|
202 app = QApplication([]) |
|
203 av_call_gui = cls(parent.host, icons_path) |
|
204 av_call_gui.show() |
|
205 webrtc_call = await parent.make_webrtc_call( |
|
206 call_data, |
|
207 sinks=webrtc.SINKS_APP, |
|
208 appsink_data=webrtc.AppSinkData( |
|
209 local_video_cb=partial(av_call_gui.on_new_sample, video_stream="local"), |
|
210 remote_video_cb=partial(av_call_gui.on_new_sample, video_stream="remote"), |
|
211 ), |
|
212 ) |
|
213 av_call_gui.webrtc_call = webrtc_call |
|
214 |
|
215 global running |
|
216 running = True |
|
217 await cls.run_qt_loop(app) |
|
218 await parent.host.a_quit() |
|
219 |
|
220 def initUI(self): |
|
221 self.setGeometry(100, 100, 800, 600) |
|
222 self.setWindowTitle("Call") |
|
223 |
|
224 # Main layouts |
|
225 self.background_widget = QWidget(self) |
|
226 self.foreground_widget = QWidget(self) |
|
227 self.setCentralWidget(self.background_widget) |
|
228 back_layout = QVBoxLayout(self.background_widget) |
|
229 front_layout = QVBoxLayout(self.foreground_widget) |
|
230 |
|
231 # Remote video |
|
232 self.remote_video_widget = QLabel(self) |
|
233 self.remote_video_widget.setSizePolicy( |
|
234 QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored |
|
235 ) |
|
236 back_layout.addWidget(self.remote_video_widget) |
|
237 |
|
238 # Fullscreen button |
|
239 fullscreen_layout = QHBoxLayout() |
|
240 front_layout.addLayout(fullscreen_layout) |
|
241 fullscreen_layout.addStretch() |
|
242 self.fullscreen_btn = QPushButton("", self) |
|
243 self.fullscreen_btn.setFixedSize(BUTTON_SIZE) |
|
244 self.fullscreen_icon_normal = QIcon(str(self.icons_path / "resize-full.svg")) |
|
245 self.fullscreen_icon_fullscreen = QIcon(str(self.icons_path / "resize-small.svg")) |
|
246 self.fullscreen_btn.setIcon(self.fullscreen_icon_normal) |
|
247 self.fullscreen_btn.setIconSize(ICON_SIZE) |
|
248 self.fullscreen_btn.clicked.connect(self.toggle_fullscreen) |
|
249 fullscreen_layout.addWidget(self.fullscreen_btn) |
|
250 |
|
251 # Control buttons |
|
252 self.control_buttons_layout = QHBoxLayout() |
|
253 self.control_buttons_layout.setSpacing(40) |
|
254 self.toggle_video_btn = cast( |
|
255 ActivableButton, self.add_control_button("videocam", self.toggle_video) |
|
256 ) |
|
257 self.toggle_audio_btn = cast( |
|
258 ActivableButton, self.add_control_button("volume-up", self.toggle_audio) |
|
259 ) |
|
260 self.share_desktop_btn = cast( |
|
261 ActivableButton, self.add_control_button("desktop", self.share_desktop) |
|
262 ) |
|
263 self.share_desktop_btn.deactivated_colour = "#47c68e" |
|
264 self.share_desktop_btn.activated_colour = "#f24468" |
|
265 self.share_desktop_btn.line_colour = "#666666" |
|
266 self.share_desktop_btn.activated = False |
|
267 self.hang_up_btn = self.add_control_button( |
|
268 "phone", self.hang_up, rotate=135, background="red", activable=False |
|
269 ) |
|
270 |
|
271 controls_widget = QWidget(self) |
|
272 controls_widget.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) |
|
273 controls_widget.setLayout(self.control_buttons_layout) |
|
274 front_layout.addStretch() |
|
275 |
|
276 bottom_layout = QHBoxLayout() |
|
277 bottom_layout.addStretch() |
|
278 front_layout.addLayout(bottom_layout) |
|
279 bottom_layout.addWidget(controls_widget, alignment=Qt.AlignmentFlag.AlignBottom) |
|
280 |
|
281 # Local video feedback |
|
282 bottom_layout.addStretch() |
|
283 self.local_video_widget = QLabel(self) |
|
284 bottom_layout.addWidget(self.local_video_widget) |
|
285 |
|
286 # we update sizes on resize event |
|
287 self.background_widget.resizeEvent = self.adjust_sizes |
|
288 self.adjust_sizes() |
|
289 |
|
290 def add_control_button( |
|
291 self, |
|
292 icon_name: str, |
|
293 callback: Callable, |
|
294 rotate: float | None = None, |
|
295 background: str | None = None, |
|
296 activable: bool = True, |
|
297 ) -> QPushButton | ActivableButton: |
|
298 if activable: |
|
299 button = ActivableButton("", self) |
|
300 else: |
|
301 button = QPushButton("", self) |
|
302 icon_path = self.icons_path / f"{icon_name}.svg" |
|
303 button.setIcon(QIcon(str(icon_path))) |
|
304 button.setIconSize(ICON_SIZE) |
|
305 button.setFixedSize(BUTTON_SIZE) |
|
306 if rotate is not None: |
|
307 pixmap = button.icon().pixmap(ICON_SIZE) |
|
308 transform = QTransform() |
|
309 transform.rotate(rotate) |
|
310 rotated_pixmap = pixmap.transformed(transform) |
|
311 button.setIcon(QIcon(rotated_pixmap)) |
|
312 if background: |
|
313 button.setStyleSheet(f"background-color: {background};") |
|
314 button.clicked.connect(callback) |
|
315 self.control_buttons_layout.addWidget(button) |
|
316 return button |
|
317 |
|
318 def adjust_sizes(self, a0: QResizeEvent | None = None) -> None: |
|
319 self.foreground_widget.setGeometry( |
|
320 0, 0, self.background_widget.width(), self.background_widget.height() |
|
321 ) |
|
322 self.local_video_widget.setFixedSize(QSize(self.width() // 3, self.height() // 3)) |
|
323 if a0 is not None: |
|
324 super().resizeEvent(a0) |
|
325 |
|
326 def on_new_sample(self, video_sink, video_stream: str) -> bool: |
|
327 sample = video_sink.emit("pull-sample") |
|
328 if sample is None: |
|
329 return False |
|
330 |
|
331 video_pad = video_sink.get_static_pad("sink") |
|
332 assert video_pad is not None |
|
333 s = video_pad.get_current_caps().get_structure(0) |
|
334 stream_size = (s.get_value("width"), s.get_value("height")) |
|
335 self.host.loop.loop.call_soon_threadsafe( |
|
336 self.update_sample, sample, stream_size, video_stream |
|
337 ) |
|
338 |
|
339 return False |
|
340 |
|
341 def update_sample(self, sample, stream_size, video_stream: str) -> None: |
|
342 if sample is None: |
|
343 return |
|
344 |
|
345 video_widget = ( |
|
346 self.remote_video_widget |
|
347 if video_stream == "remote" |
|
348 else self.local_video_widget |
|
349 ) |
|
350 |
|
351 buf = sample.get_buffer() |
|
352 result, mapinfo = buf.map(Gst.MapFlags.READ) |
|
353 if result: |
|
354 buffer = mapinfo.data |
|
355 width, height = stream_size |
|
356 qimage = QImage(buffer, width, height, QImage.Format.Format_RGB888) |
|
357 pixmap = QPixmap.fromImage(qimage).scaled( |
|
358 QSize(video_widget.width(), video_widget.height()), |
|
359 Qt.AspectRatioMode.KeepAspectRatio, |
|
360 ) |
|
361 video_widget.setPixmap(pixmap) |
|
362 video_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) |
|
363 |
|
364 buf.unmap(mapinfo) |
|
365 |
|
366 def toggle_fullscreen(self): |
|
367 fullscreen = not self.isFullScreen() |
|
368 if fullscreen: |
|
369 self.fullscreen_btn.setIcon(self.fullscreen_icon_fullscreen) |
|
370 self.showFullScreen() |
|
371 else: |
|
372 self.fullscreen_btn.setIcon(self.fullscreen_icon_normal) |
|
373 self.showNormal() |
|
374 |
|
375 def closeEvent(self, a0: QCloseEvent) -> None: |
|
376 super().closeEvent(a0) |
|
377 global running |
|
378 running = False |
|
379 |
|
380 def toggle_video(self): |
|
381 assert self.webrtc_call is not None |
|
382 self.webrtc_call.webrtc.video_muted = not self.webrtc_call.webrtc.video_muted |
|
383 self.toggle_video_btn.activated = not self.webrtc_call.webrtc.video_muted |
|
384 |
|
385 def toggle_audio(self): |
|
386 assert self.webrtc_call is not None |
|
387 self.webrtc_call.webrtc.audio_muted = not self.webrtc_call.webrtc.audio_muted |
|
388 self.toggle_audio_btn.activated = not self.webrtc_call.webrtc.audio_muted |
|
389 |
|
390 def share_desktop(self): |
|
391 assert self.webrtc_call is not None |
|
392 if self.webrtc_call.webrtc.desktop_sharing: |
|
393 self.webrtc_call.webrtc.desktop_sharing = False |
|
394 self.share_desktop_btn.activated = False |
|
395 elif display_servers.detect() == display_servers.X11: |
|
396 aio.run_async(self.show_X11_screen_dialog()) |
|
397 else: |
|
398 self.webrtc_call.webrtc.desktop_sharing = True |
|
399 |
|
400 def hang_up(self): |
|
401 self.close() |
|
402 |
|
403 async def show_X11_screen_dialog(self): |
|
404 assert self.webrtc_call is not None |
|
405 windows_data = display_servers.x11_list_windows() |
|
406 dialog = X11DesktopScreenDialog(windows_data, self) |
|
407 selected = await dialog.a_show() |
|
408 if selected is not None: |
|
409 xid = selected["id"] |
|
410 self.webrtc_call.webrtc.desktop_sharing_data = {"xid": xid} |
|
411 self.webrtc_call.webrtc.desktop_sharing = True |
|
412 self.share_desktop_btn.activated = True |