diff libervia/desktop_kivy/plugins/plugin_wid_calls.py @ 506:0480f883f0a6

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
author Goffi <goffi@goffi.org>
date Wed, 25 Oct 2023 15:28:44 +0200
parents f387992d8e37
children f0ce49b360c8
line wrap: on
line diff
--- 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)