changeset 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 bbef1a413515
children f6b8300e8234
files libervia/desktop_kivy/__init__.py libervia/desktop_kivy/plugins/plugin_wid_calls.kv libervia/desktop_kivy/plugins/plugin_wid_calls.py
diffstat 3 files changed, 306 insertions(+), 56 deletions(-) [+]
line wrap: on
line diff
--- 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
--- 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 <http://www.gnu.org/licenses/>.
 
+<CallControlButton>:
+    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"
 
 <Calls>:
+    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:
 
 
 <CallButton>:
--- 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)