cli: call command:

This command has 2 subcommands: `make` and `receive` to make a new call or wait for one.
When call is in progress, a window will be created to show incoming stream and local
feedback, and a text UI is available to (un)mute audio or video, and hang up.

rel 426
This commit is contained in:
Goffi 2023-11-01 14:10:00 +01:00
commit 934450168a
3 changed files with 318 additions and 0 deletions

316
libervia/cli/cmd_call.py Normal file
View file

@ -0,0 +1,316 @@
#!/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 functools import partial
import logging
import os
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"]
class WebRTCCall:
def __init__(self, host, profile: str, callee: jid.JID):
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.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 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 start_ui(self, webrtc_call):
if not self.args.no_ui:
ui = UI(self.host, webrtc_call.webrtc)
await ui.start()
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()
callee = jid.JID(self.args.entity)
webrtc_call = WebRTCCall(self.host, self.profile, callee)
await webrtc_call.start()
await super().start_ui(webrtc_call)
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))
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)
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"))

View file

@ -92,4 +92,5 @@ class Const(constants.Const):
"priority_high": "bold magenta",
"priority_urgent": "bold red",
"notif_title": "bold",
"shortcut": "bold underline"
})

View file

@ -47,6 +47,7 @@ dependencies = [
# "python-potr < 1.1",
"pyxdg < 0.30",
"pyyaml < 7.0.0",
"prompt_toolkit ~= 3.0",
"rich ~= 13.5",
"sat_tmp @ hg+https://repos.goffi.org/sat_tmp#egg=sat_tmp",
"setuptools_scm",