comparison libervia/cli/call_gui.py @ 4206:0f8ea0768a3b

cli (call): implement GUI output: ``call`` commands now handle various output. Beside the original one (now named ``simple``), a new ``gui`` one display a full featured GUI (make with Qt). PyQt 6 or more needs to be installed. rel 427
author Goffi <goffi@goffi.org>
date Sun, 11 Feb 2024 23:20:24 +0100
parents
children 9218d4331bb2
comparison
equal deleted inserted replaced
4205:17a8168966f9 4206:0f8ea0768a3b
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