Mercurial > libervia-backend
view libervia/cli/cmd_call.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 | 849721e1563b |
children | 9218d4331bb2 |
line wrap: on
line source
#!/usr/bin/env python3 # Libervia CLI # Copyright (C) 2009-2021 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 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 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 . import base __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): 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") ) async def start(self): root_logger = logging.getLogger() # we don't want any formatting for messages from webrtc for handler in root_logger.handlers: handler.setFormatter(None) if self.verbosity == 0: root_logger.setLevel(logging.ERROR) if self.verbosity >= 1: root_logger.setLevel(logging.WARNING) 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() 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): super().__init__( host, "make", use_verbose=True, help=_("start a call"), ) def add_parser_options(self): super().add_parser_options() self.parser.add_argument( "entity", metavar="JID", help=_("JIDs of entity to call"), ) async def start(self): await super().start() await super().output(CallData( callee=jid.JID(self.args.entity), )) class Receive(Common): def __init__(self, host): super().__init__( host, "receive", use_verbose=True, help=_("wait for a call"), ) def add_parser_options(self): super().add_parser_options() auto_accept_group = self.parser.add_mutually_exclusive_group() auto_accept_group.add_argument( "-a", "--auto-accept", action="append", metavar="JID", default=[], help=_("automatically accept call from this jid (can be used multiple times)") ) auto_accept_group.add_argument( "--auto-accept-all", action="store_true", help=_("automatically accept call from anybody") ) async def on_action_new( self, action_data_s: str, action_id: str, security_limit: int, profile: str ) -> None: if profile != self.profile: return action_data = data_format.deserialise(action_data_s) if action_data.get("type") != C.META_TYPE_CALL: return peer_jid = jid.JID(action_data["from_jid"]).bare caller = peer_jid.bare if ( not self.args.auto_accept_all and caller not in self.args.auto_accept and not await self.host.confirm( _("π Incoming call from {caller}, do you accept?").format( caller=caller ) ) ): await self.host.bridge.action_launch( action_id, data_format.serialise({"cancelled": True}), profile ) return self.disp(_("β Incoming call from {caller} accepted.").format(caller=caller)) await super().output(CallData( callee=peer_jid, sid=action_data["session_id"], action_id=action_id )) async def start(self): await super().start() self.host.bridge.register_signal("action_new", self.on_action_new, "core") class Call(base.CommandBase): subcommands = (Make, Receive) def __init__(self, host): super().__init__(host, "call", use_profile=False, help=_("A/V calls and related"))