changeset 4204:879bad48cc2d

frontends (tools/webrtc): implement X11 desktop sharing: rel 433
author Goffi <goffi@goffi.org>
date Tue, 16 Jan 2024 10:42:00 +0100
parents 4af030d4d3d8
children 17a8168966f9
files libervia/frontends/tools/webrtc.py
diffstat 1 files changed, 130 insertions(+), 10 deletions(-) [+]
line wrap: on
line diff
--- a/libervia/frontends/tools/webrtc.py	Tue Jan 16 10:41:58 2024 +0100
+++ b/libervia/frontends/tools/webrtc.py	Tue Jan 16 10:42:00 2024 +0100
@@ -41,7 +41,13 @@
 from urllib.parse import quote_plus
 
 from libervia.backend.tools.common import data_format
-from libervia.frontends.tools import aio
+from libervia.frontends.tools import aio, display_servers
+
+current_server = display_servers.detect()
+if current_server == display_servers.X11:
+    # GSTreamer's ximagesrc documentation asks to run this function
+    import ctypes
+    ctypes.CDLL('libX11.so.6').XInitThreads()
 
 
 log = logging.getLogger(__name__)
@@ -83,6 +89,8 @@
         self.pipeline = None
         self._audio_muted = False
         self._video_muted = False
+        self._desktop_sharing = False
+        self.desktop_sharing_data = None
         self.sources = sources
         self.sinks = sinks
         if sinks == SINKS_APP:
@@ -115,6 +123,16 @@
             self.on_video_mute(muted)
 
     @property
+    def desktop_sharing(self):
+        return self._desktop_sharing
+
+    @desktop_sharing.setter
+    def desktop_sharing(self, active: bool) -> None:
+        if active != self._desktop_sharing:
+            self._desktop_sharing = active
+            self.on_desktop_switch(active)
+
+    @property
     def sdp_set(self):
         return self._sdp_set
 
@@ -384,7 +402,7 @@
         {extra_elt}
 
         {video_source_elt} name=video_src ! queue leaky=downstream ! video_selector.
-        videotestsrc is-live=true pattern=black ! queue leaky=downstream ! video_selector.
+        videotestsrc name=muted_src is-live=true pattern=black ! queue leaky=downstream ! video_selector.
 
         t.
         ! queue max-size-buffers=5 max-size-time=0 max-size-bytes=0 leaky=downstream
@@ -419,6 +437,7 @@
 
         self.webrtcbin = self.pipeline.get_by_name("sendrecv")
         self.video_src = self.pipeline.get_by_name("video_src")
+        self.muted_src = self.pipeline.get_by_name("muted_src")
         self.video_selector = self.pipeline.get_by_name("video_selector")
         self.audio_valve = self.pipeline.get_by_name("audio_valve")
 
@@ -888,22 +907,123 @@
         log.info("End of stream")
 
     def on_audio_mute(self, muted: bool) -> None:
+        """Handles (un)muting of audio.
+
+        @param muted: True if audio is muted.
+        """
         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:
+        """Handles (un)muting of video.
+
+        @param muted: True if video is muted.
+        """
         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)
+            current_source = None if muted else "desktop" if self.desktop_sharing else "camera"
+            self.switch_video_source(current_source)
             state = "muted" if muted else "unmuted"
-            log.info(f"video is now {state}")
+            log.info(f"Video is now {state}")
+
+    def on_desktop_switch(self, desktop_active: bool) -> None:
+        """Switches the video source between desktop and camera.
+
+        @param desktop_active: True if desktop must be active. False for camera.
+        """
+        if self.video_muted:
+            # Update the active source state but do not switch
+            self.desktop_sharing = desktop_active
+            return
+
+        source = "desktop" if desktop_active else "camera"
+        self.switch_video_source(source)
+        self.desktop_sharing = desktop_active
+
+    def switch_video_source(self, source: str|None) -> None:
+         """Activates the specified source while deactivating the others.
+
+         @param source: 'desktop', 'camera', 'muted' or None for muted source.
+         """
+         if source is None:
+             source = "muted"
+         if source not in ["camera", "muted", "desktop"]:
+             raise ValueError(
+                 f"Invalid source: {source!r}, use one of {'camera', 'muted', 'desktop'}"
+             )
+
+         self.pipeline.set_state(Gst.State.PAUSED)
+
+         # Create a new desktop source if necessary
+         if source == "desktop":
+             self._setup_desktop_source(self.desktop_sharing_data)
+
+         # Activate the chosen source and deactivate the others
+         for src_name in ["camera", "muted", "desktop"]:
+             src_element = self.pipeline.get_by_name(f"{src_name}_src")
+             if src_name == source:
+                 if src_element:
+                     src_element.set_state(Gst.State.PLAYING)
+             else:
+                 if src_element:
+                     src_element.set_state(Gst.State.NULL)
+                     if src_name == "desktop":
+                         self._remove_desktop_source(src_element)
+
+         # Set the video_selector active pad
+         pad_name = f"sink_{['camera', 'muted', 'desktop'].index(source)}"
+         pad = self.video_selector.get_static_pad(pad_name)
+         self.video_selector.props.active_pad = pad
+         self.pipeline.set_state(Gst.State.PLAYING)
+
+    def _setup_desktop_source(self, properties: dict[str, object]|None) -> None:
+        """Set up a new desktop source.
+
+        @param properties: The properties to set on the desktop source.
+        """
+        desktop_src = Gst.ElementFactory.make("ximagesrc", "desktop_src")
+        if properties is None:
+            properties = {}
+        for key, value in properties.items():
+            log.debug(f"setting ximagesrc property: {key!r}={value!r}")
+            desktop_src.set_property(key, value)
+        video_convert = Gst.ElementFactory.make("videoconvert", "desktop_videoconvert")
+        queue = Gst.ElementFactory.make("queue", "desktop_queue")
+        queue.set_property("leaky", "downstream")
+
+        self.pipeline.add(desktop_src)
+        self.pipeline.add(video_convert)
+        self.pipeline.add(queue)
+
+        desktop_src.link(video_convert)
+        video_convert.link(queue)
+
+        sink_pad_template = self.video_selector.get_pad_template("sink_%u")
+        sink_pad = self.video_selector.request_pad(sink_pad_template, None, None)
+        queue_src_pad = queue.get_static_pad("src")
+        queue_src_pad.link(sink_pad)
+
+        desktop_src.sync_state_with_parent()
+        video_convert.sync_state_with_parent()
+        queue.sync_state_with_parent()
+
+    def _remove_desktop_source(self, desktop_src: Gst.Element) -> None:
+        """Remove the desktop source from the pipeline.
+
+        @param desktop_src: The desktop source to remove.
+        """
+        # Remove elements for the desktop source
+        video_convert = self.pipeline.get_by_name("desktop_videoconvert")
+        queue = self.pipeline.get_by_name("desktop_queue")
+        if video_convert:
+            video_convert.set_state(Gst.State.NULL)
+            desktop_src.unlink(video_convert)
+            self.pipeline.remove(video_convert)
+        if queue:
+            queue.set_state(Gst.State.NULL)
+            self.pipeline.remove(queue)
+        self.pipeline.remove(desktop_src)
 
     async def end_call(self) -> None:
         """Stop streaming and clean instance"""