comparison libervia/cli/cmd_call.py @ 4143:849721e1563b

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
author Goffi <goffi@goffi.org>
date Wed, 01 Nov 2023 14:10:00 +0100
parents
children 0f8ea0768a3b
comparison
equal deleted inserted replaced
4142:783bbdbf8567 4143:849721e1563b
1 #!/usr/bin/env python3
2
3
4 # Libervia CLI
5 # Copyright (C) 2009-2021 JΓ©rΓ΄me Poisson (goffi@goffi.org)
6
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
16
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19
20
21 from argparse import ArgumentParser
22 import asyncio
23 from functools import partial
24 import logging
25 import os
26
27 from prompt_toolkit.input import create_input
28 from prompt_toolkit.keys import Keys
29
30 from libervia.backend.core.i18n import _
31 from libervia.backend.tools.common import data_format
32 from libervia.cli.constants import Const as C
33 from libervia.frontends.tools import aio, jid
34 from rich.columns import Columns
35 from rich.console import group
36 from rich.live import Live
37 from rich.panel import Panel
38 from rich.text import Text
39
40 from . import base
41
42 __commands__ = ["Call"]
43
44
45 class WebRTCCall:
46 def __init__(self, host, profile: str, callee: jid.JID):
47 from libervia.frontends.tools import webrtc
48
49 aio.install_glib_asyncio_iteration()
50 self.host = host
51 self.profile = profile
52 self.webrtc = webrtc.WebRTC(host.bridge, profile)
53 self.webrtc.callee = callee
54 host.bridge.register_signal(
55 "ice_candidates_new", self.on_ice_candidates_new, "plugin"
56 )
57 host.bridge.register_signal("call_setup", self.on_call_setup, "plugin")
58 host.bridge.register_signal("call_ended", self.on_call_ended, "plugin")
59
60 @property
61 def sid(self) -> str | None:
62 return self.webrtc.sid
63
64 @sid.setter
65 def sid(self, new_sid: str | None) -> None:
66 self.webrtc.sid = new_sid
67
68 async def on_ice_candidates_new(
69 self, sid: str, candidates_s: str, profile: str
70 ) -> None:
71 if sid != self.webrtc.sid or profile != self.profile:
72 return
73 self.webrtc.on_ice_candidates_new(
74 data_format.deserialise(candidates_s),
75 )
76
77 async def on_call_setup(self, sid: str, setup_data_s: str, profile: str) -> None:
78 if sid != self.webrtc.sid or profile != self.profile:
79 return
80 setup_data = data_format.deserialise(setup_data_s)
81 try:
82 role = setup_data["role"]
83 sdp = setup_data["sdp"]
84 except KeyError:
85 self.host.disp(f"Invalid setup data received: {setup_data}", error=True)
86 return
87 if role == "initiator":
88 self.webrtc.on_accepted_call(sdp, profile)
89 elif role == "responder":
90 await self.webrtc.answer_call(sdp, profile)
91 else:
92 self.host.disp(
93 f"Invalid role received during setup: {setup_data}", error=True
94 )
95 # we want to be sure that call is ended if user presses `Ctrl + c` or anything
96 # else stops the session.
97 self.host.add_on_quit_callback(
98 lambda: self.host.bridge.call_end(sid, "", profile)
99 )
100
101 async def on_call_ended(self, sid: str, data_s: str, profile: str) -> None:
102 if sid != self.webrtc.sid or profile != self.profile:
103 return
104 await self.webrtc.end_call()
105 await self.host.a_quit()
106
107 async def start(self):
108 """Start a call.
109
110 To be used only if we are initiator
111 """
112 await self.webrtc.setup_call("initiator")
113 self.webrtc.start_pipeline()
114
115
116 class UI:
117 def __init__(self, host, webrtc):
118 self.host = host
119 self.webrtc = webrtc
120
121 def styled_shortcut_key(self, word: str, key: str | None = None) -> Text:
122 """Return a word with the specified key or the first letter underlined."""
123 if key is None:
124 key = word[0]
125 index = word.find(key)
126 before, keyword, after = word[:index], word[index], word[index + 1 :]
127 return Text(before) + Text(keyword, style="shortcut") + Text(after)
128
129 def get_micro_display(self):
130 if self.webrtc.audio_muted:
131 return Panel(Text("πŸ”‡ ") + self.styled_shortcut_key("Muted"), expand=False)
132 else:
133 return Panel(
134 Text("🎀 ") + self.styled_shortcut_key("Unmuted", "m"), expand=False
135 )
136
137 def get_video_display(self):
138 if self.webrtc.video_muted:
139 return Panel(Text("❌ ") + self.styled_shortcut_key("Video Off"), expand=False)
140 else:
141 return Panel(Text("πŸŽ₯ ") + self.styled_shortcut_key("Video On"), expand=False)
142
143 def get_phone_display(self):
144 return Panel(Text("πŸ“ž ") + self.styled_shortcut_key("Hang up"), expand=False)
145
146 @group()
147 def generate_control_bar(self):
148 """Return the full interface display."""
149 yield Columns(
150 [
151 self.get_micro_display(),
152 self.get_video_display(),
153 self.get_phone_display(),
154 ],
155 expand=False,
156 title="Calling [bold center]{}[/]".format(self.webrtc.callee),
157 )
158
159 async def start(self):
160 done = asyncio.Event()
161 input = create_input()
162
163 def keys_ready(live):
164 for key_press in input.read_keys():
165 char = key_press.key.lower()
166 if char == "m":
167 # audio mute
168 self.webrtc.audio_muted = not self.webrtc.audio_muted
169 live.update(self.generate_control_bar(), refresh=True)
170 elif char == "v":
171 # video mute
172 self.webrtc.video_muted = not self.webrtc.video_muted
173 live.update(self.generate_control_bar(), refresh=True)
174 elif char == "h" or key_press.key == Keys.ControlC:
175 # Hang up
176 done.set()
177 elif char == "d":
178 # generate dot file for debugging. Only available if
179 # ``GST_DEBUG_DUMP_DOT_DIR`` is set. Filename is "pipeline.dot" with a
180 # timestamp.
181 if os.getenv("GST_DEBUG_DUMP_DOT_DIR"):
182 self.webrtc.generate_dot_file()
183 self.host.disp("Dot file generated.")
184
185 with Live(
186 self.generate_control_bar(),
187 console=self.host.console,
188 auto_refresh=False
189 ) as live:
190 with input.raw_mode():
191 with input.attach(partial(keys_ready, live)):
192 await done.wait()
193
194 await self.webrtc.end_call()
195 await self.host.a_quit()
196
197
198 class Common(base.CommandBase):
199
200 def add_parser_options(self):
201 self.parser.add_argument(
202 "--no-ui", action="store_true", help=_("disable user interface")
203 )
204
205 async def start(self):
206 root_logger = logging.getLogger()
207 # we don't want any formatting for messages from webrtc
208 for handler in root_logger.handlers:
209 handler.setFormatter(None)
210 if self.verbosity == 0:
211 root_logger.setLevel(logging.ERROR)
212 if self.verbosity >= 1:
213 root_logger.setLevel(logging.WARNING)
214 if self.verbosity >= 2:
215 root_logger.setLevel(logging.DEBUG)
216
217 async def start_ui(self, webrtc_call):
218 if not self.args.no_ui:
219 ui = UI(self.host, webrtc_call.webrtc)
220 await ui.start()
221
222
223 class Make(Common):
224 def __init__(self, host):
225 super().__init__(
226 host,
227 "make",
228 use_verbose=True,
229 help=_("start a call"),
230 )
231
232 def add_parser_options(self):
233 super().add_parser_options()
234 self.parser.add_argument(
235 "entity",
236 metavar="JID",
237 help=_("JIDs of entity to call"),
238 )
239
240 async def start(self):
241 await super().start()
242 callee = jid.JID(self.args.entity)
243 webrtc_call = WebRTCCall(self.host, self.profile, callee)
244 await webrtc_call.start()
245 await super().start_ui(webrtc_call)
246
247
248 class Receive(Common):
249 def __init__(self, host):
250 super().__init__(
251 host,
252 "receive",
253 use_verbose=True,
254 help=_("wait for a call"),
255 )
256
257 def add_parser_options(self):
258 super().add_parser_options()
259 auto_accept_group = self.parser.add_mutually_exclusive_group()
260 auto_accept_group.add_argument(
261 "-a",
262 "--auto-accept",
263 action="append",
264 metavar="JID",
265 default=[],
266 help=_("automatically accept call from this jid (can be used multiple times)")
267 )
268 auto_accept_group.add_argument(
269 "--auto-accept-all",
270 action="store_true",
271 help=_("automatically accept call from anybody")
272 )
273
274 async def on_action_new(
275 self, action_data_s: str, action_id: str, security_limit: int, profile: str
276 ) -> None:
277 if profile != self.profile:
278 return
279 action_data = data_format.deserialise(action_data_s)
280 if action_data.get("type") != C.META_TYPE_CALL:
281 return
282 peer_jid = jid.JID(action_data["from_jid"]).bare
283 caller = peer_jid.bare
284 if (
285 not self.args.auto_accept_all
286 and caller not in self.args.auto_accept
287 and not await self.host.confirm(
288 _("πŸ“ž Incoming call from {caller}, do you accept?").format(
289 caller=caller
290 )
291 )
292 ):
293 await self.host.bridge.action_launch(
294 action_id, data_format.serialise({"cancelled": True}), profile
295 )
296 return
297
298 self.disp(_("βœ… Incoming call from {caller} accepted.").format(caller=caller))
299
300 webrtc_call = WebRTCCall(self.host, self.profile, peer_jid)
301 webrtc_call.sid = action_data["session_id"]
302 await self.host.bridge.action_launch(
303 action_id, data_format.serialise({"cancelled": False}), profile
304 )
305 await super().start_ui(webrtc_call)
306
307 async def start(self):
308 await super().start()
309 self.host.bridge.register_signal("action_new", self.on_action_new, "core")
310
311
312 class Call(base.CommandBase):
313 subcommands = (Make, Receive)
314
315 def __init__(self, host):
316 super().__init__(host, "call", use_profile=False, help=_("A/V calls and related"))