comparison libervia/frontends/tools/webrtc.py @ 4205:17a8168966f9

frontends (tools/webrtc): implement screensharing for Wayland + bug fixes: - Freedesktop Desktop Screenshare port is now used when Wayland is detected (needs `xdg-desktop-portal` with the implementation corresponding to desktop environment). - Add a binding feature to feedback state to application (e.g. if desktop sharing is cancelled from desktop environment, or at portal's permission request level). - fix misnaming of video source (was wrongly named `camera` instead of `video`). - fix desktop sharing pad selection in `input-selector` when it has been added once, then removed, then added again. rel 434
author Goffi <goffi@goffi.org>
date Thu, 18 Jan 2024 23:29:25 +0100
parents 879bad48cc2d
children fe29fbdabce6
comparison
equal deleted inserted replaced
4204:879bad48cc2d 4205:17a8168966f9
34 ) 34 )
35 import asyncio 35 import asyncio
36 from dataclasses import dataclass 36 from dataclasses import dataclass
37 from datetime import datetime 37 from datetime import datetime
38 import logging 38 import logging
39 from random import randint
39 import re 40 import re
40 from typing import Callable 41 from typing import Callable
41 from urllib.parse import quote_plus 42 from urllib.parse import quote_plus
42 43
43 from libervia.backend.tools.common import data_format 44 from libervia.backend.tools.common import data_format
59 SINKS_APP = "app" 60 SINKS_APP = "app"
60 SINKS_AUTO = "auto" 61 SINKS_AUTO = "auto"
61 SINKS_TEST = "test" 62 SINKS_TEST = "test"
62 63
63 64
65 class ScreenshareError(Exception):
66 pass
67
68
64 @dataclass 69 @dataclass
65 class AppSinkData: 70 class AppSinkData:
66 local_video_cb: Callable 71 local_video_cb: Callable
67 remote_video_cb: Callable 72 remote_video_cb: Callable
73
74
75 class DesktopPortal:
76
77 def __init__(self, webrtc: "WebRTC"):
78 import dbus
79 from dbus.mainloop.glib import DBusGMainLoop
80 # we want monitors + windows, see https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.ScreenCast.html#org-freedesktop-portal-screencast-availablesourcetypes
81 self.dbus = dbus
82 self.webrtc = webrtc
83 self.sources_type = dbus.UInt32(7)
84 DBusGMainLoop(set_as_default=True)
85 self.session_bus = dbus.SessionBus()
86 portal_object = self.session_bus.get_object(
87 'org.freedesktop.portal.Desktop',
88 '/org/freedesktop/portal/desktop'
89 )
90 self.screencast_interface = dbus.Interface(
91 portal_object,
92 'org.freedesktop.portal.ScreenCast'
93 )
94 self.session_interface = None
95 self.session_signal = None
96 self.handle_counter = 0
97 self.session_handle = None
98 self.stream_data: dict|None = None
99
100 @property
101 def handle_token(self):
102 self.handle_counter += 1
103 return f"libervia{self.handle_counter}"
104
105 def on_session_closed(self, details: dict) -> None:
106 if self.session_interface is not None:
107 self.session_interface = None
108 self.webrtc.desktop_sharing = False
109 if self.session_signal is not None:
110 self.session_signal.remove()
111 self.session_signal = None
112
113
114 async def dbus_call(self, method_name: str, *args) -> dict:
115 """Call a screenshare portal method
116
117 This method handle the signal response.
118 @param method_name: method to call
119 @param args: extra args
120 `handle_token` will be automatically added to the last arg (option dict)
121 @return: method result
122 """
123 if self.session_handle is not None:
124 self.end_screenshare()
125 method = getattr(self.screencast_interface, method_name)
126 options = args[-1]
127 reply_fut = asyncio.Future()
128 signal_fut = asyncio.Future()
129 # cf. https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html
130 handle_token = self.handle_token
131 sender = self.session_bus.get_unique_name().replace(".", "_")[1:]
132 path = f"/org/freedesktop/portal/desktop/request/{sender}/{handle_token}"
133 signal_match = None
134
135 def on_signal(response, results):
136 assert signal_match is not None
137 signal_match.remove()
138 if response == 0:
139 signal_fut.set_result(results)
140 elif response == 1:
141 signal_fut.set_exception(
142 exceptions.CancelError("Cancelled by user.")
143 )
144 else:
145 signal_fut.set_exception(ScreenshareError(
146 f"Can't get signal result"
147 ))
148
149 signal_match = self.session_bus.add_signal_receiver(
150 on_signal,
151 signal_name="Response",
152 dbus_interface="org.freedesktop.portal.Request",
153 path=path
154 )
155
156 options["handle_token"] = handle_token
157
158 method(
159 *args,
160 reply_handler=reply_fut.set_result,
161 error_handler=reply_fut.set_exception
162 )
163 try:
164 await reply_fut
165 except Exception as e:
166 raise ScreenshareError(f"Can't ask screenshare permission: {e}")
167 return await signal_fut
168
169 async def request_screenshare(self) -> dict:
170 session_data = await self.dbus_call(
171 "CreateSession",
172 {
173 "session_handle_token": str(randint(1, 2**32)),
174 }
175 )
176 try:
177 session_handle = session_data["session_handle"]
178 except KeyError:
179 raise ScreenshareError("Can't get session handle")
180 self.session_handle = session_handle
181
182
183 await self.dbus_call(
184 "SelectSources",
185 session_handle,
186 {
187 "multiple": True,
188 "types": self.sources_type,
189 "modal": True
190 }
191 )
192 screenshare_data = await self.dbus_call(
193 "Start",
194 session_handle,
195 "",
196 {}
197 )
198
199 session_object = self.session_bus.get_object(
200 'org.freedesktop.portal.Desktop',
201 session_handle
202 )
203 self.session_interface = self.dbus.Interface(
204 session_object,
205 'org.freedesktop.portal.Session'
206 )
207
208 self.session_signal = self.session_bus.add_signal_receiver(
209 self.on_session_closed,
210 signal_name="Closed",
211 dbus_interface="org.freedesktop.portal.Session",
212 path=session_handle
213 )
214
215 try:
216 node_id, stream_data = screenshare_data["streams"][0]
217 source_type = int(stream_data["source_type"])
218 except (IndexError, KeyError):
219 raise ScreenshareError("Can't parse stream data")
220 self.stream_data = stream_data = {
221 "session_handle": session_handle,
222 "node_id": node_id,
223 "source_type": source_type
224 }
225 try:
226 height = int(stream_data["size"][0])
227 weight = int(stream_data["size"][1])
228 except (IndexError, KeyError):
229 pass
230 else:
231 stream_data["size"] = (height, weight)
232
233 return self.stream_data
234
235 def end_screenshare(self) -> None:
236 """Close a running screenshare session, if any."""
237 if self.session_interface is None:
238 return
239 self.session_interface.Close()
240 self.on_session_closed({})
68 241
69 242
70 class WebRTC: 243 class WebRTC:
71 """GSTreamer based WebRTC implementation for audio and video communication. 244 """GSTreamer based WebRTC implementation for audio and video communication.
72 245
98 elif appsink_data is not None: 271 elif appsink_data is not None:
99 raise exceptions.InternalError( 272 raise exceptions.InternalError(
100 "appsink_data can only be used for SINKS_APP sinks" 273 "appsink_data can only be used for SINKS_APP sinks"
101 ) 274 )
102 self.reset_cb = reset_cb 275 self.reset_cb = reset_cb
276 if current_server == display_servers.WAYLAND:
277 self.desktop_portal = DesktopPortal(self)
278 else:
279 self.desktop_portal = None
103 self.reset_instance() 280 self.reset_instance()
104 281
105 @property 282 @property
106 def audio_muted(self): 283 def audio_muted(self):
107 return self._audio_muted 284 return self._audio_muted
129 @desktop_sharing.setter 306 @desktop_sharing.setter
130 def desktop_sharing(self, active: bool) -> None: 307 def desktop_sharing(self, active: bool) -> None:
131 if active != self._desktop_sharing: 308 if active != self._desktop_sharing:
132 self._desktop_sharing = active 309 self._desktop_sharing = active
133 self.on_desktop_switch(active) 310 self.on_desktop_switch(active)
311 try:
312 cb = self.bindings["desktop_sharing"]
313 except KeyError:
314 pass
315 else:
316 cb(active)
134 317
135 @property 318 @property
136 def sdp_set(self): 319 def sdp_set(self):
137 return self._sdp_set 320 return self._sdp_set
138 321
158 @property 341 @property
159 def media_types_inv(self) -> dict: 342 def media_types_inv(self) -> dict:
160 if self._media_types_inv is None: 343 if self._media_types_inv is None:
161 raise Exception("self._media_types_inv should not be None!") 344 raise Exception("self._media_types_inv should not be None!")
162 return self._media_types_inv 345 return self._media_types_inv
346
347 def bind(self, **kwargs: Callable) -> None:
348 self.bindings.clear()
349 for key, cb in kwargs.items():
350 if key not in ("desktop_sharing",):
351 raise ValueError(
352 'Only "desktop_sharing" is currently allowed for binding'
353 )
354 self.bindings[key] = cb
355
163 356
164 def generate_dot_file( 357 def generate_dot_file(
165 self, 358 self,
166 filename: str = "pipeline", 359 filename: str = "pipeline",
167 details: Gst.DebugGraphDetails = Gst.DebugGraphDetails.ALL, 360 details: Gst.DebugGraphDetails = Gst.DebugGraphDetails.ALL,
339 } 532 }
340 self._media_types = None 533 self._media_types = None
341 self._media_types_inv = None 534 self._media_types_inv = None
342 self.audio_valve = None 535 self.audio_valve = None
343 self.video_valve = None 536 self.video_valve = None
537 if self.desktop_portal is not None:
538 self.desktop_portal.end_screenshare()
539 self.desktop_sharing = False
540 self.desktop_sink_pad = None
541 self.bindings = {}
344 if self.reset_cb is not None: 542 if self.reset_cb is not None:
345 self.reset_cb() 543 self.reset_cb()
346 544
347 545
348 async def setup_call( 546 async def setup_call(
920 """Handles (un)muting of video. 1118 """Handles (un)muting of video.
921 1119
922 @param muted: True if video is muted. 1120 @param muted: True if video is muted.
923 """ 1121 """
924 if self.video_selector is not None: 1122 if self.video_selector is not None:
925 current_source = None if muted else "desktop" if self.desktop_sharing else "camera" 1123 current_source = None if muted else "desktop" if self.desktop_sharing else "video"
926 self.switch_video_source(current_source) 1124 self.switch_video_source(current_source)
927 state = "muted" if muted else "unmuted" 1125 state = "muted" if muted else "unmuted"
928 log.info(f"Video is now {state}") 1126 log.info(f"Video is now {state}")
929 1127
930 def on_desktop_switch(self, desktop_active: bool) -> None: 1128 def on_desktop_switch(self, desktop_active: bool) -> None:
931 """Switches the video source between desktop and camera. 1129 """Switches the video source between desktop and video.
932 1130
933 @param desktop_active: True if desktop must be active. False for camera. 1131 @param desktop_active: True if desktop must be active. False for video.
934 """ 1132 """
1133 if desktop_active and self.desktop_portal is not None:
1134 aio.run_async(self.on_desktop_switch_portal(desktop_active))
1135 else:
1136 self.do_desktop_switch(desktop_active)
1137
1138 async def on_desktop_switch_portal(self, desktop_active: bool) -> None:
1139 """Call freedesktop screenshare portal and the activate the shared stream"""
1140 assert self.desktop_portal is not None
1141 try:
1142 screenshare_data = await self.desktop_portal.request_screenshare()
1143 except exceptions.CancelError:
1144 self.desktop_sharing = False
1145 return
1146 self.desktop_sharing_data = {
1147 "path": str(screenshare_data["node_id"])
1148 }
1149 self.do_desktop_switch(desktop_active)
1150
1151 def do_desktop_switch(self, desktop_active: bool) -> None:
935 if self.video_muted: 1152 if self.video_muted:
936 # Update the active source state but do not switch 1153 # Update the active source state but do not switch
937 self.desktop_sharing = desktop_active 1154 self.desktop_sharing = desktop_active
938 return 1155 return
939 1156
940 source = "desktop" if desktop_active else "camera" 1157 source = "desktop" if desktop_active else "video"
941 self.switch_video_source(source) 1158 self.switch_video_source(source)
942 self.desktop_sharing = desktop_active 1159 self.desktop_sharing = desktop_active
943 1160
944 def switch_video_source(self, source: str|None) -> None: 1161 def switch_video_source(self, source: str|None) -> None:
945 """Activates the specified source while deactivating the others. 1162 """Activates the specified source while deactivating the others.
946 1163
947 @param source: 'desktop', 'camera', 'muted' or None for muted source. 1164 @param source: 'desktop', 'video', 'muted' or None for muted source.
948 """ 1165 """
949 if source is None: 1166 if source is None:
950 source = "muted" 1167 source = "muted"
951 if source not in ["camera", "muted", "desktop"]: 1168 if source not in ["video", "muted", "desktop"]:
952 raise ValueError( 1169 raise ValueError(
953 f"Invalid source: {source!r}, use one of {'camera', 'muted', 'desktop'}" 1170 f"Invalid source: {source!r}, use one of {'video', 'muted', 'desktop'}"
954 ) 1171 )
955 1172
956 self.pipeline.set_state(Gst.State.PAUSED) 1173 self.pipeline.set_state(Gst.State.PAUSED)
957 1174
958 # Create a new desktop source if necessary 1175 # Create a new desktop source if necessary
959 if source == "desktop": 1176 if source == "desktop":
960 self._setup_desktop_source(self.desktop_sharing_data) 1177 self._setup_desktop_source(self.desktop_sharing_data)
961 1178
962 # Activate the chosen source and deactivate the others 1179 # Activate the chosen source and deactivate the others
963 for src_name in ["camera", "muted", "desktop"]: 1180 for src_name in ["video", "muted", "desktop"]:
964 src_element = self.pipeline.get_by_name(f"{src_name}_src") 1181 src_element = self.pipeline.get_by_name(f"{src_name}_src")
965 if src_name == source: 1182 if src_name == source:
966 if src_element: 1183 if src_element:
967 src_element.set_state(Gst.State.PLAYING) 1184 src_element.set_state(Gst.State.PLAYING)
968 else: 1185 else:
969 if src_element: 1186 if src_element:
970 src_element.set_state(Gst.State.NULL) 1187 if src_name == "desktop":
971 if src_name == "desktop": 1188 self._remove_desktop_source(src_element)
972 self._remove_desktop_source(src_element) 1189 else:
973 1190 src_element.set_state(Gst.State.NULL)
974 # Set the video_selector active pad 1191
975 pad_name = f"sink_{['camera', 'muted', 'desktop'].index(source)}" 1192 # Set the video_selector active pad
976 pad = self.video_selector.get_static_pad(pad_name) 1193 if source == "desktop":
977 self.video_selector.props.active_pad = pad 1194 if self.desktop_sink_pad:
978 self.pipeline.set_state(Gst.State.PLAYING) 1195 pad = self.desktop_sink_pad
1196 else:
1197 log.error(f"No desktop pad available")
1198 pad = None
1199 else:
1200 pad_name = f"sink_{['video', 'muted'].index(source)}"
1201 pad = self.video_selector.get_static_pad(pad_name)
1202
1203 if pad is not None:
1204 self.video_selector.props.active_pad = pad
1205
1206 self.pipeline.set_state(Gst.State.PLAYING)
979 1207
980 def _setup_desktop_source(self, properties: dict[str, object]|None) -> None: 1208 def _setup_desktop_source(self, properties: dict[str, object]|None) -> None:
981 """Set up a new desktop source. 1209 """Set up a new desktop source.
982 1210
983 @param properties: The properties to set on the desktop source. 1211 @param properties: The properties to set on the desktop source.
984 """ 1212 """
985 desktop_src = Gst.ElementFactory.make("ximagesrc", "desktop_src") 1213 source_elt = "ximagesrc" if self.desktop_portal is None else "pipewiresrc"
1214 desktop_src = Gst.ElementFactory.make(source_elt, "desktop_src")
986 if properties is None: 1215 if properties is None:
987 properties = {} 1216 properties = {}
988 for key, value in properties.items(): 1217 for key, value in properties.items():
989 log.debug(f"setting ximagesrc property: {key!r}={value!r}") 1218 log.debug(f"setting {source_elt} property: {key!r}={value!r}")
990 desktop_src.set_property(key, value) 1219 desktop_src.set_property(key, value)
991 video_convert = Gst.ElementFactory.make("videoconvert", "desktop_videoconvert") 1220 video_convert = Gst.ElementFactory.make("videoconvert", "desktop_videoconvert")
992 queue = Gst.ElementFactory.make("queue", "desktop_queue") 1221 queue = Gst.ElementFactory.make("queue", "desktop_queue")
993 queue.set_property("leaky", "downstream") 1222 queue.set_property("leaky", "downstream")
994 1223
998 1227
999 desktop_src.link(video_convert) 1228 desktop_src.link(video_convert)
1000 video_convert.link(queue) 1229 video_convert.link(queue)
1001 1230
1002 sink_pad_template = self.video_selector.get_pad_template("sink_%u") 1231 sink_pad_template = self.video_selector.get_pad_template("sink_%u")
1003 sink_pad = self.video_selector.request_pad(sink_pad_template, None, None) 1232 self.desktop_sink_pad = self.video_selector.request_pad(sink_pad_template, None, None)
1004 queue_src_pad = queue.get_static_pad("src") 1233 queue_src_pad = queue.get_static_pad("src")
1005 queue_src_pad.link(sink_pad) 1234 queue_src_pad.link(self.desktop_sink_pad)
1006 1235
1007 desktop_src.sync_state_with_parent() 1236 desktop_src.sync_state_with_parent()
1008 video_convert.sync_state_with_parent() 1237 video_convert.sync_state_with_parent()
1009 queue.sync_state_with_parent() 1238 queue.sync_state_with_parent()
1010 1239
1014 @param desktop_src: The desktop source to remove. 1243 @param desktop_src: The desktop source to remove.
1015 """ 1244 """
1016 # Remove elements for the desktop source 1245 # Remove elements for the desktop source
1017 video_convert = self.pipeline.get_by_name("desktop_videoconvert") 1246 video_convert = self.pipeline.get_by_name("desktop_videoconvert")
1018 queue = self.pipeline.get_by_name("desktop_queue") 1247 queue = self.pipeline.get_by_name("desktop_queue")
1248
1019 if video_convert: 1249 if video_convert:
1020 video_convert.set_state(Gst.State.NULL) 1250 video_convert.set_state(Gst.State.NULL)
1021 desktop_src.unlink(video_convert) 1251 desktop_src.unlink(video_convert)
1022 self.pipeline.remove(video_convert) 1252 self.pipeline.remove(video_convert)
1253
1023 if queue: 1254 if queue:
1024 queue.set_state(Gst.State.NULL) 1255 queue.set_state(Gst.State.NULL)
1025 self.pipeline.remove(queue) 1256 self.pipeline.remove(queue)
1257
1258 desktop_src.set_state(Gst.State.NULL)
1026 self.pipeline.remove(desktop_src) 1259 self.pipeline.remove(desktop_src)
1260
1261 # Release the pad associated with the desktop source
1262 if self.desktop_sink_pad:
1263 self.video_selector.release_request_pad(self.desktop_sink_pad)
1264 self.desktop_sink_pad = None
1265
1266 if self.desktop_portal is not None:
1267 self.desktop_portal.end_screenshare()
1027 1268
1028 async def end_call(self) -> None: 1269 async def end_call(self) -> None:
1029 """Stop streaming and clean instance""" 1270 """Stop streaming and clean instance"""
1030 self.reset_instance() 1271 self.reset_instance()