# HG changeset patch # User Goffi # Date 1698240524 -7200 # Node ID 0480f883f0a65797d3a97a389e652b42c0f14fbb # Parent bbef1a4135155b4c0b08f7af66cbe63ae68d4ea6 plugin calls: update UI: - there is now a "search" UI to select a contact to call - "call" UI is displayed only when we actually are in a call - new control button to (un)mute audio and video - new control button to go to fullscreen/back to normal - add an extra "hang up" button directly in the call UI, so there is always one even in fullscreen mode - UI is similar to the one implemented in web frontend - notification + ringtone + desktop notification on incoming call - if an incoming call is cancelled from initiator, confirmation dialog is removed rel 425 diff -r bbef1a413515 -r 0480f883f0a6 libervia/desktop_kivy/__init__.py --- a/libervia/desktop_kivy/__init__.py Wed Oct 25 15:24:42 2023 +0200 +++ b/libervia/desktop_kivy/__init__.py Wed Oct 25 15:28:44 2023 +0200 @@ -21,7 +21,11 @@ __version__ = "0.9.0.dev0" -class Global(object): +class Global: + + def __init__(self): + self._host: "cagou_main.LiberviaDesktopKivy" | None = None + @property def host(self): return self._host diff -r bbef1a413515 -r 0480f883f0a6 libervia/desktop_kivy/plugins/plugin_wid_calls.kv --- a/libervia/desktop_kivy/plugins/plugin_wid_calls.kv Wed Oct 25 15:24:42 2023 +0200 +++ b/libervia/desktop_kivy/plugins/plugin_wid_calls.kv Wed Oct 25 15:28:44 2023 +0200 @@ -14,33 +14,120 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +: + size_hint: None, None + size: "50dp", "50dp" + color: 1, 1, 1, 1 + background_color: (0.28, 0.78, 0.56, 1) if self.active else (1.0, 0.88, 0.54, 1) + canvas.before: + Color: + rgba: root.background_color + Rectangle: + size: (self.width - 2*dp(self.margin_x), self.height - 2*dp(self.margin_y)) + pos: (self.x + dp(self.margin_x), self.y + dp(self.margin_y)) + canvas.after: + Color: + rgba: (1, 0, 0, 1) if not self.active else (0, 0, 0, 0) + Line: + points: [self.x + dp(10), self.y + dp(10), self.right - dp(10), self.top - dp(10)] + width: 2 + cap: "round" : + jid_selector: jid_selector + call_layout: call_layout remote_video: remote_video local_video: local_video - orientation: 'vertical' - FloatLayout: - id: float_layout - pos_hint: {'x': 0, 'y': 0} - size_hint: 1, 1 + screen_manager: screen_manager + call_screen: call_screen + ScreenManager: + id: screen_manager + SearchScreen: + name: "search" + JidSelector: + id: jid_selector + on_select: root.on_jid_select(args[1]) + to_show: ["roster"] + InCallScreen: + id: call_screen + name: "call" + remote_video: remote_video + local_video: local_video + orientation: "vertical" + FloatLayout: + id: call_layout + pos_hint: {"x": 0, "y": 0} + size_hint: 1, 1 - VideoStreamWidget: - id: remote_video - size: float_layout.size - pos: float_layout.pos - fit_mode: "contain" + VideoStreamWidget: + id: remote_video + size: call_layout.size + pos: call_layout.pos + fit_mode: "contain" + canvas.before: + Color: + rgba: (0, 0, 0, 1) + Rectangle: + pos: self.pos + size: self.size + + VideoStreamWidget: + id: local_video + size_hint: 0.25, 0.25 + pos_hint: {"right": 1, "bottom": 0} + fit_mode: "contain" + canvas.before: + Color: + rgba: (0, 0, 0, 1) + Rectangle: + pos: self.pos + size: self.size - VideoStreamWidget: - id: local_video - size_hint: 0.25, 0.25 - pos_hint: {'right': 1, 'bottom': 0} - fit_mode: "contain" - canvas.before: - Color: - rgba: (0, 0, 0, 0) - Rectangle: - pos: self.pos - size: self.size + CallControlButton: + id: full_screen_btn + size: "60dp", "60dp" + pos_hint: {"right": 1, "top": 1} + margin_x: dp(10) + margin_y: dp(10) + symbol: "resize-small" if root.fullscreen else "resize-full" + color: 0.29, 0.29, 0.29, 1 + background_color: 0.96, 0.96, 0.96, 1 + on_press: root.fullscreen = not root.fullscreen + + + BoxLayout: + id: call_controls + orientation: "horizontal" + size_hint: 0.5, None # Adjusted to 50% of the width + height: "50dp" + pos_hint: {"x": 0.25, "y": 0.05} # Adjusted starting position + spacing: "30dp" + Widget: + + CallControlButton: + symbol: "videocam" + active: not root.video_muted + on_press: root.video_muted = not root.video_muted + + CallControlButton: + symbol: "volume-up" + active: not root.audio_muted + on_press: root.audio_muted = not root.audio_muted + + + CallControlButton: + symbol: "phone" + background_color: 0.95, 0.27, 0.41, 1 + on_press: root.hang_up() + canvas.before: + PushMatrix + Rotate: + angle: 225 + origin: self.center + canvas.after: + PopMatrix + + Widget: : diff -r bbef1a413515 -r 0480f883f0a6 libervia/desktop_kivy/plugins/plugin_wid_calls.py --- a/libervia/desktop_kivy/plugins/plugin_wid_calls.py Wed Oct 25 15:24:42 2023 +0200 +++ b/libervia/desktop_kivy/plugins/plugin_wid_calls.py Wed Oct 25 15:28:44 2023 +0200 @@ -1,4 +1,5 @@ from dataclasses import dataclass +from pathlib import Path import re from typing import Optional, Callable from urllib.parse import quote_plus @@ -15,11 +16,21 @@ "your system." ) from kivy.clock import Clock +from kivy.core.audio import Sound, SoundLoader +from kivy.core.window import Window from kivy.graphics.texture import Texture -from kivy.properties import BooleanProperty, ObjectProperty +from kivy.properties import ( + BooleanProperty, + ColorProperty, + NumericProperty, + ObjectProperty, + ReferenceListProperty, +) from kivy.support import install_gobject_iteration from kivy.uix.button import Button from kivy.uix.image import Image +from kivy.uix.screenmanager import Screen +from kivy.uix.widget import Widget from libervia.backend.core.constants import Const as C from libervia.backend.core import log as logging from libervia.backend.core.i18n import _ @@ -31,6 +42,8 @@ from libervia.desktop_kivy import G from ..core import cagou_widget +from ..core import common +from ..core.behaviors import FilterBehavior log = logging.getLogger(__name__) @@ -53,10 +66,26 @@ size: Optional[tuple[int, int]] = None +class SearchScreen(Screen): + pass + + +class InCallScreen(Screen): + pass + + class CallButton(Button): parent_widget = ObjectProperty(None) +class CallControlButton(common.SymbolButton): + active = BooleanProperty(True) + background_color = ColorProperty() + margin_x = NumericProperty(0) + margin_y = NumericProperty(0) + margin = ReferenceListProperty(margin_x, margin_y) + + class VideoStreamWidget(Image): pass @@ -71,9 +100,9 @@ If true, test video and audio sources will be used. Otherwise first webcam and microphone available will be used. """ + test_mode: bool = False - def __init__(self, parent_calls: "Calls", profile: str) -> None: self.parent_calls = parent_calls self.profile = profile @@ -264,6 +293,8 @@ } self._media_types = None self._media_types_inv = None + self.audio_valve = None + self.video_valve = None async def setup_call( self, @@ -299,11 +330,14 @@ self.gst_pipe_desc = f""" webrtcbin latency=100 name=sendrecv bundle-policy=max-compat - {video_source_elt} name=video_src + input-selector name=video_selector ! videorate ! video/x-raw,framerate=30/1 ! tee name=t + {video_source_elt} name=video_src ! queue ! video_selector. + videotestsrc is-live=true pattern=black ! queue ! video_selector. + t. ! queue max-size-buffers=5 max-size-time=0 max-size-bytes=0 leaky=downstream ! videoconvert @@ -318,6 +352,7 @@ ! appsink name=local_video_sink emit-signals=true drop=true max-buffers=1 sync=True {audio_source_elt} name=audio_src + ! valve name=audio_valve ! queue max-size-buffers=10 max-size-time=0 max-size-bytes=0 ! audioconvert ! audioresample @@ -335,9 +370,14 @@ raise exceptions.InternalError("Failed to create Gstreamer pipeline.") self.webrtc = self.pipeline.get_by_name("sendrecv") + self.video_src = self.pipeline.get_by_name("video_src") + self.video_selector = self.pipeline.get_by_name("video_selector") + self.audio_valve = self.pipeline.get_by_name("audio_valve") - self.video_src = self.pipeline.get_by_name("video_src") - self.audio_src = self.pipeline.get_by_name("audio_src") + if self.parent_calls.video_muted: + self.on_video_mute(True) + if self.parent_calls.audio_muted: + self.on_audio_mute(True) # set STUN and TURN servers external_disco = data_format.deserialise( @@ -355,7 +395,7 @@ self.webrtc.set_property("stun-server", url) elif server["type"] == "turn": url = "{scheme}://{username}:{password}@{host}:{port}".format( - scheme = "turns" if server["transport"] == "tcp" else "turn", + scheme="turns" if server["transport"] == "tcp" else "turn", username=quote_plus(server["username"]), password=quote_plus(server["password"]), host=server["host"], @@ -608,7 +648,9 @@ if adjust_resolution: videoscale = Gst.ElementFactory.make("videoscale") - adjusted_caps = Gst.Caps.from_string(f"video/x-raw,width={width},height={height}") + adjusted_caps = Gst.Caps.from_string( + f"video/x-raw,width={width},height={height}" + ) capsfilter = Gst.ElementFactory.make("capsfilter") capsfilter.set_property("caps", adjusted_caps) @@ -824,17 +866,48 @@ """ log.info("End of stream") + def on_audio_mute(self, muted: bool) -> None: + if self.audio_valve is not None: + self.audio_valve.set_property("drop", muted) + state = "muted" if muted else "unmuted" + log.info(f"audio is now {state}") + + def on_video_mute(self, muted: bool) -> None: + if self.video_selector is not None: + # when muted, we switch to a black image and deactivate the camera + if not muted: + self.video_src.set_state(Gst.State.PLAYING) + pad = self.video_selector.get_static_pad("sink_1" if muted else "sink_0") + self.video_selector.props.active_pad = pad + if muted: + self.video_src.set_state(Gst.State.NULL) + state = "muted" if muted else "unmuted" + log.info(f"video is now {state}") + async def end_call(self) -> None: """Stop streaming and clean instance""" self.reset_instance() -class Calls(quick_widgets.QuickWidget, cagou_widget.LiberviaDesktopKivyWidget): - remote_video = ObjectProperty() +class Calls( + quick_widgets.QuickWidget, + cagou_widget.LiberviaDesktopKivyWidget, + FilterBehavior +): + audio_muted = BooleanProperty(False) + call_layout = ObjectProperty() + call_screen = ObjectProperty() + fullscreen = BooleanProperty(False) + in_call = BooleanProperty(False) + incoming_call_dialog: dict[str, Widget] = {} + jid_selector = ObjectProperty() local_video = ObjectProperty() - use_header_input = True + remote_video = ObjectProperty() + ringtone: Sound | None = None + screen_manager = ObjectProperty() signals_registered = False - in_call = BooleanProperty(False) + use_header_input = True + video_muted = BooleanProperty(False) def __init__(self, host, target, profiles): quick_widgets.QuickWidget.__init__(self, G.host, target, profiles) @@ -844,31 +917,21 @@ ) self.header_input_add_extra(call_btn) self.webrtc = WebRTC(self, self.profile) - - if not self.__class__.signals_registered: - log.debug("registering signals") - G.host.register_signal( - "ice_candidates_new", - handler=self.__class__.ice_candidates_new_handler, - iface="plugin", - ) - G.host.register_signal( - "call_setup", handler=self.__class__.call_setup_handler, iface="plugin" - ) - G.host.register_signal( - "call_ended", handler=self.__class__.call_ended_handler, iface="plugin" - ) - G.host.register_action_handler( - C.META_TYPE_CALL, self.__class__.action_new_handler - ) - self.__class__.signals_registered = True - + self.previous_fullscreen = None + self.bind( + audio_muted=lambda __, value: self.webrtc.on_audio_mute(value), + video_muted=lambda __, value: self.webrtc.on_video_mute(value), + ) self.reset_instance() @property def sid(self): return self.webrtc.sid + def hang_up(self): + if self.sid is not None: + aio.run_async(self.toggle_call()) + async def toggle_call(self): """Toggle between making a call and hanging up. @@ -878,6 +941,16 @@ # Initiate the call log.info("initiating call") callee = jid.JID(self.header_input.text.strip()) + if not callee: + return + if not callee.is_valid(): + G.host.add_note( + _("Calls"), + _("Can't make a call: invalid destinee {}").format(repr(callee)), + level=C.XMLUI_DATA_LVL_ERROR + ) + return + self.webrtc.callee = callee await self.webrtc.setup_call("initiator") self.webrtc.start_pipeline() @@ -947,6 +1020,47 @@ await self.webrtc.end_call() self.reset_instance() + def on_in_call(self, instance, in_call: bool) -> None: + if in_call: + self.screen_manager.transition.direction = "up" + self.screen_manager.current = "call" + else: + self.fullscreen = False + self.screen_manager.transition.direction = "down" + self.screen_manager.current = "search" + + def on_fullscreen(self, instance, fullscreen: bool) -> None: + if fullscreen: + G.host.app.show_head_widget(False, animation=False) + self.call_layout.parent.remove_widget(self.call_layout) + G.host.show_extra_ui(self.call_layout) + self.previous_fullscreen = Window.fullscreen + Window.fullscreen = "auto" + else: + G.host.app.show_head_widget(True, animation=False) + G.host.close_ui() + self.call_screen.add_widget(self.call_layout) + Window.fullscreen = self.previous_fullscreen or False + + def on_header_wid_input(self): + aio.run_async(self.toggle_call()) + + def on_header_wid_input_complete(self, wid, text, **kwargs): + """we filter items when text is entered in input box""" + for layout in self.jid_selector.items_layouts: + self.do_filter( + layout, + text, + # we append nick to jid to filter on both + lambda c: c.jid + c.data.get('nick', ''), + width_cb=lambda c: c.base_width, + height_cb=lambda c: c.minimum_height, + continue_tests=[lambda c: not isinstance(c, common.ContactButton)]) + + def on_jid_select(self, contact_button): + self.header_input.text = contact_button.jid + aio.run_async(self.toggle_call()) + @classmethod def ice_candidates_new_handler( cls, sid: str, candidates_s: str, profile: str @@ -969,6 +1083,12 @@ @classmethod def call_ended_handler(cls, sid: str, data_s: str, profile: str) -> None: + if sid in cls.incoming_call_dialog: + dialog_wid = cls.incoming_call_dialog.pop(sid) + G.host.del_notif_widget(dialog_wid) + G.host.add_note(_("Call cancelled"), _("The call has been cancelled.")) + + for wid in G.host.get_visible_list(cls): if profile not in wid.profiles or sid != wid.sid: continue @@ -978,7 +1098,46 @@ def action_new_handler( cls, action_data: dict, action_id: str, security_limit: int, profile: str ) -> None: - for wid in G.host.get_visible_list(cls): - if profile not in wid.profiles: - continue - aio.run_async(wid.on_remote_call(action_data, action_id, profile)) + if profile in G.host.profiles: + if cls.ringtone is None: + cls.ringtone = SoundLoader.load( + str(Path(G.host.media_dir) / "sounds/notifications/ring_1.mp3") + ) + if cls.ringtone is not None: + cls.ringtone.play() + peer_jid = jid.JID(action_data["from_jid"]).bare + sid = action_data["session_id"] + notif_body = f"{peer_jid} is calling you." + notif_title = "Incoming call" + G.host.desktop_notif(notif_body, title=notif_title, duration=10) + + def on_call_answer(accepted, __): + del cls.incoming_call_dialog[sid] + if cls.ringtone is not None: + cls.ringtone.stop() + if accepted: + wid = G.host.do_action("calls", str(peer_jid), [profile]) + aio.run_async(wid.on_incoming_call(action_data, action_id, profile)) + else: + aio.run_async( + G.host.a_bridge.action_launch( + action_id, data_format.serialise({"cancelled": True}), profile + ) + ) + + dialog_wid = G.host.show_dialog( + notif_body, notif_title, type="yes/no", answer_cb=on_call_answer + ) + cls.incoming_call_dialog[sid] = dialog_wid + + +if G.host is not None: + log.debug("registering signals") + G.host.register_signal( + "ice_candidates_new", + handler=Calls.ice_candidates_new_handler, + iface="plugin", + ) + G.host.register_signal("call_setup", handler=Calls.call_setup_handler, iface="plugin") + G.host.register_signal("call_ended", handler=Calls.call_ended_handler, iface="plugin") + G.host.register_action_handler(C.META_TYPE_CALL, Calls.action_new_handler)