changeset 4210:9218d4331bb2

cli (call): `tui` output implementation: - Moved original UI to a separated class, and use if with the `simple` output - By default, best output is automatically selected. For now `gui` is selected if possible, and `simple` is used as fallback. - The new `tui` output can be used to have the videos directly embedded in the terminal, either with real videos for compatible terminal emulators, or with Unicode blocks. - Text contrôls are used for both `simple` and `tui` outputs - several options can be used with `--oo` (will be documented in next commit). rel 428
author Goffi <goffi@goffi.org>
date Fri, 16 Feb 2024 18:46:06 +0100
parents fe29fbdabce6
children be89ab1cbca4
files libervia/cli/call_gui.py libervia/cli/call_simple.py libervia/cli/call_tui.py libervia/cli/call_webrtc.py libervia/cli/cmd_call.py pyproject.toml
diffstat 6 files changed, 561 insertions(+), 228 deletions(-) [+]
line wrap: on
line diff
--- a/libervia/cli/call_gui.py	Fri Feb 16 18:46:02 2024 +0100
+++ b/libervia/cli/call_gui.py	Fri Feb 16 18:46:06 2024 +0100
@@ -47,10 +47,17 @@
     QVBoxLayout,
     QWidget,
 )
+import gi
+
+from libervia.backend.core.i18n import _
+from libervia.cli.call_webrtc import WebRTCCall
+from libervia.frontends.tools import aio, display_servers, webrtc
+gi.require_versions({
+    "Gst": "1.0",
+    "GstWebRTC": "1.0"
+})
 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)
@@ -181,7 +188,7 @@
         return await self.__a_result
 
 
-class AVCallGUI(QMainWindow):
+class AVCallUI(QMainWindow):
     def __init__(self, host, icons_path: Path):
         super().__init__()
         self.host = host
@@ -196,13 +203,16 @@
             await asyncio.sleep(0.1)
 
     @classmethod
-    async def run(cls, parent, call_data, icons_path: Path):
+    async def run(cls, parent, call_data):
         """Run PyQt loop and show the app"""
-        print("Starting GUI...")
+        media_dir = Path(await parent.host.bridge.config_get("", "media_dir"))
+        icons_path = media_dir / "fonts/fontello/svg"
         app = QApplication([])
         av_call_gui = cls(parent.host, icons_path)
         av_call_gui.show()
-        webrtc_call = await parent.make_webrtc_call(
+        webrtc_call = await WebRTCCall.make_webrtc_call(
+            parent.host,
+            parent.profile,
             call_data,
             sinks=webrtc.SINKS_APP,
             appsink_data=webrtc.AppSinkData(
@@ -372,7 +382,7 @@
             self.fullscreen_btn.setIcon(self.fullscreen_icon_normal)
             self.showNormal()
 
-    def closeEvent(self, a0: QCloseEvent) -> None:
+    def closeEvent(self, a0: QCloseEvent|None) -> None:
         super().closeEvent(a0)
         global running
         running = False
@@ -400,6 +410,11 @@
     def hang_up(self):
         self.close()
 
+    @staticmethod
+    def can_run():
+        # if a known display server is detected, we should be able to run
+        return display_servers.detect() is not None
+
     async def show_X11_screen_dialog(self):
         assert self.webrtc_call is not None
         windows_data = display_servers.x11_list_windows()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/call_simple.py	Fri Feb 16 18:46:06 2024 +0100
@@ -0,0 +1,168 @@
+#!/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
+import os
+
+from prompt_toolkit.input import create_input
+from prompt_toolkit.keys import Keys
+from rich.align import Align
+from rich.columns import Columns
+from rich.console import group
+from rich.live import Live
+from rich.panel import Panel
+from rich.text import Text
+
+from .call_webrtc import CallData, WebRTCCall
+
+
+class BaseAVTUI:
+    def __init__(self, host, webrtc=None, align: str = "left"):
+        self.host = host
+        self.webrtc = webrtc
+        self.align = align
+        self.input = create_input()
+        self.done = asyncio.Event()
+        self.target_size: tuple[int, int] | None = None
+
+    @staticmethod
+    def parse_output_opts(parent) -> dict:
+        """Parse output options.
+
+        This method should be called in a loop checking all output options.
+        It will set the relevant attributes.
+        @return: keyword argument to use to instanciate WebRTCCall
+        """
+        kwargs = {}
+        for oo in parent.args.output_opts:
+            if oo.startswith("size="):
+                try:
+                    width_s, height_s = oo[5:].lower().strip().split("x", 1)
+                    width, height = int(width_s), int(height_s)
+                except ValueError:
+                    parent.parser.error(
+                        "Invalid size; it must be in the form widthxheight "
+                        "(e.g., 640x360)."
+                    )
+                else:
+                    kwargs["target_size"] = (width, height)
+        return kwargs
+
+    def styled_shortcut_key(self, word: str, key: str | None = None) -> Text:
+        """Return a word with the specified key or the first letter underlined."""
+        if key is None:
+            key = word[0]
+        index = word.find(key)
+        before, keyword, after = word[:index], word[index], word[index + 1 :]
+        return Text(before) + Text(keyword, style="shortcut") + Text(after)
+
+    def get_micro_display(self):
+        assert self.webrtc is not None
+        if self.webrtc.audio_muted:
+            return Panel(Text("🔇 ") + self.styled_shortcut_key("Muted"), expand=False)
+        else:
+            return Panel(
+                Text("🎤 ") + self.styled_shortcut_key("Unmuted", "m"), expand=False
+            )
+
+    def get_video_display(self):
+        assert self.webrtc is not None
+        if self.webrtc.video_muted:
+            return Panel(Text("❌ ") + self.styled_shortcut_key("Video Off"), expand=False)
+        else:
+            return Panel(Text("🎥 ") + self.styled_shortcut_key("Video On"), expand=False)
+
+    def get_phone_display(self):
+        return Panel(Text("📞 ") + self.styled_shortcut_key("Hang up"), expand=False)
+
+    @group()
+    def generate_control_bar(self):
+        """Return the full interface display."""
+        assert self.webrtc is not None
+        yield Align(
+            Columns(
+                [
+                    Text(),
+                    self.get_micro_display(),
+                    self.get_video_display(),
+                    self.get_phone_display(),
+                ],
+                expand=False,
+                title="Calling [bold center]{}[/]".format(self.webrtc.callee),
+            ),
+            align=self.align,
+        )
+
+    def keys_ready(self, live=None):
+        assert self.webrtc is not None
+        for key_press in self.input.read_keys():
+            char = key_press.key.lower()
+            if char == "m":
+                # audio mute
+                self.webrtc.audio_muted = not self.webrtc.audio_muted
+                if live is not None:
+                    live.update(self.generate_control_bar(), refresh=True)
+            elif char == "v":
+                # video mute
+                self.webrtc.video_muted = not self.webrtc.video_muted
+                if live is not None:
+                    live.update(self.generate_control_bar(), refresh=True)
+            elif char == "h" or key_press.key == Keys.ControlC:
+                # Hang up
+                self.done.set()
+            elif char == "d":
+                # generate dot file for debugging. Only available if
+                # ``GST_DEBUG_DUMP_DOT_DIR`` is set. Filename is "pipeline.dot" with a
+                # timestamp.
+                if os.getenv("GST_DEBUG_DUMP_DOT_DIR"):
+                    self.webrtc.generate_dot_file()
+                    self.host.disp("Dot file generated.")
+
+
+class AVCallUI(BaseAVTUI):
+    def __init__(self, host, webrtc):
+        super().__init__(host, webrtc)
+
+    async def start(self):
+        assert self.webrtc is not None
+        with Live(
+            self.generate_control_bar(), console=self.host.console, auto_refresh=False
+        ) as live:
+            with self.input.raw_mode():
+                with self.input.attach(partial(self.keys_ready, live)):
+                    await self.done.wait()
+
+        await self.webrtc.end_call()
+        await self.host.a_quit()
+
+    @classmethod
+    async def run(cls, parent, call_data: CallData) -> None:
+        kwargs = cls.parse_output_opts(parent)
+        merge_pip = False if "split" in parent.args.output_opts else None
+
+        webrtc_call = await WebRTCCall.make_webrtc_call(
+            parent.host,
+            parent.profile,
+            call_data,
+            merge_pip=merge_pip,
+            **kwargs,
+        )
+        if not parent.args.no_ui:
+            ui = cls(parent.host, webrtc_call.webrtc)
+            await ui.start()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/call_tui.py	Fri Feb 16 18:46:06 2024 +0100
@@ -0,0 +1,194 @@
+#!/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
+import sys
+
+from PIL import Image
+import gi
+from rich.padding import Padding
+from term_image import image as t_image
+
+from libervia.cli.constants import Const as C
+from libervia.frontends.tools import webrtc
+
+from .call_simple import BaseAVTUI
+from .call_webrtc import CallData, WebRTCCall
+
+gi.require_versions({"Gst": "1.0", "GstWebRTC": "1.0"})
+
+from gi.repository import Gst
+
+
+class AVCallUI(BaseAVTUI):
+    def __init__(self, parent):
+        super().__init__(parent.host, align="center")
+        self.parent = parent
+        self.image_rows = 0
+        self.buffer = None
+        self._processing = False
+        self.render_class: t_image.ImageMeta | None = None
+        self.target_size
+        self.fps = 25
+        for oo in parent.args.output_opts:
+            if oo.startswith("renderer="):
+                renderer = oo[9:].lower().strip()
+                match renderer:
+                    case "auto":
+                        # we let None as it will auto-detect best option later
+                        pass
+                    case "block":
+                        self.render_class = t_image.BlockImage
+                    case "iterm2":
+                        self.render_class = t_image.ITerm2Image
+                    case "kitty":
+                        self.render_class = t_image.KittyImage
+                    case _:
+                        parent.parser.error(f"Invalid renderer: {renderer!r}")
+            elif oo.startswith("fps="):
+                try:
+                    self.fps = int(oo[4:])
+                except ValueError:
+                    parent.parser.error(f"Invalid FPS: {oo[4:]!r}")
+
+    async def init_call(self, call_data):
+        kwargs = self.parse_output_opts(self.parent)
+        if "target_size" not in kwargs:
+            # we use low res by default for performance reason
+            kwargs["target_size"] = (640, 380)
+        webrtc_call = await WebRTCCall.make_webrtc_call(
+            self.parent.host,
+            self.parent.profile,
+            call_data,
+            sinks=webrtc.SINKS_APP,
+            appsink_data=webrtc.AppSinkData(
+                local_video_cb=partial(self.on_new_sample, video_stream="local"),
+                remote_video_cb=None,
+            ),
+            merge_pip=True,
+            **kwargs,
+        )
+        self.webrtc = webrtc_call.webrtc
+
+    async def start(self, call_data):
+        term_rows = self.host.console.size.height
+        if term_rows < 20:
+            self.host.disp(
+                "Your terminal must have a height of a least 20 rows.", error=True
+            )
+            self.host.a_quit(C.EXIT_ERROR)
+        self.image_rows = term_rows - 6
+        self.image_cols = self.host.console.size.width
+        await self.init_call(call_data)
+        assert self.webrtc is not None
+
+        idx = 0
+        self.buffer = ""
+
+        # we detect render
+        if self.render_class is None:
+            self.render_class = t_image.auto_image_class()
+
+        loop_sleep = 1/self.fps
+
+        with self.input.raw_mode():
+            # for whatever reason, using self.input.attach is breaking KittyImage and uses
+            # a BlockImage style rendering instead. So we don't use it and we call
+            # ``self.keys_ready()`` ourself in the loop, below.
+            # cursor is not restored despite the ``screen`` context if peer is hanging up,
+            # so we reactivate cursor here
+            self.host.add_on_quit_callback(self.host.console.show_cursor, True)
+            with self.host.console.screen():
+                while True:
+                    idx += 1
+                    if self.buffer is not None:
+                        sys.stdout.write(self.buffer)
+                        sys.stdout.write("\n")
+                        self.parent.console.print(
+                            # the padding avoid artifact when toggling buttons
+                            Padding(self.generate_control_bar(), (0, 2, 0, 0))
+                        )
+                        sys.stdout.flush()
+                        rendered = True
+                    else:
+                        rendered = False
+                    await asyncio.sleep(loop_sleep)
+                    self.keys_ready()
+                    if self.done.is_set():
+                        break
+                    if rendered:
+                        # we put cursor back at the top of image to print the next frame
+                        # FIXME: we use +4 for the controls because we know the height of the
+                        #   renderable, but it would be better to measure it dynamically.
+                        sys.stdout.write(f"\033[{self.image_rows+4}A")
+
+        await self.webrtc.end_call()
+        await self.host.a_quit()
+
+    @classmethod
+    async def run(cls, parent, call_data: CallData) -> None:
+        ui = cls(parent)
+        await ui.start(call_data)
+
+    def on_new_sample(self, video_sink, video_stream: str) -> bool:
+        if self._processing:
+            # image encoding for terminal is slow, if one is already processing, we don't
+            # bother going further
+            return False
+        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"))
+        buf = sample.get_buffer()
+        result, mapinfo = buf.map(Gst.MapFlags.READ)
+        if result and self.render_class is not None:
+            self._processing = True
+            image_data = mapinfo.data
+            image = Image.frombuffer("RGB", stream_size, image_data, "raw", "RGB", 0, 1)
+            img_renderer = self.render_class(image, height=self.image_rows)
+            img_fmt = f"<{self.image_cols}.^1"
+            if self.render_class == t_image.KittyImage:
+                # we don't do compression to speed up things
+                img_fmt += "+Wc0"
+            self.host.loop.loop.call_soon_threadsafe(
+                self.update_sample,
+                sample,
+                stream_size,
+                video_stream,
+                format(img_renderer, img_fmt),
+            )
+            self._processing = False
+
+        buf.unmap(mapinfo)
+
+        return False
+
+    def update_sample(self, sample, stream_size, video_stream: str, buffer) -> None:
+        if sample is None:
+            return
+
+        if video_stream == "remote":
+            return
+
+        self.buffer = buffer
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/call_webrtc.py	Fri Feb 16 18:46:06 2024 +0100
@@ -0,0 +1,135 @@
+#!/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/>.
+
+from dataclasses import dataclass
+
+from libervia.backend.tools.common import data_format
+from libervia.frontends.tools import aio, jid
+
+
+@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, **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, **kwargs)
+        self.webrtc.callee = callee
+        host.bridge.register_signal(
+            "ice_candidates_new", self.on_ice_candidates_new, "plugin"
+        )
+        host.bridge.register_signal("call_setup", self.on_call_setup, "plugin")
+        host.bridge.register_signal("call_ended", self.on_call_ended, "plugin")
+
+    @classmethod
+    async def make_webrtc_call(
+        cls,
+        host,
+        profile: str,
+        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 = cls(host, 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 host.bridge.action_launch(
+                    call_data.action_id,
+                    data_format.serialise({"cancelled": False}),
+                    profile
+                )
+        return webrtc_call
+
+    @property
+    def sid(self) -> str | None:
+        return self.webrtc.sid
+
+    @sid.setter
+    def sid(self, new_sid: str | None) -> None:
+        self.webrtc.sid = new_sid
+
+    async def on_ice_candidates_new(
+        self, sid: str, candidates_s: str, profile: str
+    ) -> None:
+        if sid != self.webrtc.sid or profile != self.profile:
+            return
+        self.webrtc.on_ice_candidates_new(
+            data_format.deserialise(candidates_s),
+        )
+
+    async def on_call_setup(self, sid: str, setup_data_s: str, profile: str) -> None:
+        if sid != self.webrtc.sid or profile != self.profile:
+            return
+        setup_data = data_format.deserialise(setup_data_s)
+        try:
+            role = setup_data["role"]
+            sdp = setup_data["sdp"]
+        except KeyError:
+            self.host.disp(f"Invalid setup data received: {setup_data}", error=True)
+            return
+        if role == "initiator":
+            self.webrtc.on_accepted_call(sdp, profile)
+        elif role == "responder":
+            await self.webrtc.answer_call(sdp, profile)
+        else:
+            self.host.disp(
+                f"Invalid role received during setup: {setup_data}", error=True
+            )
+        # we want to be sure that call is ended if user presses `Ctrl + c` or anything
+        # else stops the session.
+        self.host.add_on_quit_callback(
+            lambda: self.host.bridge.call_end(sid, "", profile)
+        )
+
+    async def on_call_ended(self, sid: str, data_s: str, profile: str) -> None:
+        if sid != self.webrtc.sid or profile != self.profile:
+            return
+        await self.webrtc.end_call()
+        await self.host.a_quit()
+
+    async def start(self):
+        """Start a call.
+
+        To be used only if we are initiator
+        """
+        await self.webrtc.setup_call("initiator")
+        self.webrtc.start_pipeline()
--- a/libervia/cli/cmd_call.py	Fri Feb 16 18:46:02 2024 +0100
+++ b/libervia/cli/cmd_call.py	Fri Feb 16 18:46:06 2024 +0100
@@ -18,199 +18,22 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 
-from argparse import ArgumentParser
-import asyncio
-from dataclasses import dataclass
 from functools import partial
+import importlib
 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
 
 from libervia.backend.core.i18n import _
 from libervia.backend.tools.common import data_format
 from libervia.cli.constants import Const as C
-from libervia.frontends.tools import aio, jid
-from rich.columns import Columns
-from rich.console import group
-from rich.live import Live
-from rich.panel import Panel
-from rich.text import Text
+from libervia.frontends.tools import jid
 
 from . import base
+from .call_webrtc import CallData, WebRTCCall
 
 __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, **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, **kwargs)
-        self.webrtc.callee = callee
-        host.bridge.register_signal(
-            "ice_candidates_new", self.on_ice_candidates_new, "plugin"
-        )
-        host.bridge.register_signal("call_setup", self.on_call_setup, "plugin")
-        host.bridge.register_signal("call_ended", self.on_call_ended, "plugin")
-
-    @property
-    def sid(self) -> str | None:
-        return self.webrtc.sid
-
-    @sid.setter
-    def sid(self, new_sid: str | None) -> None:
-        self.webrtc.sid = new_sid
-
-    async def on_ice_candidates_new(
-        self, sid: str, candidates_s: str, profile: str
-    ) -> None:
-        if sid != self.webrtc.sid or profile != self.profile:
-            return
-        self.webrtc.on_ice_candidates_new(
-            data_format.deserialise(candidates_s),
-        )
-
-    async def on_call_setup(self, sid: str, setup_data_s: str, profile: str) -> None:
-        if sid != self.webrtc.sid or profile != self.profile:
-            return
-        setup_data = data_format.deserialise(setup_data_s)
-        try:
-            role = setup_data["role"]
-            sdp = setup_data["sdp"]
-        except KeyError:
-            self.host.disp(f"Invalid setup data received: {setup_data}", error=True)
-            return
-        if role == "initiator":
-            self.webrtc.on_accepted_call(sdp, profile)
-        elif role == "responder":
-            await self.webrtc.answer_call(sdp, profile)
-        else:
-            self.host.disp(
-                f"Invalid role received during setup: {setup_data}", error=True
-            )
-        # we want to be sure that call is ended if user presses `Ctrl + c` or anything
-        # else stops the session.
-        self.host.add_on_quit_callback(
-            lambda: self.host.bridge.call_end(sid, "", profile)
-        )
-
-    async def on_call_ended(self, sid: str, data_s: str, profile: str) -> None:
-        if sid != self.webrtc.sid or profile != self.profile:
-            return
-        await self.webrtc.end_call()
-        await self.host.a_quit()
-
-    async def start(self):
-        """Start a call.
-
-        To be used only if we are initiator
-        """
-        await self.webrtc.setup_call("initiator")
-        self.webrtc.start_pipeline()
-
-
-class UI:
-    def __init__(self, host, webrtc):
-        self.host = host
-        self.webrtc = webrtc
-
-    def styled_shortcut_key(self, word: str, key: str | None = None) -> Text:
-        """Return a word with the specified key or the first letter underlined."""
-        if key is None:
-            key = word[0]
-        index = word.find(key)
-        before, keyword, after = word[:index], word[index], word[index + 1 :]
-        return Text(before) + Text(keyword, style="shortcut") + Text(after)
-
-    def get_micro_display(self):
-        if self.webrtc.audio_muted:
-            return Panel(Text("🔇 ") + self.styled_shortcut_key("Muted"), expand=False)
-        else:
-            return Panel(
-                Text("🎤 ") + self.styled_shortcut_key("Unmuted", "m"), expand=False
-            )
-
-    def get_video_display(self):
-        if self.webrtc.video_muted:
-            return Panel(Text("❌ ") + self.styled_shortcut_key("Video Off"), expand=False)
-        else:
-            return Panel(Text("🎥 ") + self.styled_shortcut_key("Video On"), expand=False)
-
-    def get_phone_display(self):
-        return Panel(Text("📞 ") + self.styled_shortcut_key("Hang up"), expand=False)
-
-    @group()
-    def generate_control_bar(self):
-        """Return the full interface display."""
-        yield Columns(
-            [
-                self.get_micro_display(),
-                self.get_video_display(),
-                self.get_phone_display(),
-            ],
-            expand=False,
-            title="Calling [bold center]{}[/]".format(self.webrtc.callee),
-        )
-
-    async def start(self):
-        done = asyncio.Event()
-        input = create_input()
-
-        def keys_ready(live):
-            for key_press in input.read_keys():
-                char = key_press.key.lower()
-                if char == "m":
-                    # audio mute
-                    self.webrtc.audio_muted = not self.webrtc.audio_muted
-                    live.update(self.generate_control_bar(), refresh=True)
-                elif char == "v":
-                    # video mute
-                    self.webrtc.video_muted = not self.webrtc.video_muted
-                    live.update(self.generate_control_bar(), refresh=True)
-                elif char == "h" or key_press.key == Keys.ControlC:
-                    # Hang up
-                    done.set()
-                elif char == "d":
-                    # generate dot file for debugging. Only available if
-                    # ``GST_DEBUG_DUMP_DOT_DIR`` is set. Filename is "pipeline.dot" with a
-                    # timestamp.
-                    if os.getenv("GST_DEBUG_DUMP_DOT_DIR"):
-                        self.webrtc.generate_dot_file()
-                        self.host.disp("Dot file generated.")
-
-        with Live(
-            self.generate_control_bar(),
-            console=self.host.console,
-            auto_refresh=False
-        ) as live:
-            with input.raw_mode():
-                with input.attach(partial(keys_ready, live)):
-                    await done.wait()
-
-        await self.webrtc.end_call()
-        await self.host.a_quit()
-
-
 class Common(base.CommandBase):
 
 
@@ -219,9 +42,14 @@
             *args,
             use_output=C.OUTPUT_CUSTOM,
             extra_outputs={
+                #: automatically select best output for current platform
                 "default": self.auto_output,
-                "simple": self.simple_output,
-                "gui": self.gui_output,
+                #: simple output with GStreamer ``autovideosink``
+                "simple": partial(self.use_output, "simple"),
+                #: Qt GUI
+                "gui": partial(self.use_output, "gui"),
+                #: experimental TUI output
+                "tui": partial(self.use_output, "tui"),
             },
             **kwargs
         )
@@ -243,50 +71,32 @@
         if self.verbosity >= 2:
             root_logger.setLevel(logging.DEBUG)
 
-    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()
+    async def auto_output(self, call_data: CallData) -> None:
+        """Make a guess on the best output to use on current platform"""
+        try:
+            from .call_gui import AVCallUI
+        except ImportError:
+            # we can't import GUI, we may have missing modules
+            await self.use_output("simple", call_data)
         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
+            if AVCallUI.can_run():
+                await self.use_output("gui", call_data)
+            else:
+                await self.use_output("simple", call_data)
 
-    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"
+    async def use_output(self, output_type, call_data: CallData):
         try:
-            from .call_gui import AVCallGUI
-            await AVCallGUI.run(self, call_data, icons_path)
+            AVCall_module = importlib.import_module(f"libervia.cli.call_{output_type}")
+            AVCallUI = AVCall_module.AVCallUI
         except Exception as e:
-            self.disp(f"Error starting GUI: {e}", error=True)
+            self.disp(f"Error starting {output_type.upper()} UI: {e}", error=True)
             self.host.quit(C.EXIT_ERROR)
+        else:
+            try:
+                await AVCallUI.run(self, call_data)
+            except Exception as e:
+                self.disp(f"Error running {output_type.upper()} UI: {e}", error=True)
+                self.host.quit(C.EXIT_ERROR)
 
 
 class Make(Common):
--- a/pyproject.toml	Fri Feb 16 18:46:02 2024 +0100
+++ b/pyproject.toml	Fri Feb 16 18:46:06 2024 +0100
@@ -80,6 +80,17 @@
 SVG = [
     "CairoSVG",
 ]
+GUI = [
+    "PyQt6"
+]
+TUI = [
+    "term-image ~= 0.7.1"
+]
+all = [
+    "libervia-backed[SVG]",
+    "libervia-backed[GUI]",
+    "libervia-backed[TUI]",
+]
 
 [project.scripts]
 jp = "libervia.cli.base:LiberviaCli.run"