diff 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
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/call_gui.py	Sun Feb 11 23:20:24 2024 +0100
@@ -0,0 +1,412 @@
+#!/usr/bin/env python3
+
+# Libervia CLI
+# Copyright (C) 2009-2024 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import asyncio
+from functools import partial
+from pathlib import Path
+from typing import Callable, cast
+
+from PyQt6.QtCore import QPoint, QSize, Qt
+from PyQt6.QtGui import (
+    QCloseEvent,
+    QColor,
+    QIcon,
+    QImage,
+    QPainter,
+    QPen,
+    QPixmap,
+    QResizeEvent,
+    QTransform,
+)
+from PyQt6.QtWidgets import (
+    QApplication,
+    QDialog,
+    QDialogButtonBox,
+    QHBoxLayout,
+    QLabel,
+    QListWidget,
+    QListWidgetItem,
+    QMainWindow,
+    QPushButton,
+    QSizePolicy,
+    QVBoxLayout,
+    QWidget,
+)
+from gi.repository import Gst
+
+from libervia.backend.core.i18n import _
+from libervia.frontends.tools import aio, display_servers, webrtc
+
+
+ICON_SIZE = QSize(45, 45)
+BUTTON_SIZE = QSize(50, 50)
+running = False
+
+
+class ActivableButton(QPushButton):
+    def __init__(self, text, parent=None):
+        super().__init__(parent)
+        self._activated = True
+        self._activated_colour = "#47c68e"
+        self._deactivated_colour = "#ffe089"
+        self._line_colour = "#ff0000"
+        self._update_background_color()
+
+    @property
+    def activated_colour(self) -> str:
+        return self._activated_colour
+
+    @activated_colour.setter
+    def activated_colour(self, new_colour: str) -> None:
+        if new_colour != self._activated_colour:
+            self._activated_colour = new_colour
+            self._update_background_color()
+
+    @property
+    def deactivated_colour(self) -> str:
+        return self._deactivated_colour
+
+    @deactivated_colour.setter
+    def deactivated_colour(self, new_colour: str) -> None:
+        if new_colour != self._deactivated_colour:
+            self._deactivated_colour = new_colour
+            self._update_background_color()
+
+    @property
+    def line_colour(self) -> str:
+        return self._line_colour
+
+    @line_colour.setter
+    def line_colour(self, new_colour: str) -> None:
+        if new_colour != self._line_colour:
+            self._line_colour = new_colour
+            self.update()
+
+    def paintEvent(self, a0):
+        super().paintEvent(a0)
+
+        if not self._activated:
+            painter = QPainter(self)
+            painter.setRenderHint(QPainter.RenderHint.Antialiasing)
+
+            line_color = QColor(self._line_colour)
+            line_width = 4
+            cap_style = Qt.PenCapStyle.RoundCap
+
+            pen = QPen(line_color, line_width, Qt.PenStyle.SolidLine)
+            pen.setCapStyle(cap_style)
+            painter.setPen(pen)
+
+            margin = 5
+            start_point = QPoint(margin, self.height() - margin)
+            end_point = QPoint(self.width() - margin, margin)
+            painter.drawLine(start_point, end_point)
+
+    def _update_background_color(self):
+        if self._activated:
+            self.setStyleSheet(f"background-color: {self._activated_colour};")
+        else:
+            self.setStyleSheet(f"background-color: {self._deactivated_colour};")
+        self.update()
+
+    @property
+    def activated(self):
+        return self._activated
+
+    @activated.setter
+    def activated(self, value):
+        if self._activated != value:
+            self._activated = value
+            self._update_background_color()
+
+
+class X11DesktopScreenDialog(QDialog):
+    def __init__(self, windows_data, parent=None):
+        super().__init__(parent)
+        self.__a_result = asyncio.get_running_loop().create_future()
+        self.setWindowTitle("Please select a window to share:")
+        self.resize(400, 300)
+        self.list_widget = QListWidget(self)
+        for window_data in windows_data:
+            item = QListWidgetItem(window_data["title"])
+            item.setData(Qt.ItemDataRole.UserRole, window_data)
+            self.list_widget.addItem(item)
+
+        self.buttonBox = QDialogButtonBox(
+            QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
+        )
+        self.buttonBox.accepted.connect(self.on_accepted)
+        self.buttonBox.rejected.connect(self.on_rejected)
+
+        layout = QVBoxLayout(self)
+        layout.addWidget(self.list_widget)
+        layout.addWidget(self.buttonBox)
+
+    def get_selected_window(self) -> dict | None:
+        selectedItem = self.list_widget.currentItem()
+        if selectedItem:
+            return selectedItem.data(Qt.ItemDataRole.UserRole)
+        return None
+
+    def on_accepted(self):
+        self.__a_result.set_result(self.get_selected_window())
+        self.close()
+
+    def on_rejected(self):
+        self.__a_result.set_result(None)
+        self.close()
+
+    def closeEvent(self, a0):
+        super().closeEvent(a0)
+        if not self.__a_result.done():
+            self.__a_result.set_result(None)
+
+    async def a_show(self) -> dict | None:
+        self.open()
+        return await self.__a_result
+
+
+class AVCallGUI(QMainWindow):
+    def __init__(self, host, icons_path: Path):
+        super().__init__()
+        self.host = host
+        self.webrtc_call = None
+        self.icons_path = icons_path
+        self.initUI()
+
+    @staticmethod
+    async def run_qt_loop(app):
+        while running:
+            app.sendPostedEvents()
+            await asyncio.sleep(0.1)
+
+    @classmethod
+    async def run(cls, parent, call_data, icons_path: Path):
+        """Run PyQt loop and show the app"""
+        print("Starting GUI...")
+        app = QApplication([])
+        av_call_gui = cls(parent.host, icons_path)
+        av_call_gui.show()
+        webrtc_call = await parent.make_webrtc_call(
+            call_data,
+            sinks=webrtc.SINKS_APP,
+            appsink_data=webrtc.AppSinkData(
+                local_video_cb=partial(av_call_gui.on_new_sample, video_stream="local"),
+                remote_video_cb=partial(av_call_gui.on_new_sample, video_stream="remote"),
+            ),
+        )
+        av_call_gui.webrtc_call = webrtc_call
+
+        global running
+        running = True
+        await cls.run_qt_loop(app)
+        await parent.host.a_quit()
+
+    def initUI(self):
+        self.setGeometry(100, 100, 800, 600)
+        self.setWindowTitle("Call")
+
+        # Main layouts
+        self.background_widget = QWidget(self)
+        self.foreground_widget = QWidget(self)
+        self.setCentralWidget(self.background_widget)
+        back_layout = QVBoxLayout(self.background_widget)
+        front_layout = QVBoxLayout(self.foreground_widget)
+
+        # Remote video
+        self.remote_video_widget = QLabel(self)
+        self.remote_video_widget.setSizePolicy(
+            QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored
+        )
+        back_layout.addWidget(self.remote_video_widget)
+
+        # Fullscreen button
+        fullscreen_layout = QHBoxLayout()
+        front_layout.addLayout(fullscreen_layout)
+        fullscreen_layout.addStretch()
+        self.fullscreen_btn = QPushButton("", self)
+        self.fullscreen_btn.setFixedSize(BUTTON_SIZE)
+        self.fullscreen_icon_normal = QIcon(str(self.icons_path / "resize-full.svg"))
+        self.fullscreen_icon_fullscreen = QIcon(str(self.icons_path / "resize-small.svg"))
+        self.fullscreen_btn.setIcon(self.fullscreen_icon_normal)
+        self.fullscreen_btn.setIconSize(ICON_SIZE)
+        self.fullscreen_btn.clicked.connect(self.toggle_fullscreen)
+        fullscreen_layout.addWidget(self.fullscreen_btn)
+
+        # Control buttons
+        self.control_buttons_layout = QHBoxLayout()
+        self.control_buttons_layout.setSpacing(40)
+        self.toggle_video_btn = cast(
+            ActivableButton, self.add_control_button("videocam", self.toggle_video)
+        )
+        self.toggle_audio_btn = cast(
+            ActivableButton, self.add_control_button("volume-up", self.toggle_audio)
+        )
+        self.share_desktop_btn = cast(
+            ActivableButton, self.add_control_button("desktop", self.share_desktop)
+        )
+        self.share_desktop_btn.deactivated_colour = "#47c68e"
+        self.share_desktop_btn.activated_colour = "#f24468"
+        self.share_desktop_btn.line_colour = "#666666"
+        self.share_desktop_btn.activated = False
+        self.hang_up_btn = self.add_control_button(
+            "phone", self.hang_up, rotate=135, background="red", activable=False
+        )
+
+        controls_widget = QWidget(self)
+        controls_widget.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
+        controls_widget.setLayout(self.control_buttons_layout)
+        front_layout.addStretch()
+
+        bottom_layout = QHBoxLayout()
+        bottom_layout.addStretch()
+        front_layout.addLayout(bottom_layout)
+        bottom_layout.addWidget(controls_widget, alignment=Qt.AlignmentFlag.AlignBottom)
+
+        # Local video feedback
+        bottom_layout.addStretch()
+        self.local_video_widget = QLabel(self)
+        bottom_layout.addWidget(self.local_video_widget)
+
+        # we update sizes on resize event
+        self.background_widget.resizeEvent = self.adjust_sizes
+        self.adjust_sizes()
+
+    def add_control_button(
+        self,
+        icon_name: str,
+        callback: Callable,
+        rotate: float | None = None,
+        background: str | None = None,
+        activable: bool = True,
+    ) -> QPushButton | ActivableButton:
+        if activable:
+            button = ActivableButton("", self)
+        else:
+            button = QPushButton("", self)
+        icon_path = self.icons_path / f"{icon_name}.svg"
+        button.setIcon(QIcon(str(icon_path)))
+        button.setIconSize(ICON_SIZE)
+        button.setFixedSize(BUTTON_SIZE)
+        if rotate is not None:
+            pixmap = button.icon().pixmap(ICON_SIZE)
+            transform = QTransform()
+            transform.rotate(rotate)
+            rotated_pixmap = pixmap.transformed(transform)
+            button.setIcon(QIcon(rotated_pixmap))
+        if background:
+            button.setStyleSheet(f"background-color: {background};")
+        button.clicked.connect(callback)
+        self.control_buttons_layout.addWidget(button)
+        return button
+
+    def adjust_sizes(self, a0: QResizeEvent | None = None) -> None:
+        self.foreground_widget.setGeometry(
+            0, 0, self.background_widget.width(), self.background_widget.height()
+        )
+        self.local_video_widget.setFixedSize(QSize(self.width() // 3, self.height() // 3))
+        if a0 is not None:
+            super().resizeEvent(a0)
+
+    def on_new_sample(self, video_sink, video_stream: str) -> bool:
+        sample = video_sink.emit("pull-sample")
+        if sample is None:
+            return False
+
+        video_pad = video_sink.get_static_pad("sink")
+        assert video_pad is not None
+        s = video_pad.get_current_caps().get_structure(0)
+        stream_size = (s.get_value("width"), s.get_value("height"))
+        self.host.loop.loop.call_soon_threadsafe(
+            self.update_sample, sample, stream_size, video_stream
+        )
+
+        return False
+
+    def update_sample(self, sample, stream_size, video_stream: str) -> None:
+        if sample is None:
+            return
+
+        video_widget = (
+            self.remote_video_widget
+            if video_stream == "remote"
+            else self.local_video_widget
+        )
+
+        buf = sample.get_buffer()
+        result, mapinfo = buf.map(Gst.MapFlags.READ)
+        if result:
+            buffer = mapinfo.data
+            width, height = stream_size
+            qimage = QImage(buffer, width, height, QImage.Format.Format_RGB888)
+            pixmap = QPixmap.fromImage(qimage).scaled(
+                QSize(video_widget.width(), video_widget.height()),
+                Qt.AspectRatioMode.KeepAspectRatio,
+            )
+            video_widget.setPixmap(pixmap)
+            video_widget.setAlignment(Qt.AlignmentFlag.AlignCenter)
+
+            buf.unmap(mapinfo)
+
+    def toggle_fullscreen(self):
+        fullscreen = not self.isFullScreen()
+        if fullscreen:
+            self.fullscreen_btn.setIcon(self.fullscreen_icon_fullscreen)
+            self.showFullScreen()
+        else:
+            self.fullscreen_btn.setIcon(self.fullscreen_icon_normal)
+            self.showNormal()
+
+    def closeEvent(self, a0: QCloseEvent) -> None:
+        super().closeEvent(a0)
+        global running
+        running = False
+
+    def toggle_video(self):
+        assert self.webrtc_call is not None
+        self.webrtc_call.webrtc.video_muted = not self.webrtc_call.webrtc.video_muted
+        self.toggle_video_btn.activated = not self.webrtc_call.webrtc.video_muted
+
+    def toggle_audio(self):
+        assert self.webrtc_call is not None
+        self.webrtc_call.webrtc.audio_muted = not self.webrtc_call.webrtc.audio_muted
+        self.toggle_audio_btn.activated = not self.webrtc_call.webrtc.audio_muted
+
+    def share_desktop(self):
+        assert self.webrtc_call is not None
+        if self.webrtc_call.webrtc.desktop_sharing:
+            self.webrtc_call.webrtc.desktop_sharing = False
+            self.share_desktop_btn.activated = False
+        elif display_servers.detect() == display_servers.X11:
+            aio.run_async(self.show_X11_screen_dialog())
+        else:
+            self.webrtc_call.webrtc.desktop_sharing = True
+
+    def hang_up(self):
+        self.close()
+
+    async def show_X11_screen_dialog(self):
+        assert self.webrtc_call is not None
+        windows_data = display_servers.x11_list_windows()
+        dialog = X11DesktopScreenDialog(windows_data, self)
+        selected = await dialog.a_show()
+        if selected is not None:
+            xid = selected["id"]
+            self.webrtc_call.webrtc.desktop_sharing_data = {"xid": xid}
+            self.webrtc_call.webrtc.desktop_sharing = True
+            self.share_desktop_btn.activated = True