changeset 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 17a8168966f9
children 2865e70b0b2c
files libervia/cli/call_gui.py libervia/cli/cmd_call.py libervia/cli/constants.py libervia/cli/loops.py
diffstat 4 files changed, 497 insertions(+), 14 deletions(-) [+]
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
--- a/libervia/cli/cmd_call.py	Thu Jan 18 23:29:25 2024 +0100
+++ b/libervia/cli/cmd_call.py	Sun Feb 11 23:20:24 2024 +0100
@@ -20,9 +20,12 @@
 
 from argparse import ArgumentParser
 import asyncio
+from dataclasses import dataclass
 from functools import partial
 import logging
 import os
+from pathlib import Path
+from typing import Callable
 
 from prompt_toolkit.input import create_input
 from prompt_toolkit.keys import Keys
@@ -42,14 +45,27 @@
 __commands__ = ["Call"]
 
 
+@dataclass
+class CallData:
+    callee: jid.JID
+    sid: str|None = None
+    action_id: str|None = None
+
+
 class WebRTCCall:
-    def __init__(self, host, profile: str, callee: jid.JID):
+    def __init__(self, host, profile: str, callee: jid.JID, **kwargs):
+        """Create and setup a webRTC instance
+
+        @param profile: profile making or receiving the call
+        @param callee: peer jid
+        @param kwargs: extra kw args to use when instantiating WebRTC
+        """
         from libervia.frontends.tools import webrtc
 
         aio.install_glib_asyncio_iteration()
         self.host = host
         self.profile = profile
-        self.webrtc = webrtc.WebRTC(host.bridge, profile)
+        self.webrtc = webrtc.WebRTC(host.bridge, profile, **kwargs)
         self.webrtc.callee = callee
         host.bridge.register_signal(
             "ice_candidates_new", self.on_ice_candidates_new, "plugin"
@@ -197,6 +213,19 @@
 
 class Common(base.CommandBase):
 
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(
+            *args,
+            use_output=C.OUTPUT_CUSTOM,
+            extra_outputs={
+                "default": self.auto_output,
+                "simple": self.simple_output,
+                "gui": self.gui_output,
+            },
+            **kwargs
+        )
+
     def add_parser_options(self):
         self.parser.add_argument(
             "--no-ui", action="store_true", help=_("disable user interface")
@@ -214,11 +243,51 @@
         if self.verbosity >= 2:
             root_logger.setLevel(logging.DEBUG)
 
-    async def start_ui(self, webrtc_call):
+    async def make_webrtc_call(self, call_data: CallData, **kwargs) -> WebRTCCall:
+        """Create the webrtc_call instance
+
+        @param call_data: Call data of the command
+        @param kwargs: extra args used to instanciate WebRTCCall
+
+        """
+        webrtc_call = WebRTCCall(self.host, self.profile, call_data.callee, **kwargs)
+        if call_data.sid is None:
+            # we are making the call
+            await webrtc_call.start()
+        else:
+            # we are receiving the call
+            webrtc_call.sid = call_data.sid
+            if call_data.action_id is not None:
+                await self.host.bridge.action_launch(
+                    call_data.action_id,
+                    data_format.serialise({"cancelled": False}),
+                    self.profile
+                )
+        return webrtc_call
+
+    async def auto_output(self, call_data: CallData):
+        """Make a guess on the best output to use on current platform"""
+        # For now we just use simple output
+        await self.simple_output(call_data)
+
+    async def simple_output(self, call_data: CallData):
+        """Run simple output, with GStreamer ``autovideosink``"""
+        webrtc_call = await self.make_webrtc_call(call_data)
         if not self.args.no_ui:
             ui = UI(self.host, webrtc_call.webrtc)
             await ui.start()
 
+    async def gui_output(self, call_data: CallData):
+        """Run GUI output"""
+        media_dir = Path(await self.host.bridge.config_get("", "media_dir"))
+        icons_path = media_dir / "fonts/fontello/svg"
+        try:
+            from .call_gui import AVCallGUI
+            await AVCallGUI.run(self, call_data, icons_path)
+        except Exception as e:
+            self.disp(f"Error starting GUI: {e}", error=True)
+            self.host.quit(C.EXIT_ERROR)
+
 
 class Make(Common):
     def __init__(self, host):
@@ -239,10 +308,9 @@
 
     async def start(self):
         await super().start()
-        callee = jid.JID(self.args.entity)
-        webrtc_call = WebRTCCall(self.host, self.profile, callee)
-        await webrtc_call.start()
-        await super().start_ui(webrtc_call)
+        await super().output(CallData(
+            callee=jid.JID(self.args.entity),
+        ))
 
 
 class Receive(Common):
@@ -297,12 +365,11 @@
 
         self.disp(_("✅ Incoming call from {caller} accepted.").format(caller=caller))
 
-        webrtc_call = WebRTCCall(self.host, self.profile, peer_jid)
-        webrtc_call.sid = action_data["session_id"]
-        await self.host.bridge.action_launch(
-            action_id, data_format.serialise({"cancelled": False}), profile
-        )
-        await super().start_ui(webrtc_call)
+        await super().output(CallData(
+            callee=peer_jid,
+            sid=action_data["session_id"],
+            action_id=action_id
+        ))
 
     async def start(self):
         await super().start()
--- a/libervia/cli/constants.py	Thu Jan 18 23:29:25 2024 +0100
+++ b/libervia/cli/constants.py	Sun Feb 11 23:20:24 2024 +0100
@@ -31,6 +31,9 @@
     CONFIG_SECTION = APP_COMPONENT.lower()
     PLUGIN_CMD = "commands"
     PLUGIN_OUTPUT = "outputs"
+    #: All outputs are managed directly by the command, ``extra_outputs`` must be
+    #: specified.
+    OUTPUT_CUSTOM = "custom"
     OUTPUT_TEXT = "text"  # blob of unicode text
     OUTPUT_DICT = "dict"  # simple key/value dictionary
     OUTPUT_LIST = "list"
@@ -43,6 +46,7 @@
     OUTPUT_XMLUI = "xmlui"  # XMLUI as unicode string
     OUTPUT_LIST_XMLUI = "list_xmlui"  # list of XMLUI (as unicode strings)
     OUTPUT_TYPES = (
+        OUTPUT_CUSTOM,
         OUTPUT_TEXT,
         OUTPUT_DICT,
         OUTPUT_LIST,
--- a/libervia/cli/loops.py	Thu Jan 18 23:29:25 2024 +0100
+++ b/libervia/cli/loops.py	Sun Feb 11 23:20:24 2024 +0100
@@ -43,7 +43,7 @@
         class LiberviaCLILoop:
 
             def __init__(self):
-                self.loop = loop = asyncio.get_event_loop()
+                self.loop = asyncio.get_event_loop()
 
             def run(self, libervia_cli, args, namespace):
                 aio.install_glib_asyncio_iteration()