# HG changeset patch # User Goffi # Date 1705398120 -3600 # Node ID 879bad48cc2dc115b4d49342dcd641d60d34d13d # Parent 4af030d4d3d83b441400ba86a8e417a98ca9fde5 frontends (tools/webrtc): implement X11 desktop sharing: rel 433 diff -r 4af030d4d3d8 -r 879bad48cc2d libervia/frontends/tools/webrtc.py --- 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"""