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